Skip to content

Commit 8f522e8

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

26 files changed

Lines changed: 572 additions & 246 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>;

goldens/aria/tabs/index.api.md

Lines changed: 14 additions & 15 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
import { WritableSignal } from '@angular/core';
1213

1314
// @public
@@ -21,7 +22,6 @@ export class Tab implements HasElement, OnInit, OnDestroy {
2122
// (undocumented)
2223
ngOnInit(): void;
2324
open(): void;
24-
readonly panel: _angular_core.Signal<TabPanel | undefined>;
2525
readonly _pattern: TabPattern;
2626
readonly selected: _angular_core.Signal<boolean>;
2727
readonly value: _angular_core.InputSignal<string>;
@@ -42,6 +42,7 @@ export class TabContent {
4242
// @public
4343
export class TabList implements OnInit, OnDestroy {
4444
constructor();
45+
readonly _collection: SortedCollection<Tab>;
4546
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
4647
readonly element: HTMLElement;
4748
// (undocumented)
@@ -54,15 +55,12 @@ export class TabList implements OnInit, OnDestroy {
5455
open(value: string): boolean;
5556
readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">;
5657
readonly _pattern: TabListPattern;
57-
// (undocumented)
58-
_registerTab(child: Tab): void;
5958
readonly selectedTab: _angular_core.ModelSignal<string | undefined>;
6059
readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">;
6160
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
62-
readonly _sortedTabs: _angular_core.Signal<Tab[]>;
61+
readonly _tabPatterns: _angular_core.Signal<TabPattern[]>;
62+
readonly _tabsParent: Tabs;
6363
readonly textDirection: WritableSignal<_angular_cdk_bidi.Direction>;
64-
// (undocumented)
65-
_unregisterTab(child: Tab): void;
6664
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
6765
// (undocumented)
6866
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<TabList, "[ngTabList]", ["ngTabList"], { "orientation": { "alias": "orientation"; "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; }; "selectedTab": { "alias": "selectedTab"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; }, { "selectedTab": "selectedTabChange"; }, never, never, true, never>;
@@ -80,7 +78,6 @@ export class TabPanel implements OnInit, OnDestroy {
8078
// (undocumented)
8179
ngOnInit(): void;
8280
readonly _pattern: TabPanelPattern;
83-
readonly _tabPattern: WritableSignal<TabPattern | undefined>;
8481
readonly value: _angular_core.InputSignal<string>;
8582
readonly visible: _angular_core.Signal<boolean>;
8683
// (undocumented)
@@ -90,19 +87,21 @@ export class TabPanel implements OnInit, OnDestroy {
9087
}
9188

9289
// @public
93-
export class Tabs {
90+
export class Tabs implements OnDestroy {
9491
constructor();
92+
readonly _collection: SortedCollection<TabPanel>;
9593
readonly element: HTMLElement;
9694
// (undocumented)
97-
findTabPanel(value?: string): TabPanel | undefined;
98-
// (undocumented)
99-
_registerList(list: TabList): void;
100-
// (undocumented)
101-
_registerPanel(panel: TabPanel): void;
95+
ngOnDestroy(): void;
96+
readonly _panelMap: _angular_core.Signal<Map<string, TabPanelPattern>>;
10297
// (undocumented)
103-
_unregisterList(list: TabList): void;
98+
_register(child: TabList): void;
99+
readonly _tabList: _angular_core.WritableSignal<TabList | undefined>;
100+
readonly _tabMap: _angular_core.Signal<Map<string, TabPattern>>;
101+
readonly _tabPanelPatterns: _angular_core.Signal<TabPanelPattern[]>;
102+
readonly _tabPatterns: _angular_core.Signal<TabPattern[] | undefined>;
104103
// (undocumented)
105-
_unregisterPanel(panel: TabPanel): void;
104+
_unregister(): void;
106105
// (undocumented)
107106
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<Tabs, "[ngTabs]", ["ngTabs"], {}, {}, never, never, true, never>;
108107
// (undocumented)

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. */

0 commit comments

Comments
 (0)