Skip to content

Commit fc055d5

Browse files
committed
refactor(aria/accordion): simplify code by using template references instead of user id to match panels with triggers
1 parent 23be683 commit fc055d5

22 files changed

Lines changed: 221 additions & 511 deletions

goldens/aria/accordion/index.api.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import * as _angular_cdk_bidi from '@angular/cdk/bidi';
88
import * as _angular_core from '@angular/core';
99
import { OnDestroy } from '@angular/core';
10+
import { OnInit } from '@angular/core';
1011
import { WritableSignal } from '@angular/core';
1112

1213
// @public
@@ -19,7 +20,6 @@ export class AccordionContent {
1920

2021
// @public
2122
export class AccordionGroup {
22-
constructor();
2323
collapseAll(): void;
2424
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
2525
readonly element: HTMLElement;
@@ -30,7 +30,7 @@ export class AccordionGroup {
3030
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
3131
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3232
// (undocumented)
33-
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", "_panels"], never, true, never>;
33+
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>;
3434
// (undocumented)
3535
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionGroup, never>;
3636
}
@@ -42,31 +42,30 @@ export class AccordionPanel {
4242
collapse(): void;
4343
expand(): void;
4444
readonly id: _angular_core.InputSignal<string>;
45-
readonly panelId: _angular_core.InputSignal<string>;
46-
readonly _pattern: AccordionPanelPattern;
4745
toggle(): void;
4846
readonly visible: _angular_core.Signal<boolean>;
4947
// (undocumented)
50-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionPanel, "[ngAccordionPanel]", ["ngAccordionPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "panelId": { "alias": "panelId"; "required": true; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
48+
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionPanel, "[ngAccordionPanel]", ["ngAccordionPanel"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; }, {}, never, never, true, [{ directive: typeof DeferredContentAware; inputs: { "preserveContent": "preserveContent"; }; outputs: {}; }]>;
5149
// (undocumented)
5250
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionPanel, never>;
5351
}
5452

5553
// @public
56-
export class AccordionTrigger {
57-
readonly _accordionPanelPattern: WritableSignal<AccordionPanelPattern | undefined>;
54+
export class AccordionTrigger implements OnInit {
5855
readonly active: _angular_core.Signal<boolean>;
5956
collapse(): void;
6057
readonly disabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
6158
readonly element: HTMLElement;
6259
expand(): void;
6360
readonly expanded: _angular_core.ModelSignal<boolean>;
6461
readonly id: _angular_core.InputSignal<string>;
65-
readonly panelId: _angular_core.InputSignal<string>;
66-
readonly _pattern: AccordionTriggerPattern;
62+
// (undocumented)
63+
ngOnInit(): void;
64+
readonly panel: _angular_core.InputSignal<AccordionPanel>;
65+
_pattern: AccordionTriggerPattern;
6766
toggle(): void;
6867
// (undocumented)
69-
static ɵdir: _angular_core.ɵɵDirectiveDeclaration<AccordionTrigger, "[ngAccordionTrigger]", ["ngAccordionTrigger"], { "id": { "alias": "id"; "required": false; "isSignal": true; }; "panelId": { "alias": "panelId"; "required": true; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "expanded": { "alias": "expanded"; "required": false; "isSignal": true; }; }, { "expanded": "expandedChange"; }, never, never, true, never>;
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>;
7069
// (undocumented)
7170
static ɵfac: _angular_core.ɵɵFactoryDeclaration<AccordionTrigger, never>;
7271
}

goldens/aria/private/index.api.md

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { untracked } from '@angular/core/primitives/signals';
1010

1111
// @public
1212
export interface AccordionGroupInputs extends Omit<ListNavigationInputs<AccordionTriggerPattern> & ListFocusInputs<AccordionTriggerPattern> & Omit<ListExpansionInputs, 'items'>, 'focusMode'> {
13-
getItem: (e: Element | null | undefined) => AccordionTriggerPattern | undefined;
1413
}
1514

1615
// @public
@@ -31,43 +30,24 @@ export class AccordionGroupPattern {
3130
toggle(): void;
3231
}
3332

34-
// @public
35-
export interface AccordionPanelInputs {
36-
accordionTrigger: SignalLike<AccordionTriggerPattern | undefined>;
37-
id: SignalLike<string>;
38-
panelId: SignalLike<string>;
39-
}
40-
41-
// @public
42-
export class AccordionPanelPattern {
43-
constructor(inputs: AccordionPanelInputs);
44-
accordionTrigger: SignalLike<AccordionTriggerPattern | undefined>;
45-
hidden: SignalLike<boolean>;
46-
id: SignalLike<string>;
47-
// (undocumented)
48-
readonly inputs: AccordionPanelInputs;
49-
}
50-
5133
// @public
5234
export interface AccordionTriggerInputs extends Omit<ListNavigationItem & ListFocusItem, 'index'>, Omit<ExpansionItem, 'expandable'> {
5335
accordionGroup: SignalLike<AccordionGroupPattern>;
54-
accordionPanel: SignalLike<AccordionPanelPattern | undefined>;
55-
panelId: SignalLike<string>;
36+
accordionPanelId: SignalLike<string>;
5637
}
5738

5839
// @public
5940
export class AccordionTriggerPattern implements ListNavigationItem, ListFocusItem, ExpansionItem {
6041
constructor(inputs: AccordionTriggerInputs);
6142
readonly active: SignalLike<boolean>;
6243
close(): void;
63-
readonly controls: SignalLike<string | undefined>;
44+
readonly controls: SignalLike<string>;
6445
readonly disabled: SignalLike<boolean>;
6546
readonly element: SignalLike<HTMLElement>;
6647
readonly expandable: SignalLike<boolean>;
6748
readonly expanded: WritableSignalLike<boolean>;
6849
readonly hardDisabled: SignalLike<boolean>;
6950
readonly id: SignalLike<string>;
70-
readonly index: SignalLike<number>;
7151
// (undocumented)
7252
readonly inputs: AccordionTriggerInputs;
7353
open(): void;

src/aria/accordion/accordion-group.ts

Lines changed: 15 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,17 @@
88

99
import {
1010
Directive,
11-
input,
1211
ElementRef,
13-
inject,
14-
contentChildren,
15-
afterRenderEffect,
16-
signal,
1712
booleanAttribute,
1813
computed,
14+
contentChildren,
15+
inject,
16+
input,
17+
signal,
1918
} from '@angular/core';
2019
import {Directionality} from '@angular/cdk/bidi';
21-
import {AccordionGroupPattern, AccordionTriggerPattern} from '../private';
20+
import {AccordionGroupPattern} from '../private';
2221
import {AccordionTrigger} from './accordion-trigger';
23-
import {AccordionPanel} from './accordion-panel';
2422
import {ACCORDION_GROUP} from './accordion-tokens';
2523

2624
/**
@@ -32,22 +30,22 @@ import {ACCORDION_GROUP} from './accordion-tokens';
3230
* It supports both single and multiple expansion modes.
3331
*
3432
* ```html
35-
* <div ngAccordionGroup [multiExpandable]="true" [(expandedPanels)]="expandedItems">
33+
* <div ngAccordionGroup [multiExpandable]="true">
3634
* <div class="accordion-item">
3735
* <h3>
38-
* <button ngAccordionTrigger panelId="item-1">Item 1</button>
36+
* <button ngAccordionTrigger [panel]="panel1">Item 1</button>
3937
* </h3>
40-
* <div ngAccordionPanel panelId="item-1">
38+
* <div ngAccordionPanel #panel1="ngAccordionPanel">
4139
* <ng-template ngAccordionContent>
4240
* <p>Content for Item 1.</p>
4341
* </ng-template>
4442
* </div>
4543
* </div>
4644
* <div class="accordion-item">
4745
* <h3>
48-
* <button ngAccordionTrigger panelId="item-2">Item 2</button>
46+
* <button ngAccordionTrigger [panel]="panel2">Item 2</button>
4947
* </h3>
50-
* <div ngAccordionPanel panelId="item-2">
48+
* <div ngAccordionPanel #panel2="ngAccordionPanel">
5149
* <ng-template ngAccordionContent>
5250
* <p>Content for Item 2.</p>
5351
* </ng-template>
@@ -80,10 +78,11 @@ export class AccordionGroup {
8078
private readonly _triggers = contentChildren(AccordionTrigger, {descendants: true});
8179

8280
/** The AccordionTrigger patterns nested inside this group. */
83-
private readonly _triggerPatterns = computed(() => this._triggers().map(t => t._pattern));
84-
85-
/** The AccordionPanels nested inside this group. */
86-
private readonly _panels = contentChildren(AccordionPanel, {descendants: true});
81+
private readonly _triggerPatterns = computed(() =>
82+
this._triggers()
83+
.map(t => t._pattern)
84+
.filter(p => !!p),
85+
);
8786

8887
/** The text direction (ltr or rtl). */
8988
readonly textDirection = inject(Directionality).valueSignal;
@@ -110,26 +109,9 @@ export class AccordionGroup {
110109
items: this._triggerPatterns,
111110
// TODO(ok7sai): Investigate whether an accordion should support horizontal mode.
112111
orientation: () => 'vertical',
113-
getItem: e => this._getItem(e),
114112
element: () => this.element,
115113
});
116114

117-
constructor() {
118-
// Effect to link triggers with their corresponding panels and update the group's items.
119-
afterRenderEffect(() => {
120-
const triggers = this._triggers();
121-
const panels = this._panels();
122-
123-
for (const trigger of triggers) {
124-
const panel = panels.find(p => p.panelId() === trigger.panelId());
125-
trigger._accordionPanelPattern.set(panel?._pattern);
126-
if (panel) {
127-
panel._accordionTriggerPattern.set(trigger._pattern);
128-
}
129-
}
130-
});
131-
}
132-
133115
/** Expands all accordion panels if multi-expandable. */
134116
expandAll() {
135117
this._pattern.expansionBehavior.openAll();
@@ -139,20 +121,4 @@ export class AccordionGroup {
139121
collapseAll() {
140122
this._pattern.expansionBehavior.closeAll();
141123
}
142-
143-
/** Gets the trigger pattern for a given element. */
144-
private _getItem(element: Element | null | undefined): AccordionTriggerPattern | undefined {
145-
let target = element;
146-
147-
while (target) {
148-
const pattern = this._triggerPatterns().find(t => t.element() === target);
149-
if (pattern) {
150-
return pattern;
151-
}
152-
153-
target = target.parentElement?.closest('[ngAccordionTrigger]');
154-
}
155-
156-
return undefined;
157-
}
158124
}

src/aria/accordion/accordion-panel.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,28 @@
88

99
import {
1010
Directive,
11-
input,
12-
inject,
11+
WritableSignal,
1312
afterRenderEffect,
14-
signal,
1513
computed,
16-
WritableSignal,
14+
inject,
15+
input,
16+
signal,
1717
} from '@angular/core';
1818
import {_IdGenerator} from '@angular/cdk/a11y';
19-
import {DeferredContentAware, AccordionPanelPattern, AccordionTriggerPattern} from '../private';
19+
import {DeferredContentAware, AccordionTriggerPattern} from '../private';
2020

2121
/**
2222
* The content panel of an accordion item that is conditionally visible.
2323
*
24-
* This directive is a container for the content that is shown or hidden. It requires
25-
* a `panelId` that must match the `panelId` of its corresponding `ngAccordionTrigger`.
24+
* This directive is a container for the content that is shown or hidden. It should
25+
* expose a template reference that will be used by the corresponding `ngAccordionTrigger`.
2626
* The content within the panel should be provided using an `ng-template` with the
2727
* `ngAccordionContent` directive so that the content is not rendered on the page until the trigger
2828
* is expanded. It applies `role="region"` for accessibility and uses the `inert` attribute to hide
2929
* its content from assistive technologies when not visible.
3030
*
3131
* ```html
32-
* <div ngAccordionPanel panelId="unique-id-1">
32+
* <div ngAccordionPanel #panel="ngAccordionPanel">
3333
* <ng-template ngAccordionContent>
3434
* <p>This content is lazily rendered and will be shown when the panel is expanded.</p>
3535
* </ng-template>
@@ -50,8 +50,8 @@ import {DeferredContentAware, AccordionPanelPattern, AccordionTriggerPattern} fr
5050
],
5151
host: {
5252
'role': 'region',
53-
'[attr.id]': '_pattern.id()',
54-
'[attr.aria-labelledby]': '_pattern.accordionTrigger()?.id()',
53+
'[attr.id]': 'id()',
54+
'[attr.aria-labelledby]': '_accordionTriggerPattern()?.id()',
5555
'[attr.inert]': '!visible() ? true : null',
5656
},
5757
})
@@ -62,23 +62,13 @@ export class AccordionPanel {
6262
/** A global unique identifier for the panel. */
6363
readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true));
6464

65-
/** A local unique identifier for the panel, used to match with its trigger's `panelId`. */
66-
readonly panelId = input.required<string>();
67-
6865
/** Whether the accordion panel is visible. True if the associated trigger is expanded. */
69-
readonly visible = computed(() => !this._pattern.hidden());
66+
readonly visible = computed(() => this._accordionTriggerPattern()?.expanded() === true);
7067

7168
/** The parent accordion trigger pattern that controls this panel. This is set by AccordionGroup. */
7269
readonly _accordionTriggerPattern: WritableSignal<AccordionTriggerPattern | undefined> =
7370
signal(undefined);
7471

75-
/** The UI pattern instance for this panel. */
76-
readonly _pattern: AccordionPanelPattern = new AccordionPanelPattern({
77-
id: this.id,
78-
panelId: this.panelId,
79-
accordionTrigger: () => this._accordionTriggerPattern(),
80-
});
81-
8272
constructor() {
8373
// Connect the panel's hidden state to the DeferredContentAware's visibility.
8474
afterRenderEffect(() => {

0 commit comments

Comments
 (0)