Skip to content

Commit bc23c8a

Browse files
committed
refactor(aria/accordion): Change to use template references to match panel with trigger
1 parent 3670e0f commit bc23c8a

22 files changed

Lines changed: 188 additions & 475 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 & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,43 +31,24 @@ export class AccordionGroupPattern {
3131
toggle(): void;
3232
}
3333

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-
5134
// @public
5235
export interface AccordionTriggerInputs extends Omit<ListNavigationItem & ListFocusItem, 'index'>, Omit<ExpansionItem, 'expandable'> {
5336
accordionGroup: SignalLike<AccordionGroupPattern>;
54-
accordionPanel: SignalLike<AccordionPanelPattern | undefined>;
55-
panelId: SignalLike<string>;
37+
accordionPanelId: SignalLike<string>;
5638
}
5739

5840
// @public
5941
export class AccordionTriggerPattern implements ListNavigationItem, ListFocusItem, ExpansionItem {
6042
constructor(inputs: AccordionTriggerInputs);
6143
readonly active: SignalLike<boolean>;
6244
close(): void;
63-
readonly controls: SignalLike<string | undefined>;
45+
readonly controls: SignalLike<string>;
6446
readonly disabled: SignalLike<boolean>;
6547
readonly element: SignalLike<HTMLElement>;
6648
readonly expandable: SignalLike<boolean>;
6749
readonly expanded: WritableSignalLike<boolean>;
6850
readonly hardDisabled: SignalLike<boolean>;
6951
readonly id: SignalLike<string>;
70-
readonly index: SignalLike<number>;
7152
// (undocumented)
7253
readonly inputs: AccordionTriggerInputs;
7354
open(): void;

src/aria/accordion/accordion-group.ts

Lines changed: 10 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,13 @@ import {
1212
ElementRef,
1313
inject,
1414
contentChildren,
15-
afterRenderEffect,
1615
signal,
1716
booleanAttribute,
1817
computed,
1918
} from '@angular/core';
2019
import {Directionality} from '@angular/cdk/bidi';
2120
import {AccordionGroupPattern, AccordionTriggerPattern} 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="ngAccordionTrigger">
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="ngAccordionTrigger">
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;
@@ -114,22 +113,6 @@ export class AccordionGroup {
114113
element: () => this.element,
115114
});
116115

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-
133116
/** Expands all accordion panels if multi-expandable. */
134117
expandAll() {
135118
this._pattern.expansionBehavior.openAll();

src/aria/accordion/accordion-panel.ts

Lines changed: 7 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,20 @@ import {
1616
WritableSignal,
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+
* exlse 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">
33-
* <ng-template ngAccordionContent>
32+
* <div ngAccordionPanel #panel="ngAccordionTrigger">
3433
* <p>This content is lazily rendered and will be shown when the panel is expanded.</p>
3534
* </ng-template>
3635
* </div>
@@ -50,8 +49,8 @@ import {DeferredContentAware, AccordionPanelPattern, AccordionTriggerPattern} fr
5049
],
5150
host: {
5251
'role': 'region',
53-
'[attr.id]': '_pattern.id()',
54-
'[attr.aria-labelledby]': '_pattern.accordionTrigger()?.id()',
52+
'[attr.id]': 'id()',
53+
'[attr.aria-labelledby]': '_accordionTriggerPattern()?.id()',
5554
'[attr.inert]': '!visible() ? true : null',
5655
},
5756
})
@@ -62,23 +61,13 @@ export class AccordionPanel {
6261
/** A global unique identifier for the panel. */
6362
readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true));
6463

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

7167
/** The parent accordion trigger pattern that controls this panel. This is set by AccordionGroup. */
7268
readonly _accordionTriggerPattern: WritableSignal<AccordionTriggerPattern | undefined> =
7369
signal(undefined);
7470

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-
8271
constructor() {
8372
// Connect the panel's hidden state to the DeferredContentAware's visibility.
8473
afterRenderEffect(() => {

src/aria/accordion/accordion-trigger.ts

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@ import {
1010
Directive,
1111
input,
1212
ElementRef,
13+
OnInit,
1314
inject,
14-
signal,
1515
model,
1616
booleanAttribute,
1717
computed,
18-
WritableSignal,
1918
} from '@angular/core';
2019
import {_IdGenerator} from '@angular/cdk/a11y';
21-
import {AccordionPanelPattern, AccordionTriggerPattern} from '../private';
20+
import {AccordionTriggerPattern} from '../private';
2221
import {ACCORDION_GROUP} from './accordion-tokens';
22+
import {AccordionPanel} from './accordion-panel';
2323

2424
/**
2525
* The trigger that toggles the visibility of its associated `ngAccordionPanel`.
2626
*
27-
* This directive requires a `panelId` that must match the `panelId` of the `ngAccordionPanel` it
28-
* controls. When clicked, it will expand or collapse the panel. It also handles keyboard
27+
* This directive requires the `panel` input be set to the template reference of the `ngAccordionPanel`
28+
* it controls. When clicked, it will expand or collapse the panel. It also handles keyboard
2929
* interactions for navigation within the `ngAccordionGroup`. It applies `role="button"` and manages
3030
* `aria-expanded`, `aria-controls`, and `aria-disabled` attributes for accessibility.
3131
* The `disabled` input can be used to disable the trigger.
3232
*
3333
* ```html
34-
* <button ngAccordionTrigger panelId="unique-id-1">
34+
* <button ngAccordionTrigger panel="panel">
3535
* Accordion Trigger Text
3636
* </button>
3737
* ```
@@ -45,15 +45,15 @@ import {ACCORDION_GROUP} from './accordion-tokens';
4545
host: {
4646
'[attr.data-active]': 'active()',
4747
'role': 'button',
48-
'[id]': '_pattern.id()',
48+
'[id]': 'id()',
4949
'[attr.aria-expanded]': 'expanded()',
5050
'[attr.aria-controls]': '_pattern.controls()',
5151
'[attr.aria-disabled]': '_pattern.disabled()',
5252
'[attr.disabled]': '_pattern.hardDisabled() ? true : null',
5353
'[attr.tabindex]': '_pattern.tabIndex()',
5454
},
5555
})
56-
export class AccordionTrigger {
56+
export class AccordionTrigger implements OnInit {
5757
/** A reference to the trigger element. */
5858
private readonly _elementRef = inject(ElementRef);
5959

@@ -63,32 +63,33 @@ export class AccordionTrigger {
6363
/** The parent AccordionGroup. */
6464
private readonly _accordionGroup = inject(ACCORDION_GROUP);
6565

66+
/** The associated AccordionPanel. */
67+
readonly panel = input.required<AccordionPanel>();
68+
6669
/** A unique identifier for the widget. */
6770
readonly id = input(inject(_IdGenerator).getId('ng-accordion-trigger-', true));
6871

69-
/** A local unique identifier for the trigger, used to match with its panel's `panelId`. */
70-
readonly panelId = input.required<string>();
71-
7272
/** Whether the trigger is disabled. */
7373
readonly disabled = input(false, {transform: booleanAttribute});
7474

7575
/** Whether the corresponding panel is expanded. */
7676
readonly expanded = model<boolean>(false);
7777

7878
/** Whether the trigger is active. */
79-
readonly active = computed(() => this._pattern.active());
80-
81-
/** The accordion panel pattern controlled by this trigger. This is set by AccordionGroup. */
82-
readonly _accordionPanelPattern: WritableSignal<AccordionPanelPattern | undefined> =
83-
signal(undefined);
79+
readonly active = computed(() => this._pattern!.active());
8480

8581
/** The UI pattern instance for this trigger. */
86-
readonly _pattern: AccordionTriggerPattern = new AccordionTriggerPattern({
87-
...this,
88-
accordionGroup: computed(() => this._accordionGroup._pattern),
89-
accordionPanel: this._accordionPanelPattern,
90-
element: () => this.element,
91-
});
82+
_pattern!: AccordionTriggerPattern;
83+
84+
ngOnInit() {
85+
this._pattern = new AccordionTriggerPattern({
86+
...this,
87+
accordionGroup: computed(() => this._accordionGroup._pattern),
88+
accordionPanelId: () => this.panel().id(),
89+
element: () => this.element,
90+
});
91+
this.panel()._accordionTriggerPattern.set(this._pattern);
92+
}
9293

9394
/** Expands this item. */
9495
expand() {

src/aria/accordion/accordion.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -387,13 +387,13 @@ describe('AccordionGroup', () => {
387387
<div class="item-container">
388388
<button
389389
ngAccordionTrigger
390-
[panelId]="item.panelId"
390+
[panel]="panel"
391391
[disabled]="item.disabled"
392392
[(expanded)]="item.expanded"
393393
>{{ item.header }}</button>
394394
<div
395395
ngAccordionPanel
396-
[panelId]="item.panelId"
396+
#panel="ngAccordionPanel"
397397
>
398398
<ng-template ngAccordionContent>
399399
{{ item.content }}

0 commit comments

Comments
 (0)