Skip to content

Commit 8e0c53c

Browse files
committed
refactor(aria/tabs): split directives and resolve circular dependencies
Splits up the directives across files and resolve circular dependencies in them.
1 parent 4033c7d commit 8e0c53c

File tree

10 files changed

+461
-378
lines changed

10 files changed

+461
-378
lines changed

goldens/aria/tabs/index.api.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export class TabList implements OnInit, OnDestroy {
6161
readonly selectedTab: _angular_core.ModelSignal<string | undefined>;
6262
readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">;
6363
readonly softDisabled: _angular_core.InputSignalWithTransform<boolean, unknown>;
64-
readonly _tabPatterns: _angular_core.Signal<TabPattern[]>;
64+
readonly _tabPatterns: _angular_core.Signal<i1.TabPattern[]>;
6565
readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>;
6666
// (undocumented)
6767
_unregister(child: Tab): void;
@@ -95,8 +95,8 @@ export class Tabs {
9595
readonly element: HTMLElement;
9696
// (undocumented)
9797
_register(child: TabList | TabPanel): void;
98-
readonly _tabPatterns: _angular_core.Signal<TabPattern[] | undefined>;
99-
readonly _unorderedTabpanelPatterns: _angular_core.Signal<TabPanelPattern[]>;
98+
readonly _tabPatterns: _angular_core.Signal<i1.TabPattern[] | undefined>;
99+
readonly _unorderedTabpanelPatterns: _angular_core.Signal<i1.TabPanelPattern[]>;
100100
// (undocumented)
101101
_unregister(child: TabList | TabPanel): void;
102102
// (undocumented)

src/aria/tabs/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 = "tabs",
7-
srcs = [
8-
"index.ts",
9-
"tabs.ts",
10-
],
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
1111
deps = [
1212
"//:node_modules/@angular/core",
1313
"//src/aria/private",

src/aria/tabs/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,8 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
export {Tabs, TabList, Tab, TabPanel, TabContent} from './tabs';
9+
export {Tabs} from './tabs';
10+
export {TabList} from './tab-list';
11+
export {Tab} from './tab';
12+
export {TabPanel} from './tab-panel';
13+
export {TabContent} from './tab-content';

src/aria/tabs/tab-content.ts

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 TabContent container for the lazy-loaded content.
14+
*
15+
* This structural directive should be applied to an `ng-template` within an `ngTabPanel`.
16+
* It enables lazy loading of the tab's content, meaning the content is only rendered
17+
* when the tab is activated for the first time.
18+
*
19+
* ```html
20+
* <div ngTabPanel value="myTabId">
21+
* <ng-template ngTabContent>
22+
* <p>This content will be loaded when 'myTabId' is selected.</p>
23+
* </ng-template>
24+
* </div>
25+
* ```
26+
*
27+
* @developerPreview 21.0
28+
*/
29+
@Directive({
30+
selector: 'ng-template[ngTabContent]',
31+
exportAs: 'ngTabContent',
32+
hostDirectives: [DeferredContent],
33+
})
34+
export class TabContent {}

src/aria/tabs/tab-list.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
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 {Directionality} from '@angular/cdk/bidi';
10+
import {
11+
booleanAttribute,
12+
computed,
13+
Directive,
14+
ElementRef,
15+
inject,
16+
input,
17+
model,
18+
signal,
19+
afterRenderEffect,
20+
OnInit,
21+
OnDestroy,
22+
} from '@angular/core';
23+
import {TabListPattern} from '@angular/aria/private';
24+
import {sortDirectives, TABS} from './utils';
25+
import type {Tab} from './tab';
26+
27+
/**
28+
* A TabList container.
29+
*
30+
* The `ngTabList` directive controls a list of `ngTab` elements. It manages keyboard
31+
* navigation, selection, and the overall orientation of the tabs. It should be placed
32+
* within an `ngTabs` container.
33+
*
34+
* ```html
35+
* <ul ngTabList [(selectedTab)]="mySelectedTab" orientation="horizontal" selectionMode="explicit">
36+
* <li ngTab value="first">First Tab</li>
37+
* <li ngTab value="second">Second Tab</li>
38+
* </ul>
39+
* ```
40+
*
41+
* @developerPreview 21.0
42+
*/
43+
@Directive({
44+
selector: '[ngTabList]',
45+
exportAs: 'ngTabList',
46+
host: {
47+
'role': 'tablist',
48+
'[attr.tabindex]': '_pattern.tabIndex()',
49+
'[attr.aria-disabled]': '_pattern.disabled()',
50+
'[attr.aria-orientation]': '_pattern.orientation()',
51+
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
52+
'(keydown)': '_pattern.onKeydown($event)',
53+
'(pointerdown)': '_pattern.onPointerdown($event)',
54+
'(focusin)': '_onFocus()',
55+
},
56+
})
57+
export class TabList implements OnInit, OnDestroy {
58+
/** A reference to the host element. */
59+
private readonly _elementRef = inject(ElementRef);
60+
61+
/** A reference to the host element. */
62+
readonly element = this._elementRef.nativeElement as HTMLElement;
63+
64+
/** The parent Tabs. */
65+
private readonly _tabs = inject(TABS);
66+
67+
/** The Tabs nested inside of the TabList. */
68+
private readonly _unorderedTabs = signal(new Set<Tab>());
69+
70+
/** Text direction. */
71+
readonly textDirection = inject(Directionality).valueSignal;
72+
73+
/** The Tab UIPatterns of the child Tabs. */
74+
readonly _tabPatterns = computed(() =>
75+
[...this._unorderedTabs()].sort(sortDirectives).map(tab => tab._pattern),
76+
);
77+
78+
/** Whether the tablist is vertically or horizontally oriented. */
79+
readonly orientation = input<'vertical' | 'horizontal'>('horizontal');
80+
81+
/** Whether focus should wrap when navigating. */
82+
readonly wrap = input(true, {transform: booleanAttribute});
83+
84+
/**
85+
* Whether to allow disabled items to receive focus. When `true`, disabled items are
86+
* focusable but not interactive. When `false`, disabled items are skipped during navigation.
87+
*/
88+
readonly softDisabled = input(true, {transform: booleanAttribute});
89+
90+
/**
91+
* The focus strategy used by the tablist.
92+
* - `roving`: Focus is moved to the active tab using `tabindex`.
93+
* - `activedescendant`: Focus remains on the tablist container, and `aria-activedescendant` is used to indicate the active tab.
94+
*/
95+
readonly focusMode = input<'roving' | 'activedescendant'>('roving');
96+
97+
/**
98+
* The selection strategy used by the tablist.
99+
* - `follow`: The focused tab is automatically selected.
100+
* - `explicit`: Tabs are selected explicitly by the user (e.g., via click or spacebar).
101+
*/
102+
readonly selectionMode = input<'follow' | 'explicit'>('follow');
103+
104+
/** The current selected tab. */
105+
readonly selectedTab = model<string | undefined>();
106+
107+
/** Whether the tablist is disabled. */
108+
readonly disabled = input(false, {transform: booleanAttribute});
109+
110+
/** The TabList UIPattern. */
111+
readonly _pattern: TabListPattern = new TabListPattern({
112+
...this,
113+
items: this._tabPatterns,
114+
activeItem: signal(undefined),
115+
element: () => this._elementRef.nativeElement,
116+
});
117+
118+
/** Whether the tree has received focus yet. */
119+
private _hasFocused = signal(false);
120+
121+
constructor() {
122+
afterRenderEffect(() => {
123+
if (!this._hasFocused()) {
124+
this._pattern.setDefaultState();
125+
}
126+
});
127+
128+
afterRenderEffect(() => {
129+
const tab = this._pattern.selectedTab();
130+
if (tab) {
131+
this.selectedTab.set(tab.value());
132+
}
133+
});
134+
135+
afterRenderEffect(() => {
136+
const value = this.selectedTab();
137+
if (value) {
138+
this._pattern.open(value);
139+
}
140+
});
141+
}
142+
143+
_onFocus() {
144+
this._hasFocused.set(true);
145+
}
146+
147+
ngOnInit() {
148+
this._tabs._register(this);
149+
}
150+
151+
ngOnDestroy() {
152+
this._tabs._unregister(this);
153+
}
154+
155+
_register(child: Tab) {
156+
this._unorderedTabs().add(child);
157+
this._unorderedTabs.set(new Set(this._unorderedTabs()));
158+
}
159+
160+
_unregister(child: Tab) {
161+
this._unorderedTabs().delete(child);
162+
this._unorderedTabs.set(new Set(this._unorderedTabs()));
163+
}
164+
165+
/** Opens the tab panel with the specified value. */
166+
open(value: string): boolean {
167+
return this._pattern.open(value);
168+
}
169+
}

src/aria/tabs/tab-panel.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
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 {_IdGenerator} from '@angular/cdk/a11y';
10+
import {
11+
computed,
12+
Directive,
13+
ElementRef,
14+
inject,
15+
input,
16+
afterRenderEffect,
17+
OnInit,
18+
OnDestroy,
19+
} from '@angular/core';
20+
import {TabPanelPattern, DeferredContentAware} from '@angular/aria/private';
21+
import {TABS} from './utils';
22+
23+
/**
24+
* A TabPanel container for the resources of layered content associated with a tab.
25+
*
26+
* The `ngTabPanel` directive holds the content for a specific tab. It is linked to an
27+
* `ngTab` by a matching `value`. If a tab panel is hidden, the `inert` attribute will be
28+
* applied to remove it from the accessibility tree. Proper styling is required for visual hiding.
29+
*
30+
* ```html
31+
* <div ngTabPanel value="myTabId">
32+
* <ng-template ngTabContent>
33+
* <!-- Content for the tab panel -->
34+
* </ng-template>
35+
* </div>
36+
* ```
37+
*
38+
* @developerPreview 21.0
39+
*/
40+
@Directive({
41+
selector: '[ngTabPanel]',
42+
exportAs: 'ngTabPanel',
43+
host: {
44+
'role': 'tabpanel',
45+
'[attr.id]': '_pattern.id()',
46+
'[attr.tabindex]': '_pattern.tabIndex()',
47+
'[attr.inert]': '!visible() ? true : null',
48+
'[attr.aria-labelledby]': '_pattern.labelledBy()',
49+
},
50+
hostDirectives: [
51+
{
52+
directive: DeferredContentAware,
53+
inputs: ['preserveContent'],
54+
},
55+
],
56+
})
57+
export class TabPanel implements OnInit, OnDestroy {
58+
/** A reference to the host element. */
59+
private readonly _elementRef = inject(ElementRef);
60+
61+
/** A reference to the host element. */
62+
readonly element = this._elementRef.nativeElement as HTMLElement;
63+
64+
/** The DeferredContentAware host directive. */
65+
private readonly _deferredContentAware = inject(DeferredContentAware);
66+
67+
/** The parent Tabs. */
68+
private readonly _tabs = inject(TABS);
69+
70+
/** A global unique identifier for the tab. */
71+
readonly id = input(inject(_IdGenerator).getId('ng-tabpanel-', true));
72+
73+
/** The Tab UIPattern associated with the tabpanel */
74+
private readonly _tabPattern = computed(() =>
75+
this._tabs._tabPatterns()?.find(tab => tab.value() === this.value()),
76+
);
77+
78+
/** A local unique identifier for the tabpanel. */
79+
readonly value = input.required<string>();
80+
81+
/** Whether the tab panel is visible. */
82+
readonly visible = computed(() => !this._pattern.hidden());
83+
84+
/** The TabPanel UIPattern. */
85+
readonly _pattern: TabPanelPattern = new TabPanelPattern({
86+
...this,
87+
tab: this._tabPattern,
88+
});
89+
90+
constructor() {
91+
afterRenderEffect(() => this._deferredContentAware.contentVisible.set(this.visible()));
92+
}
93+
94+
ngOnInit() {
95+
this._tabs._register(this);
96+
}
97+
98+
ngOnDestroy() {
99+
this._tabs._unregister(this);
100+
}
101+
}

0 commit comments

Comments
 (0)