Skip to content

Commit 20439c6

Browse files
committed
refactor(multiple): implement generic child discovery with SortedCollection
1 parent fccc2ef commit 20439c6

25 files changed

Lines changed: 559 additions & 231 deletions

goldens/aria/accordion/index.api.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import * as _angular_cdk_bidi from '@angular/cdk/bidi';
88
import * as _angular_core from '@angular/core';
99
import { OnDestroy } from '@angular/core';
1010
import { OnInit } from '@angular/core';
11+
import { Signal } from '@angular/core';
1112

1213
// @public
1314
export class AccordionContent {
@@ -18,17 +19,19 @@ export class AccordionContent {
1819
}
1920

2021
// @public
21-
export class AccordionGroup {
22+
export class AccordionGroup implements OnDestroy {
23+
constructor();
2224
collapseAll(): void;
25+
readonly _collection: SortedCollection<AccordionTrigger>;
2326
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
2427
readonly element: HTMLElement;
2528
expandAll(): void;
2629
readonly multiExpandable: _angular_core.InputSignalWithTransform<boolean, unknown>;
30+
// (undocumented)
31+
ngOnDestroy(): void;
2732
readonly _pattern: AccordionGroupPattern;
28-
_registerTrigger(trigger: AccordionTrigger): void;
2933
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
3034
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
31-
_unregisterTrigger(trigger: AccordionTrigger): void;
3235
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3336
// (undocumented)
3437
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>;
@@ -61,7 +64,6 @@ export class AccordionTrigger implements OnInit, OnDestroy {
6164
expand(): void;
6265
readonly expanded: _angular_core.ModelSignal<boolean>;
6366
readonly id: _angular_core.InputSignal<string>;
64-
readonly index: _angular_core.InputSignal<number | undefined>;
6567
// (undocumented)
6668
ngOnDestroy(): void;
6769
// (undocumented)
@@ -71,7 +73,7 @@ export class AccordionTrigger implements OnInit, OnDestroy {
7173
_pattern: AccordionTriggerPattern;
7274
toggle(): void;
7375
// (undocumented)
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>;
76+
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>;
7577
// (undocumented)
7678
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionTrigger, never>;
7779
}

goldens/aria/listbox/index.api.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,22 @@
66

77
import * as _angular_cdk_bidi from '@angular/cdk/bidi';
88
import * as _angular_core from '@angular/core';
9+
import { OnDestroy } from '@angular/core';
10+
import { OnInit } from '@angular/core';
11+
import { Signal } from '@angular/core';
912

1013
// @public
11-
export class Listbox<V> {
14+
export class Listbox<V> implements OnDestroy {
1215
constructor();
16+
readonly _collection: SortedCollection<Option_2<V>>;
1317
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
1418
readonly element: HTMLElement;
1519
readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">;
1620
gotoFirst(): void;
1721
readonly id: _angular_core.InputSignal<string>;
18-
protected readonly items: _angular_core.Signal<OptionPattern<V>[]>;
1922
readonly multi: _angular_core.InputSignalWithTransform<boolean, unknown>;
23+
// (undocumented)
24+
ngOnDestroy(): void;
2025
readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">;
2126
readonly _pattern: ListboxPattern<V>;
2227
readonly readonly: _angular_core.InputSignalWithTransform<boolean, unknown>;
@@ -29,18 +34,22 @@ export class Listbox<V> {
2934
readonly value: _angular_core.ModelSignal<V[]>;
3035
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3136
// (undocumented)
32-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Listbox<any>, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, ["_options"], never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>;
37+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Listbox<any>, "[ngListbox]", ["ngListbox"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "orientation": { "alias": "orientation"; "required": false; "isSignal": true; }; "multi": { "alias": "multi"; "required": false; "isSignal": true; }; "wrap": { "alias": "wrap"; "required": false; "isSignal": true; }; "softDisabled": { "alias": "softDisabled"; "required": false; "isSignal": true; }; "focusMode": { "alias": "focusMode"; "required": false; "isSignal": true; }; "selectionMode": { "alias": "selectionMode"; "required": false; "isSignal": true; }; "typeaheadDelay": { "alias": "typeaheadDelay"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "value": { "alias": "value"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, [{ directive: typeof ComboboxPopup; inputs: {}; outputs: {}; }]>;
3338
// (undocumented)
3439
static ɵfac: _angular_core.ɵɵFactoryDeclaration<Listbox<any>, never>;
3540
}
3641

3742
// @public
38-
class Option_2<V> {
43+
class Option_2<V> implements OnInit, OnDestroy {
3944
readonly active: _angular_core.Signal<boolean>;
4045
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
4146
readonly element: HTMLElement;
4247
readonly id: _angular_core.InputSignal<string>;
4348
readonly label: _angular_core.InputSignal<string | undefined>;
49+
// (undocumented)
50+
ngOnDestroy(): void;
51+
// (undocumented)
52+
ngOnInit(): void;
4453
readonly _pattern: OptionPattern<V>;
4554
readonly selected: _angular_core.Signal<boolean | undefined>;
4655
readonly value: _angular_core.InputSignal<V>;

src/aria/accordion/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ ng_project(
1111
deps = [
1212
"//:node_modules/@angular/core",
1313
"//src/aria/private",
14+
"//src/aria/private/utils",
1415
"//src/cdk/a11y",
1516
"//src/cdk/bidi",
1617
"//src/cdk/testing",
@@ -27,6 +28,7 @@ ng_project(
2728
":accordion",
2829
"//:node_modules/@angular/core",
2930
"//:node_modules/@angular/platform-browser",
31+
"//src/aria/private/testing",
3032
"//src/cdk/testing",
3133
"//src/cdk/testing/private",
3234
"//src/cdk/testing/testbed",

src/aria/accordion/accordion-group.ts

Lines changed: 23 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,14 @@ import {
1414
inject,
1515
input,
1616
signal,
17+
afterNextRender,
18+
OnDestroy,
1719
} from '@angular/core';
1820
import {Directionality} from '@angular/cdk/bidi';
19-
import {AccordionGroupPattern, sortDirectives} from '../private';
21+
import {AccordionGroupPattern} from '../private';
22+
import {SortedCollection} from '../private/utils/collection';
23+
import {ACCORDION_GROUP, ACCORDION_COLLECTION} from './accordion-tokens';
2024
import {AccordionTrigger} from './accordion-trigger';
21-
import {ACCORDION_GROUP} from './accordion-tokens';
2225

2326
/**
2427
* A container for a group of accordion items. It manages the overall state and
@@ -64,32 +67,24 @@ import {ACCORDION_GROUP} from './accordion-tokens';
6467
'(click)': '_pattern.onClick($event)',
6568
'(focusin)': '_pattern.onFocus($event)',
6669
},
67-
providers: [{provide: ACCORDION_GROUP, useExisting: AccordionGroup}],
70+
providers: [
71+
{provide: ACCORDION_GROUP, useExisting: AccordionGroup},
72+
{provide: ACCORDION_COLLECTION, useFactory: () => inject(AccordionGroup)._collection},
73+
],
6874
})
69-
export class AccordionGroup {
75+
export class AccordionGroup implements OnDestroy {
7076
/** A reference to the group element. */
7177
private readonly _elementRef = inject(ElementRef);
7278

7379
/** A reference to the group element. */
7480
readonly element = this._elementRef.nativeElement as HTMLElement;
7581

76-
/** The AccordionTriggers nested inside this group. */
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-
});
82+
/** The collection of AccordionTriggers. */
83+
readonly _collection = new SortedCollection<AccordionTrigger>();
8984

9085
/** The corresponding patterns for the accordion triggers. */
9186
private readonly _triggerPatterns = computed(() => {
92-
return this._sortedTriggers().map(t => t._pattern);
87+
return this._collection.orderedItems().map(t => t._pattern);
9388
});
9489

9590
/** The text direction (ltr or rtl). */
@@ -119,6 +114,16 @@ export class AccordionGroup {
119114
orientation: () => 'vertical',
120115
});
121116

117+
constructor() {
118+
afterNextRender(() => {
119+
this._collection.startObserving(this.element);
120+
});
121+
}
122+
123+
ngOnDestroy() {
124+
this._collection.stopObserving();
125+
}
126+
122127
/** Expands all accordion panels if multi-expandable. */
123128
expandAll() {
124129
this._pattern.expandAll();
@@ -128,16 +133,4 @@ export class AccordionGroup {
128133
collapseAll() {
129134
this._pattern.collapseAll();
130135
}
131-
132-
/** Internal method to register each trigger as we can not use contentChildren. */
133-
_registerTrigger(trigger: AccordionTrigger) {
134-
this._triggers().add(trigger);
135-
this._triggers.set(new Set(this._triggers()));
136-
}
137-
138-
/** Internal method to unregister each trigger as we can not use contentChildren. */
139-
_unregisterTrigger(trigger: AccordionTrigger) {
140-
this._triggers().delete(trigger);
141-
this._triggers.set(new Set(this._triggers()));
142-
}
143136
}

src/aria/accordion/accordion-tokens.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88

99
import {InjectionToken} from '@angular/core';
1010
import type {AccordionGroup} from './accordion-group';
11+
import {SortedCollection} from '../private/utils/collection';
12+
import type {AccordionTrigger} from './accordion-trigger';
1113

1214
/** Token used to expose the accordion group. */
1315
export const ACCORDION_GROUP = new InjectionToken<AccordionGroup>('ACCORDION_GROUP');
16+
17+
export const ACCORDION_COLLECTION = new InjectionToken<SortedCollection<AccordionTrigger>>(
18+
'ACCORDION_COLLECTION',
19+
);

src/aria/accordion/accordion-trigger.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from '@angular/core';
2020
import {_IdGenerator} from '@angular/cdk/a11y';
2121
import {AccordionTriggerPattern} from '../private';
22-
import {ACCORDION_GROUP} from './accordion-tokens';
22+
import {ACCORDION_GROUP, ACCORDION_COLLECTION} from './accordion-tokens';
2323
import {AccordionPanel} from './accordion-panel';
2424

2525
/**
@@ -64,6 +64,9 @@ export class AccordionTrigger implements OnInit, OnDestroy {
6464
/** The parent AccordionGroup. */
6565
private readonly _accordionGroup = inject(ACCORDION_GROUP);
6666

67+
/** The parent collection. */
68+
private readonly _collection = inject(ACCORDION_COLLECTION);
69+
6770
/** The associated AccordionPanel. */
6871
readonly panel = input.required<AccordionPanel>();
6972

@@ -76,9 +79,6 @@ export class AccordionTrigger implements OnInit, OnDestroy {
7679
/** Whether the trigger is disabled. */
7780
readonly disabled = input(false, {transform: booleanAttribute});
7881

79-
/** The index of the trigger within the accordion group. */
80-
readonly index = input<number>();
81-
8282
/** Whether the corresponding panel is expanded. */
8383
readonly expanded = model<boolean>(false);
8484

@@ -98,13 +98,13 @@ export class AccordionTrigger implements OnInit, OnDestroy {
9898

9999
this.panel()._pattern = this._pattern;
100100

101-
this._accordionGroup._registerTrigger(this);
101+
this._collection.register(this);
102102
}
103103

104104
ngOnDestroy() {
105105
this.panel()._pattern = undefined;
106106

107-
this._accordionGroup._unregisterTrigger(this);
107+
this._collection.unregister(this);
108108
}
109109

110110
/** Expands this item. */

src/aria/accordion/accordion.spec.ts

Lines changed: 16 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {ComponentFixture, TestBed} from '@angular/core/testing';
33
import {By} from '@angular/platform-browser';
44
import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private';
55
import {_IdGenerator} from '@angular/cdk/a11y';
6+
import {waitForMicrotasks} from '../private/testing/test-helpers';
67
import {AccordionPanel} from './accordion-panel';
78
import {AccordionTrigger} from './accordion-trigger';
89
import {AccordionContent} from './accordion-content';
@@ -57,7 +58,6 @@ describe('AccordionGroup', () => {
5758

5859
const getTriggerAttribute = (index: number, attribute: string) =>
5960
triggerElements[index].getAttribute(attribute);
60-
const getTriggerText = (index: number) => triggerElements[index].textContent?.trim();
6161

6262
const getPanelAttribute = (index: number, attribute: string) =>
6363
panelElements[index].getAttribute(attribute);
@@ -292,68 +292,24 @@ describe('AccordionGroup', () => {
292292
expect(isTriggerExpanded(0)).toBeFalse();
293293
});
294294

295-
describe('with shuffled items', () => {
296-
it('should focus on new last trigger with End', () => {
297-
const items = testComponent.items().reverse();
298-
testComponent.items.set([...items]);
299-
fixture.detectChanges();
300-
301-
// Now reversed, End should move to the former first trigger.
302-
endKey();
303-
expect(isTriggerActive(0)).toBeTrue();
304-
});
305-
306-
it('should focus on newly prepended trigger with Begin', () => {
307-
const items = testComponent.items();
308-
items.unshift({
309-
panelId: 'item-0',
310-
header: 'Item 0 Header',
311-
content: 'Item 0 Content',
312-
disabled: signal(false),
313-
expanded: signal(false),
314-
});
315-
testComponent.items.set([...items]);
316-
setupTriggerAndPanels();
317-
318-
homeKey();
319-
expect(isTriggerActive(0)).toBeTrue();
320-
expect(getTriggerText(0)).toBe('Item 0 Header');
321-
});
295+
it('should update collection order when items are shuffled', async () => {
296+
const groupDebugElement = fixture.debugElement.query(By.directive(AccordionGroup));
297+
const groupDirective = groupDebugElement.injector.get(AccordionGroup);
322298

323-
it('should focus on newly appended trigger with End', () => {
324-
const items = testComponent.items();
325-
items.push({
326-
panelId: 'item-4',
327-
header: 'Item 4 Header',
328-
content: 'Item 4 Content',
329-
disabled: signal(false),
330-
expanded: signal(false),
331-
});
332-
testComponent.items.set([...items]);
333-
setupTriggerAndPanels();
299+
let orderedItems = groupDirective._collection.orderedItems();
300+
expect(orderedItems.length).toBe(3);
301+
expect(orderedItems[0].element.textContent?.trim()).toBe('Item 1 Header');
302+
expect(orderedItems[2].element.textContent?.trim()).toBe('Item 3 Header');
334303

335-
endKey();
336-
expect(isTriggerActive(3)).toBeTrue();
337-
expect(getTriggerText(3)).toBe('Item 4 Header');
338-
});
339-
340-
it('should focus on inserted trigger with navigation', () => {
341-
const items = testComponent.items();
342-
items.splice(2, 0, {
343-
panelId: 'item-2a',
344-
header: 'Item 2a Header',
345-
content: 'Item 2a Content',
346-
disabled: signal(false),
347-
expanded: signal(false),
348-
});
349-
testComponent.items.set([...items]);
350-
setupTriggerAndPanels();
304+
const items = testComponent.items().reverse();
305+
testComponent.items.set([...items]);
306+
fixture.detectChanges();
307+
await waitForMicrotasks();
351308

352-
downArrowKey();
353-
downArrowKey();
354-
expect(isTriggerActive(2)).toBeTrue();
355-
expect(triggerElements[2].textContent?.trim()).toBe('Item 2a Header');
356-
});
309+
orderedItems = groupDirective._collection.orderedItems();
310+
expect(orderedItems.length).toBe(3);
311+
expect(orderedItems[0].element.textContent?.trim()).toBe('Item 3 Header');
312+
expect(orderedItems[2].element.textContent?.trim()).toBe('Item 1 Header');
357313
});
358314

359315
describe('wrap behavior', () => {
@@ -539,7 +495,6 @@ describe('AccordionGroup', () => {
539495
<div>
540496
<button
541497
ngAccordionTrigger
542-
[index]="$index"
543498
[panel]="panel"
544499
[disabled]="item.disabled()"
545500
[(expanded)]="item.expanded"

src/aria/listbox/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ ng_project(
1212
"//:node_modules/@angular/core",
1313
"//src/aria/combobox",
1414
"//src/aria/private",
15+
"//src/aria/private/utils",
1516
"//src/cdk/a11y",
1617
"//src/cdk/bidi",
1718
"//src/cdk/testing",
@@ -30,6 +31,7 @@ ng_project(
3031
"//:node_modules/@angular/core",
3132
"//:node_modules/@angular/platform-browser",
3233
"//:node_modules/axe-core",
34+
"//src/aria/private/testing",
3335
"//src/cdk/testing",
3436
"//src/cdk/testing/private",
3537
"//src/cdk/testing/testbed",

0 commit comments

Comments
 (0)