Skip to content

Commit f54cc85

Browse files
committed
feat(cdk-experimental/ui-patterns): tabs ui pattern
1 parent 31c4dad commit f54cc85

21 files changed

+833
-0
lines changed

src/cdk-experimental/tabs/BUILD.bazel

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
load("//tools:defaults.bzl", "ng_module")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ng_module(
6+
name = "tabs",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//src/cdk-experimental/ui-patterns/tabs",
13+
"//src/cdk/a11y",
14+
"//src/cdk/bidi",
15+
],
16+
)

src/cdk-experimental/tabs/index.ts

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
export * from './public-api';
+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
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+
export {CdkTabs, CdkTablist, CdkTab, CdkTabpanel} from './tabs';

src/cdk-experimental/tabs/tabs.ts

+200
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
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+
booleanAttribute,
11+
computed,
12+
contentChildren,
13+
Directive,
14+
ElementRef,
15+
inject,
16+
input,
17+
model,
18+
} from '@angular/core';
19+
import {Directionality} from '@angular/cdk/bidi';
20+
import {TabPattern} from '@angular/cdk-experimental/ui-patterns/tabs/tab';
21+
import {TablistPattern} from '@angular/cdk-experimental/ui-patterns/tabs/tablist';
22+
import {TabpanelPattern} from '@angular/cdk-experimental/ui-patterns/tabs/tabpanel';
23+
import {toSignal} from '@angular/core/rxjs-interop';
24+
import {_IdGenerator} from '@angular/cdk/a11y';
25+
26+
@Directive({
27+
selector: '[cdkTabs]',
28+
exportAs: 'cdkTabs',
29+
host: {
30+
'class': 'cdk-tabs',
31+
},
32+
})
33+
export class CdkTabs {
34+
/** The CdkTabs nested inside of the container. */
35+
private readonly _cdkTabs = contentChildren(CdkTab, {descendants: true});
36+
37+
/** The CdkTabpanels nested inside of the container. */
38+
private readonly _cdkTabpanels = contentChildren(CdkTabpanel, {descendants: true});
39+
40+
/** The Tab UIPattern of the child Tabs. */
41+
tabs = computed(() => this._cdkTabs().map(tab => tab.pattern));
42+
43+
/** The Tabpanel UIPattern of the child Tabpanels. */
44+
tabpanels = computed(() => this._cdkTabpanels().map(tabpanel => tabpanel.pattern));
45+
}
46+
47+
@Directive({
48+
selector: '[cdkTablist]',
49+
exportAs: 'cdkTablist',
50+
host: {
51+
'role': 'tablist',
52+
'class': 'cdk-tablist',
53+
'[attr.tabindex]': 'pattern.tabindex()',
54+
'[attr.aria-disabled]': 'pattern.disabled()',
55+
'[attr.aria-orientation]': 'pattern.orientation()',
56+
'[attr.aria-multiselectable]': 'pattern.multiselectable()',
57+
'[attr.aria-activedescendant]': 'pattern.activedescendant()',
58+
'(keydown)': 'pattern.onKeydown($event)',
59+
'(mousedown)': 'pattern.onPointerdown($event)',
60+
},
61+
})
62+
export class CdkTablist {
63+
/** The directionality (LTR / RTL) context for the application (or a subtree of it). */
64+
private readonly _directionality = inject(Directionality);
65+
66+
/** The CdkTabs nested inside of the CdkTablist. */
67+
private readonly _cdkTabs = contentChildren(CdkTab, {descendants: true});
68+
69+
/** A signal wrapper for directionality. */
70+
protected textDirection = toSignal(this._directionality.change, {
71+
initialValue: this._directionality.value,
72+
});
73+
74+
/** The Tab UIPatterns of the child Tabs. */
75+
protected items = computed(() => this._cdkTabs().map(tab => tab.pattern));
76+
77+
/** Whether the tablist is vertically or horizontally oriented. */
78+
orientation = input<'vertical' | 'horizontal'>('horizontal');
79+
80+
/** Whether multiple items in the list can be selected at once. */
81+
multiselectable = input(false, {transform: booleanAttribute});
82+
83+
/** Whether focus should wrap when navigating. */
84+
wrap = input(true, {transform: booleanAttribute});
85+
86+
/** Whether disabled items in the list should be skipped when navigating. */
87+
skipDisabled = input(true, {transform: booleanAttribute});
88+
89+
/** The focus strategy used by the tablist. */
90+
focusMode = input<'roving' | 'activedescendant'>('roving');
91+
92+
/** The selection strategy used by the tablist. */
93+
selectionMode = input<'follow' | 'explicit'>('follow');
94+
95+
/** Whether the tablist is disabled. */
96+
disabled = input(false, {transform: booleanAttribute});
97+
98+
/** The ids of the current selected tab. */
99+
selectedIds = model<string[]>([]);
100+
101+
/** The current index that has been navigated to. */
102+
activeIndex = model<number>(0);
103+
104+
/** The Tablist UIPattern. */
105+
pattern: TablistPattern = new TablistPattern({
106+
...this,
107+
items: this.items,
108+
textDirection: this.textDirection,
109+
});
110+
}
111+
112+
@Directive({
113+
selector: '[cdkTab]',
114+
exportAs: 'cdkTab',
115+
host: {
116+
'role': 'tab',
117+
'class': 'cdk-tab',
118+
'[attr.id]': 'pattern.id()',
119+
'[attr.tabindex]': 'pattern.tabindex()',
120+
'[attr.aria-selected]': 'pattern.selected()',
121+
'[attr.aria-disabled]': 'pattern.disabled()',
122+
'[attr.aria-controls]': 'pattern.controls()',
123+
},
124+
})
125+
export class CdkTab {
126+
/** A reference to the tab element. */
127+
private readonly _elementRef = inject(ElementRef);
128+
129+
/** The parent CdkTabs. */
130+
private readonly _cdkTabs = inject(CdkTabs);
131+
132+
/** The parent CdkTablist. */
133+
private readonly _cdkTablist = inject(CdkTablist);
134+
135+
/** A unique identifier for the tab. */
136+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-tab-');
137+
138+
/** A unique identifier for the tab. */
139+
protected id = computed(() => this._generatedId);
140+
141+
/** A reference to the tab element to be focused on navigation. */
142+
protected element = computed(() => this._elementRef.nativeElement);
143+
144+
/** The position of the tab in the list. */
145+
protected index = computed(() => this._cdkTabs.tabs().findIndex(tab => tab.id === this.id));
146+
147+
/** The parent Tablist UIPattern. */
148+
protected tablist = computed(() => this._cdkTablist.pattern);
149+
150+
/** The Tabpanel UIPattern associated with the tab */
151+
protected tabpanel = computed(() => this._cdkTabs.tabpanels()[this.index()]);
152+
153+
/** Whether a tab is disabled. */
154+
disabled = input(false, {transform: booleanAttribute});
155+
156+
/** The Tab UIPattern. */
157+
pattern: TabPattern = new TabPattern({
158+
...this,
159+
id: this.id,
160+
tablist: this.tablist,
161+
tabpanel: this.tabpanel,
162+
element: this.element,
163+
});
164+
}
165+
166+
@Directive({
167+
selector: '[cdkTabpanel]',
168+
exportAs: 'cdkTabpanel',
169+
host: {
170+
'role': 'tabpanel',
171+
'class': 'cdk-tabpanel',
172+
'[attr.id]': 'pattern.id()',
173+
'[attr.aria-hidden]': 'pattern.hidden()',
174+
},
175+
})
176+
export class CdkTabpanel {
177+
/** The parent CdkTabs. */
178+
private readonly _cdkTabs = inject(CdkTabs);
179+
180+
/** A unique identifier for the tab. */
181+
private readonly _generatedId = inject(_IdGenerator).getId('cdk-tabpanel-');
182+
183+
/** A unique identifier for the tabpanel. */
184+
protected id = computed(() => this._generatedId);
185+
186+
/** The position of the tabpanel in the tabs. */
187+
protected index = computed(() =>
188+
this._cdkTabs.tabpanels().findIndex(tabpanel => tabpanel.id === this.id),
189+
);
190+
191+
/** The Tab UIPattern associated with the tabpanel */
192+
protected tab = computed(() => this._cdkTabs.tabs()[this.index()]);
193+
194+
/** The Tabpanel UIPattern. */
195+
pattern: TabpanelPattern = new TabpanelPattern({
196+
...this,
197+
id: this.id,
198+
tab: this.tab,
199+
});
200+
}

src/cdk-experimental/ui-patterns/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ ts_library(
1010
),
1111
deps = [
1212
"//src/cdk-experimental/ui-patterns/listbox",
13+
"//src/cdk-experimental/ui-patterns/tabs",
1314
"@npm//@angular/core",
1415
],
1516
)

src/cdk-experimental/ui-patterns/public-api.ts

+3
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,6 @@
88

99
export * from './listbox/listbox';
1010
export * from './listbox/option';
11+
export * from './tabs/tab';
12+
export * from './tabs/tablist';
13+
export * from './tabs/tabpanel';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
load("//tools:defaults.bzl", "ts_library")
2+
3+
package(default_visibility = ["//visibility:public"])
4+
5+
ts_library(
6+
name = "tabs",
7+
srcs = glob(
8+
["**/*.ts"],
9+
exclude = ["**/*.spec.ts"],
10+
),
11+
deps = [
12+
"//src/cdk-experimental/ui-patterns/behaviors/event-manager",
13+
"//src/cdk-experimental/ui-patterns/behaviors/list-focus",
14+
"//src/cdk-experimental/ui-patterns/behaviors/list-navigation",
15+
"//src/cdk-experimental/ui-patterns/behaviors/list-selection",
16+
"@npm//@angular/core",
17+
],
18+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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 {computed, Signal} from '@angular/core';
10+
import {ListSelection, ListSelectionItem} from '../behaviors/list-selection/list-selection';
11+
import {ListNavigation, ListNavigationItem} from '../behaviors/list-navigation/list-navigation';
12+
import {ListFocus, ListFocusItem} from '../behaviors/list-focus/list-focus';
13+
import {TabpanelPattern} from './tabpanel';
14+
15+
interface TablistPattern {
16+
focusManager: ListFocus<TabPattern>;
17+
selection: ListSelection<TabPattern>;
18+
navigation: ListNavigation<TabPattern>;
19+
}
20+
21+
/** The required inputs to tabs. */
22+
export interface TabInputs extends ListNavigationItem, ListSelectionItem, ListFocusItem {
23+
tablist: Signal<TablistPattern>;
24+
tabpanel: Signal<TabpanelPattern>;
25+
}
26+
27+
/** A tab in a tablist. */
28+
export class TabPattern {
29+
/** A unique identifier for the tab. */
30+
id: Signal<string>;
31+
32+
/** The position of the tab in the list. */
33+
index = computed(
34+
() =>
35+
this.tablist()
36+
.navigation.inputs.items()
37+
.findIndex(i => i.id() === this.id()) ?? -1,
38+
);
39+
40+
/** Whether the tab is selected. */
41+
selected = computed(() => this.tablist().selection.inputs.selectedIds().includes(this.id()));
42+
43+
/** A Tabpanel Id controlled by the tab. */
44+
controls = computed(() => this.tabpanel().id());
45+
46+
/** Whether the tab is disabled. */
47+
disabled: Signal<boolean>;
48+
49+
/** A reference to the parent tablist. */
50+
tablist: Signal<TablistPattern>;
51+
52+
/** A reference to the corresponding tabpanel. */
53+
tabpanel: Signal<TabpanelPattern>;
54+
55+
/** The tabindex of the tab. */
56+
tabindex = computed(() => this.tablist().focusManager.getItemTabindex(this));
57+
58+
/** The html element that should receive focus. */
59+
element: Signal<HTMLElement>;
60+
61+
constructor(args: TabInputs) {
62+
this.id = args.id;
63+
this.tablist = args.tablist;
64+
this.tabpanel = args.tabpanel;
65+
this.element = args.element;
66+
this.disabled = args.disabled;
67+
}
68+
}

0 commit comments

Comments
 (0)