Skip to content

Commit ec77d60

Browse files
committed
refactor(aria/accordion): split directives and resolve circular dependencies
Splits up the directives across files and resolve circular dependencies in them. (cherry picked from commit 80ef657)
1 parent a0489f2 commit ec77d60

File tree

10 files changed

+429
-360
lines changed

10 files changed

+429
-360
lines changed

goldens/aria/accordion/index.api.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export class AccordionGroup {
3030
readonly multiExpandable: _angular_core.InputSignalWithTransform<boolean, unknown>;
3131
readonly _pattern: AccordionGroupPattern;
3232
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
33-
readonly textDirection: WritableSignal<_angular_cdk_bidi.Direction>;
33+
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
3434
readonly wrap: _angular_core.InputSignalWithTransform<boolean, unknown>;
3535
// (undocumented)
3636
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>;

src/aria/accordion/BUILD.bazel

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@ package(default_visibility = ["//visibility:public"])
44

55
ng_project(
66
name = "accordion",
7-
srcs = [
8-
"accordion.ts",
9-
"index.ts",
10-
],
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
1111
deps = [
1212
"//:node_modules/@angular/core",
1313
"//src/aria/private",
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {Directive} from '@angular/core';
10+
import {DeferredContent} from '@angular/aria/private';
11+
12+
/**
13+
* A structural directive that provides a mechanism for lazily rendering the content for an
14+
* `ngAccordionPanel`.
15+
*
16+
* This directive should be applied to an `ng-template` inside an `ngAccordionPanel`.
17+
* It allows the content of the panel to be lazily rendered, improving performance
18+
* by only creating the content when the panel is first expanded.
19+
*
20+
* ```html
21+
* <div ngAccordionPanel panelId="unique-id-1">
22+
* <ng-template ngAccordionContent>
23+
* <p>This is the content that will be displayed inside the panel.</p>
24+
* </ng-template>
25+
* </div>
26+
* ```
27+
*
28+
* @developerPreview 21.0
29+
*/
30+
@Directive({
31+
selector: 'ng-template[ngAccordionContent]',
32+
hostDirectives: [DeferredContent],
33+
})
34+
export class AccordionContent {}
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
Directive,
11+
input,
12+
ElementRef,
13+
inject,
14+
contentChildren,
15+
afterRenderEffect,
16+
signal,
17+
booleanAttribute,
18+
computed,
19+
} from '@angular/core';
20+
import {Directionality} from '@angular/cdk/bidi';
21+
import {AccordionGroupPattern, AccordionTriggerPattern} from '@angular/aria/private';
22+
import {AccordionTrigger} from './accordion-trigger';
23+
import {AccordionPanel} from './accordion-panel';
24+
import {ACCORDION_GROUP} from './accordion-tokens';
25+
26+
/**
27+
* A container for a group of accordion items. It manages the overall state and
28+
* interactions of the accordion, such as keyboard navigation and expansion mode.
29+
*
30+
* The `ngAccordionGroup` serves as the root of a group of accordion triggers and panels,
31+
* coordinating the behavior of the `ngAccordionTrigger` and `ngAccordionPanel` elements within it.
32+
* It supports both single and multiple expansion modes.
33+
*
34+
* ```html
35+
* <div ngAccordionGroup [multiExpandable]="true" [(expandedPanels)]="expandedItems">
36+
* <div class="accordion-item">
37+
* <h3>
38+
* <button ngAccordionTrigger panelId="item-1">Item 1</button>
39+
* </h3>
40+
* <div ngAccordionPanel panelId="item-1">
41+
* <ng-template ngAccordionContent>
42+
* <p>Content for Item 1.</p>
43+
* </ng-template>
44+
* </div>
45+
* </div>
46+
* <div class="accordion-item">
47+
* <h3>
48+
* <button ngAccordionTrigger panelId="item-2">Item 2</button>
49+
* </h3>
50+
* <div ngAccordionPanel panelId="item-2">
51+
* <ng-template ngAccordionContent>
52+
* <p>Content for Item 2.</p>
53+
* </ng-template>
54+
* </div>
55+
* </div>
56+
* </div>
57+
* ```
58+
*
59+
* @developerPreview 21.0
60+
*/
61+
@Directive({
62+
selector: '[ngAccordionGroup]',
63+
exportAs: 'ngAccordionGroup',
64+
host: {
65+
'(keydown)': '_pattern.onKeydown($event)',
66+
'(pointerdown)': '_pattern.onPointerdown($event)',
67+
'(focusin)': '_pattern.onFocus($event)',
68+
},
69+
providers: [{provide: ACCORDION_GROUP, useExisting: AccordionGroup}],
70+
})
71+
export class AccordionGroup {
72+
/** A reference to the group element. */
73+
private readonly _elementRef = inject(ElementRef);
74+
75+
/** A reference to the group element. */
76+
readonly element = this._elementRef.nativeElement as HTMLElement;
77+
78+
/** The AccordionTriggers nested inside this group. */
79+
private readonly _triggers = contentChildren(AccordionTrigger, {descendants: true});
80+
81+
/** The AccordionTrigger patterns nested inside this group. */
82+
private readonly _triggerPatterns = computed(() => this._triggers().map(t => t._pattern));
83+
84+
/** The AccordionPanels nested inside this group. */
85+
private readonly _panels = contentChildren(AccordionPanel, {descendants: true});
86+
87+
/** The text direction (ltr or rtl). */
88+
readonly textDirection = inject(Directionality).valueSignal;
89+
90+
/** Whether the entire accordion group is disabled. */
91+
readonly disabled = input(false, {transform: booleanAttribute});
92+
93+
/** Whether multiple accordion items can be expanded simultaneously. */
94+
readonly multiExpandable = input(true, {transform: booleanAttribute});
95+
96+
/**
97+
* Whether to allow disabled items to receive focus. When `true`, disabled items are
98+
* focusable but not interactive. When `false`, disabled items are skipped during navigation.
99+
*/
100+
readonly softDisabled = input(true, {transform: booleanAttribute});
101+
102+
/** Whether keyboard navigation should wrap around from the last item to the first, and vice-versa. */
103+
readonly wrap = input(false, {transform: booleanAttribute});
104+
105+
/** The UI pattern instance for this accordion group. */
106+
readonly _pattern: AccordionGroupPattern = new AccordionGroupPattern({
107+
...this,
108+
activeItem: signal(undefined),
109+
items: this._triggerPatterns,
110+
// TODO(ok7sai): Investigate whether an accordion should support horizontal mode.
111+
orientation: () => 'vertical',
112+
getItem: e => this._getItem(e),
113+
element: () => this.element,
114+
});
115+
116+
constructor() {
117+
// Effect to link triggers with their corresponding panels and update the group's items.
118+
afterRenderEffect(() => {
119+
const triggers = this._triggers();
120+
const panels = this._panels();
121+
122+
for (const trigger of triggers) {
123+
const panel = panels.find(p => p.panelId() === trigger.panelId());
124+
trigger._accordionPanelPattern.set(panel?._pattern);
125+
if (panel) {
126+
panel._accordionTriggerPattern.set(trigger._pattern);
127+
}
128+
}
129+
});
130+
}
131+
132+
/** Expands all accordion panels if multi-expandable. */
133+
expandAll() {
134+
this._pattern.expansionBehavior.openAll();
135+
}
136+
137+
/** Collapses all accordion panels. */
138+
collapseAll() {
139+
this._pattern.expansionBehavior.closeAll();
140+
}
141+
142+
/** Gets the trigger pattern for a given element. */
143+
private _getItem(element: Element | null | undefined): AccordionTriggerPattern | undefined {
144+
let target = element;
145+
146+
while (target) {
147+
const pattern = this._triggerPatterns().find(t => t.element() === target);
148+
if (pattern) {
149+
return pattern;
150+
}
151+
152+
target = target.parentElement?.closest('[ngAccordionTrigger]');
153+
}
154+
155+
return undefined;
156+
}
157+
}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {
10+
Directive,
11+
input,
12+
inject,
13+
afterRenderEffect,
14+
signal,
15+
computed,
16+
WritableSignal,
17+
} from '@angular/core';
18+
import {_IdGenerator} from '@angular/cdk/a11y';
19+
import {
20+
DeferredContentAware,
21+
AccordionPanelPattern,
22+
AccordionTriggerPattern,
23+
} from '@angular/aria/private';
24+
25+
/**
26+
* The content panel of an accordion item that is conditionally visible.
27+
*
28+
* This directive is a container for the content that is shown or hidden. It requires
29+
* a `panelId` that must match the `panelId` of its corresponding `ngAccordionTrigger`.
30+
* The content within the panel should be provided using an `ng-template` with the
31+
* `ngAccordionContent` directive so that the content is not rendered on the page until the trigger
32+
* is expanded. It applies `role="region"` for accessibility and uses the `inert` attribute to hide
33+
* its content from assistive technologies when not visible.
34+
*
35+
* ```html
36+
* <div ngAccordionPanel panelId="unique-id-1">
37+
* <ng-template ngAccordionContent>
38+
* <p>This content is lazily rendered and will be shown when the panel is expanded.</p>
39+
* </ng-template>
40+
* </div>
41+
* ```
42+
*
43+
* @developerPreview 21.0
44+
*/
45+
@Directive({
46+
selector: '[ngAccordionPanel]',
47+
exportAs: 'ngAccordionPanel',
48+
hostDirectives: [
49+
{
50+
directive: DeferredContentAware,
51+
inputs: ['preserveContent'],
52+
},
53+
],
54+
host: {
55+
'role': 'region',
56+
'[attr.id]': '_pattern.id()',
57+
'[attr.aria-labelledby]': '_pattern.accordionTrigger()?.id()',
58+
'[attr.inert]': '!visible() ? true : null',
59+
},
60+
})
61+
export class AccordionPanel {
62+
/** The DeferredContentAware host directive. */
63+
private readonly _deferredContentAware = inject(DeferredContentAware);
64+
65+
/** A global unique identifier for the panel. */
66+
readonly id = input(inject(_IdGenerator).getId('ng-accordion-panel-', true));
67+
68+
/** A local unique identifier for the panel, used to match with its trigger's `panelId`. */
69+
readonly panelId = input.required<string>();
70+
71+
/** Whether the accordion panel is visible. True if the associated trigger is expanded. */
72+
readonly visible = computed(() => !this._pattern.hidden());
73+
74+
/** The parent accordion trigger pattern that controls this panel. This is set by AccordionGroup. */
75+
readonly _accordionTriggerPattern: WritableSignal<AccordionTriggerPattern | undefined> =
76+
signal(undefined);
77+
78+
/** The UI pattern instance for this panel. */
79+
readonly _pattern: AccordionPanelPattern = new AccordionPanelPattern({
80+
id: this.id,
81+
panelId: this.panelId,
82+
accordionTrigger: () => this._accordionTriggerPattern(),
83+
});
84+
85+
constructor() {
86+
// Connect the panel's hidden state to the DeferredContentAware's visibility.
87+
afterRenderEffect(() => {
88+
this._deferredContentAware.contentVisible.set(this.visible());
89+
});
90+
}
91+
92+
/** Expands this item. */
93+
expand() {
94+
this._accordionTriggerPattern()?.open();
95+
}
96+
97+
/** Collapses this item. */
98+
collapse() {
99+
this._accordionTriggerPattern()?.close();
100+
}
101+
102+
/** Toggles the expansion state of this item. */
103+
toggle() {
104+
this._accordionTriggerPattern()?.toggle();
105+
}
106+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
import type {AccordionGroup} from './accordion-group';
11+
12+
/** Token used to expose the accordion group. */
13+
export const ACCORDION_GROUP = new InjectionToken<AccordionGroup>('ACCORDION_GROUP');

0 commit comments

Comments
 (0)