Skip to content

Commit 100dc2b

Browse files
authored
refactor(aria/accordion): Replace ContentChildren with manual registration of triggers (#33027)
* refactor(multiple): add utils to aria/private and consolidate sortDirectives usage * refactor(aria/accordion): Add element reference to AccordionPanel * test(aria/accordion): Add tests for shuffled and removed/replaced triggers and factor and clean up test methods * fix(aria/accordion): Fix up toggle icon display across examples * refactor(aria/accordion): Replace ContentChildren with manual registration of triggers
1 parent 2a13f69 commit 100dc2b

30 files changed

+642
-414
lines changed

goldens/aria/accordion/index.api.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,13 @@ export class AccordionGroup {
2525
expandAll(): void;
2626
readonly multiExpandable: _angular_core.InputSignalWithTransform<boolean, unknown>;
2727
readonly _pattern: AccordionGroupPattern;
28+
_registerTrigger(trigger: AccordionTrigger): void;
2829
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
2930
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
31+
_unregisterTrigger(trigger: AccordionTrigger): void;
3032
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3133
// (undocumented)
32-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionGroup, "[ngAccordionGroup]", ["ngAccordionGroup"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "multiExpandable": { "alias": "multiExpandable"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; }, {}, ["_triggers"], never, true, never>;
34+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionGroup, "[ngAccordionGroup]", ["ngAccordionGroup"], { "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "multiExpandable": { "alias": "multiExpandable"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; }, {}, never, never, true, never>;
3335
// (undocumented)
3436
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionGroup, never>;
3537
}
@@ -38,6 +40,7 @@ export class AccordionGroup {
3840
export class AccordionPanel {
3941
constructor();
4042
collapse(): void;
43+
readonly element: HTMLElement;
4144
expand(): void;
4245
readonly id: _angular_core.InputSignal<string>;
4346
_pattern?: AccordionTriggerPattern;
@@ -50,22 +53,25 @@ export class AccordionPanel {
5053
}
5154

5255
// @public
53-
export class AccordionTrigger implements OnInit {
56+
export class AccordionTrigger implements OnInit, OnDestroy {
5457
readonly active: _angular_core.Signal<boolean>;
5558
collapse(): void;
5659
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
5760
readonly element: HTMLElement;
5861
expand(): void;
5962
readonly expanded: _angular_core.ModelSignal<boolean>;
6063
readonly id: _angular_core.InputSignal<string>;
64+
readonly index: _angular_core.InputSignal<number | undefined>;
65+
// (undocumented)
66+
ngOnDestroy(): void;
6167
// (undocumented)
6268
ngOnInit(): void;
6369
readonly panel: _angular_core.InputSignal<AccordionPanel>;
6470
readonly panelId: _angular_core.Signal<string>;
6571
_pattern: AccordionTriggerPattern;
6672
toggle(): void;
6773
// (undocumented)
68-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionTrigger, "[ngAccordionTrigger]", ["ngAccordionTrigger"], { "panel": { "alias": "panel"; "required": true; "isSignal": true; }; "id": { "alias": "id"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; }, never, never, true, never>;
74+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionTrigger, "[ngAccordionTrigger]", ["ngAccordionTrigger"], { "panel": { "alias": "panel"; "required": true; "isSignal": true; }; "id": { "alias": "id"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "index": { "alias": "index"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; }, never, never, true, never>;
6975
// (undocumented)
7076
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionTrigger, never>;
7177
}

goldens/aria/private/index.api.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,12 @@ export class GridRowPattern {
418418
rowIndex: SignalLike<number | undefined>;
419419
}
420420

421+
// @public (undocumented)
422+
export interface HasElement {
423+
// (undocumented)
424+
element: HTMLElement;
425+
}
426+
421427
// @public (undocumented)
422428
export function linkedSignal<T>(sourceFn: () => T): WritableSignalLike<T>;
423429

@@ -650,6 +656,9 @@ export function signal<T>(initialValue: T): WritableSignalLike<T>;
650656
// @public (undocumented)
651657
export type SignalLike<T> = () => T;
652658

659+
// @public
660+
export function sortDirectives(a: HasElement, b: HasElement): 1 | -1;
661+
653662
// @public
654663
export interface TabInputs extends Omit<ListNavigationItem, 'index'>, Omit<ExpansionItem, 'expandable'> {
655664
tablist: SignalLike<TabListPattern>;

src/aria/accordion/accordion-content.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {DeferredContent} from '../private';
1818
* by only creating the content when the panel is first expanded.
1919
*
2020
* ```html
21-
* <div ngAccordionPanel panelId="unique-id-1">
21+
* <div ngAccordionPanel>
2222
* <ng-template ngAccordionContent>
2323
* <p>This is the content that will be displayed inside the panel.</p>
2424
* </ng-template>

src/aria/accordion/accordion-group.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,12 @@ import {
1111
ElementRef,
1212
booleanAttribute,
1313
computed,
14-
contentChildren,
1514
inject,
1615
input,
1716
signal,
1817
} from '@angular/core';
1918
import {Directionality} from '@angular/cdk/bidi';
20-
import {AccordionGroupPattern} from '../private';
19+
import {AccordionGroupPattern, sortDirectives} from '../private';
2120
import {AccordionTrigger} from './accordion-trigger';
2221
import {ACCORDION_GROUP} from './accordion-tokens';
2322

@@ -75,10 +74,23 @@ export class AccordionGroup {
7574
readonly element = this._elementRef.nativeElement as HTMLElement;
7675

7776
/** The AccordionTriggers nested inside this group. */
78-
private readonly _triggers = contentChildren(AccordionTrigger, {descendants: true});
77+
private readonly _triggers = signal(new Set<AccordionTrigger>());
78+
79+
/** The AccordionTriggers nested inside this group. */
80+
private readonly _sortedTriggers = computed(() => {
81+
const triggers = [...this._triggers()] as AccordionTrigger[];
82+
const sortFn =
83+
triggers[0]?.index() === undefined
84+
? sortDirectives
85+
: (a: AccordionTrigger, b: AccordionTrigger) => a.index()! - b.index()!;
86+
87+
return triggers.sort(sortFn);
88+
});
7989

8090
/** The corresponding patterns for the accordion triggers. */
81-
private readonly _triggerPatterns = computed(() => this._triggers().map(t => t._pattern));
91+
private readonly _triggerPatterns = computed(() => {
92+
return this._sortedTriggers().map(t => t._pattern);
93+
});
8294

8395
/** The text direction (ltr or rtl). */
8496
readonly textDirection = inject(Directionality).valueSignal;
@@ -117,4 +129,16 @@ export class AccordionGroup {
117129
collapseAll() {
118130
this._pattern.collapseAll();
119131
}
132+
133+
/** Internal method to register each trigger as we can not use contentChildren. */
134+
_registerTrigger(trigger: AccordionTrigger) {
135+
this._triggers().add(trigger);
136+
this._triggers.set(new Set(this._triggers()));
137+
}
138+
139+
/** Internal method to unregister each trigger as we can not use contentChildren. */
140+
_unregisterTrigger(trigger: AccordionTrigger) {
141+
this._triggers().delete(trigger);
142+
this._triggers.set(new Set(this._triggers()));
143+
}
120144
}

src/aria/accordion/accordion-panel.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {Directive, afterRenderEffect, computed, inject, input} from '@angular/core';
9+
import {Directive, ElementRef, afterRenderEffect, computed, inject, input} from '@angular/core';
1010
import {_IdGenerator} from '@angular/cdk/a11y';
1111
import {DeferredContentAware, AccordionTriggerPattern} from '../private';
1212

@@ -48,6 +48,12 @@ import {DeferredContentAware, AccordionTriggerPattern} from '../private';
4848
},
4949
})
5050
export class AccordionPanel {
51+
/** A reference to the trigger element. */
52+
private readonly _elementRef = inject(ElementRef);
53+
54+
/** A reference to the trigger element. */
55+
readonly element = this._elementRef.nativeElement as HTMLElement;
56+
5157
/** The DeferredContentAware host directive. */
5258
private readonly _deferredContentAware = inject(DeferredContentAware);
5359

src/aria/accordion/accordion-trigger.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import {
1010
Directive,
1111
ElementRef,
12+
OnDestroy,
1213
OnInit,
1314
booleanAttribute,
1415
computed,
@@ -53,7 +54,7 @@ import {AccordionPanel} from './accordion-panel';
5354
'[attr.tabindex]': '_pattern.tabIndex()',
5455
},
5556
})
56-
export class AccordionTrigger implements OnInit {
57+
export class AccordionTrigger implements OnInit, OnDestroy {
5758
/** A reference to the trigger element. */
5859
private readonly _elementRef = inject(ElementRef);
5960

@@ -69,12 +70,15 @@ export class AccordionTrigger implements OnInit {
6970
/** The unique identifier for the trigger. */
7071
readonly id = input(inject(_IdGenerator).getId('ng-accordion-trigger-', true));
7172

72-
/** The unique identifier for the correspondingtrigger panel. */
73+
/** The unique identifier for the corresponding trigger panel. */
7374
readonly panelId = computed(() => this.panel().id());
7475

7576
/** Whether the trigger is disabled. */
7677
readonly disabled = input(false, {transform: booleanAttribute});
7778

79+
/** The index of the trigger within the accordion group. */
80+
readonly index = input<number>();
81+
7882
/** Whether the corresponding panel is expanded. */
7983
readonly expanded = model<boolean>(false);
8084

@@ -93,6 +97,14 @@ export class AccordionTrigger implements OnInit {
9397
});
9498

9599
this.panel()._pattern = this._pattern;
100+
101+
this._accordionGroup._registerTrigger(this);
102+
}
103+
104+
ngOnDestroy() {
105+
this.panel()._pattern = undefined;
106+
107+
this._accordionGroup._unregisterTrigger(this);
96108
}
97109

98110
/** Expands this item. */

0 commit comments

Comments
 (0)