Skip to content

Commit ccfaf91

Browse files
committed
feat(cdk-experimental/tabs): support initial tab selection and add unit tests
1 parent 8be9b8f commit ccfaf91

File tree

6 files changed

+350
-6
lines changed

6 files changed

+350
-6
lines changed

src/cdk-experimental/tabs/tabs.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
inject,
2121
input,
2222
model,
23-
signal,
23+
linkedSignal,
2424
} from '@angular/core';
2525
import {toSignal} from '@angular/core/rxjs-interop';
2626
import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns';
@@ -97,6 +97,9 @@ export class CdkTabList {
9797
/** The CdkTabs nested inside of the CdkTabList. */
9898
private readonly _cdkTabs = contentChildren(CdkTab);
9999

100+
/** The internal tab selection state. */
101+
private readonly _selection = linkedSignal(() => (this.tab() ? [this.tab()!] : []));
102+
100103
/** A signal wrapper for directionality. */
101104
protected textDirection = toSignal(this._directionality.change, {
102105
initialValue: this._directionality.value,
@@ -126,13 +129,21 @@ export class CdkTabList {
126129
/** The current index that has been navigated to. */
127130
activeIndex = model<number>(0);
128131

132+
// TODO(ok7sai): Provides a default state when there is no pre-select tab.
133+
/** The current selected tab. */
134+
tab = model<string | undefined>();
135+
129136
/** The TabList UIPattern. */
130137
pattern: TabListPattern = new TabListPattern({
131138
...this,
132139
items: this.tabs,
133140
textDirection: this.textDirection,
134-
value: signal<string[]>([]),
141+
value: this._selection,
135142
});
143+
144+
constructor() {
145+
effect(() => this.tab.set(this._selection()[0]));
146+
}
136147
}
137148

138149
/** A selectable tab in a TabList. */

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

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
load("//tools:defaults.bzl", "ts_project")
1+
load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project")
22

33
package(default_visibility = ["//visibility:public"])
44

@@ -17,3 +17,22 @@ ts_project(
1717
"//src/cdk-experimental/ui-patterns/behaviors/signal-like",
1818
],
1919
)
20+
21+
ts_project(
22+
name = "unit_test_sources",
23+
testonly = True,
24+
srcs = [
25+
"tabs.spec.ts",
26+
],
27+
deps = [
28+
":tabs",
29+
"//:node_modules/@angular/core",
30+
"//src/cdk/keycodes",
31+
"//src/cdk/testing/private",
32+
],
33+
)
34+
35+
ng_web_test_suite(
36+
name = "unit_tests",
37+
deps = [":unit_test_sources"],
38+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
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 {signal} from '@angular/core';
10+
import {
11+
TabInputs,
12+
TabPattern,
13+
TabListInputs,
14+
TabListPattern,
15+
TabPanelInputs,
16+
TabPanelPattern,
17+
} from './tabs';
18+
import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like';
19+
import {createKeyboardEvent} from '@angular/cdk/testing/private';
20+
import {ModifierKeys} from '@angular/cdk/testing';
21+
22+
// Converts the SignalLike type to WritableSignalLike type for controlling test inputs.
23+
type WritableSignalOverrides<O> = {
24+
[K in keyof O as O[K] extends SignalLike<any> ? K : never]: O[K] extends SignalLike<infer T>
25+
? WritableSignalLike<T>
26+
: never;
27+
};
28+
29+
type TestTabListInputs = TabListInputs & WritableSignalOverrides<TabListInputs>;
30+
type TestTabInputs = TabInputs & WritableSignalOverrides<TabInputs>;
31+
type TestTabPanelInputs = TabPanelInputs & WritableSignalOverrides<TabPanelInputs>;
32+
33+
const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods);
34+
const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods);
35+
const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods);
36+
const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods);
37+
const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods);
38+
const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods);
39+
const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods);
40+
const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods);
41+
42+
function createTabElement(): HTMLElement {
43+
const element = document.createElement('div');
44+
element.role = 'tab';
45+
return element;
46+
}
47+
48+
describe('Tabs Pattern', () => {
49+
let tabListInputs: TestTabListInputs;
50+
let tabListPattern: TabListPattern;
51+
let tabInputs: TestTabInputs[];
52+
let tabPatterns: TabPattern[];
53+
let tabPanelInputs: TestTabPanelInputs[];
54+
let tabPanelPatterns: TabPanelPattern[];
55+
56+
beforeEach(() => {
57+
// Initiate TabListPattern.
58+
tabListInputs = {
59+
orientation: signal('horizontal'),
60+
wrap: signal(true),
61+
textDirection: signal('ltr'),
62+
selectionMode: signal('follow'),
63+
focusMode: signal('roving'),
64+
disabled: signal(false),
65+
activeIndex: signal(0),
66+
skipDisabled: signal(true),
67+
items: signal([]),
68+
value: signal(['tab-1']),
69+
};
70+
tabListPattern = new TabListPattern(tabListInputs);
71+
72+
// Initiate a list of TabPatterns.
73+
tabInputs = [
74+
{
75+
tablist: signal(tabListPattern),
76+
tabpanel: signal(undefined),
77+
id: signal('tab-1-id'),
78+
element: signal(createTabElement()),
79+
disabled: signal(false),
80+
value: signal('tab-1'),
81+
},
82+
{
83+
tablist: signal(tabListPattern),
84+
tabpanel: signal(undefined),
85+
id: signal('tab-2-id'),
86+
element: signal(createTabElement()),
87+
disabled: signal(false),
88+
value: signal('tab-2'),
89+
},
90+
{
91+
tablist: signal(tabListPattern),
92+
tabpanel: signal(undefined),
93+
id: signal('tab-3-id'),
94+
element: signal(createTabElement()),
95+
disabled: signal(false),
96+
value: signal('tab-3'),
97+
},
98+
];
99+
tabPatterns = [
100+
new TabPattern(tabInputs[0]),
101+
new TabPattern(tabInputs[1]),
102+
new TabPattern(tabInputs[2]),
103+
];
104+
105+
// Initiate a list of TabPanelPatterns.
106+
tabPanelInputs = [
107+
{
108+
id: signal('tabpanel-1-id'),
109+
tab: signal(undefined),
110+
value: signal('tab-1'),
111+
},
112+
{
113+
id: signal('tabpanel-2-id'),
114+
tab: signal(undefined),
115+
value: signal('tab-2'),
116+
},
117+
{
118+
id: signal('tabpanel-3-id'),
119+
tab: signal(undefined),
120+
value: signal('tab-3'),
121+
},
122+
];
123+
tabPanelPatterns = [
124+
new TabPanelPattern(tabPanelInputs[0]),
125+
new TabPanelPattern(tabPanelInputs[1]),
126+
new TabPanelPattern(tabPanelInputs[2]),
127+
];
128+
129+
// Binding between tabs and tabpanels.
130+
tabInputs[0].tabpanel.set(tabPanelPatterns[0]);
131+
tabInputs[1].tabpanel.set(tabPanelPatterns[1]);
132+
tabInputs[2].tabpanel.set(tabPanelPatterns[2]);
133+
tabPanelInputs[0].tab.set(tabPatterns[0]);
134+
tabPanelInputs[1].tab.set(tabPatterns[1]);
135+
tabPanelInputs[2].tab.set(tabPatterns[2]);
136+
tabListInputs.items.set(tabPatterns);
137+
});
138+
139+
it('sets the selected tab by setting `value`.', () => {
140+
expect(tabPatterns[0].selected()).toBeTrue();
141+
expect(tabPatterns[1].selected()).toBeFalse();
142+
tabListInputs.value.set(['tab-2']);
143+
expect(tabPatterns[0].selected()).toBeFalse();
144+
expect(tabPatterns[1].selected()).toBeTrue();
145+
});
146+
147+
it('sets a tabpanel to be not hidden if a tab is selected.', () => {
148+
tabListInputs.value.set(['tab-1']);
149+
expect(tabPatterns[0].selected()).toBeTrue();
150+
expect(tabPanelPatterns[0].hidden()).toBeFalse();
151+
});
152+
153+
it('sets a tabpanel to be hidden if a tab is not selected.', () => {
154+
tabListInputs.value.set(['tab-1']);
155+
expect(tabPatterns[1].selected()).toBeFalse();
156+
expect(tabPanelPatterns[1].hidden()).toBeTrue();
157+
});
158+
159+
it('gets a controlled tabpanel id from a tab.', () => {
160+
expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id');
161+
expect(tabPatterns[0].controls()).toBe('tabpanel-1-id');
162+
expect(tabPanelPatterns[1].id()).toBe('tabpanel-2-id');
163+
expect(tabPatterns[1].controls()).toBe('tabpanel-2-id');
164+
expect(tabPanelPatterns[2].id()).toBe('tabpanel-3-id');
165+
expect(tabPatterns[2].controls()).toBe('tabpanel-3-id');
166+
});
167+
168+
describe('Keyboard Navigation', () => {
169+
it('does not handle keyboard event if a tablist is disabled.', () => {
170+
expect(tabPatterns[1].active()).toBeFalse();
171+
tabListInputs.disabled.set(true);
172+
tabListPattern.onKeydown(right());
173+
expect(tabPatterns[1].active()).toBeFalse();
174+
});
175+
176+
it('skips the disabled tab when `skipDisabled` is set to true.', () => {
177+
tabInputs[1].disabled.set(true);
178+
tabListPattern.onKeydown(right());
179+
expect(tabPatterns[0].active()).toBeFalse();
180+
expect(tabPatterns[1].active()).toBeFalse();
181+
expect(tabPatterns[2].active()).toBeTrue();
182+
});
183+
184+
it('does not skip the disabled tab when `skipDisabled` is set to false.', () => {
185+
tabListInputs.skipDisabled.set(false);
186+
tabInputs[1].disabled.set(true);
187+
tabListPattern.onKeydown(right());
188+
expect(tabPatterns[0].active()).toBeFalse();
189+
expect(tabPatterns[1].active()).toBeTrue();
190+
expect(tabPatterns[2].active()).toBeFalse();
191+
});
192+
193+
it('selects a tab by focus if `selectionMode` is "follow".', () => {
194+
expect(tabPatterns[0].selected()).toBeTrue();
195+
expect(tabPatterns[1].selected()).toBeFalse();
196+
tabListPattern.onKeydown(right());
197+
expect(tabPatterns[0].selected()).toBeFalse();
198+
expect(tabPatterns[1].selected()).toBeTrue();
199+
});
200+
201+
it('selects a tab by enter key if `selectionMode` is "explicit".', () => {
202+
tabListInputs.selectionMode.set('explicit');
203+
expect(tabPatterns[0].selected()).toBeTrue();
204+
expect(tabPatterns[1].selected()).toBeFalse();
205+
tabListPattern.onKeydown(right());
206+
expect(tabPatterns[0].selected()).toBeTrue();
207+
expect(tabPatterns[1].selected()).toBeFalse();
208+
tabListPattern.onKeydown(enter());
209+
expect(tabPatterns[0].selected()).toBeFalse();
210+
expect(tabPatterns[1].selected()).toBeTrue();
211+
});
212+
213+
it('selects a tab by space key if `selectionMode` is "explicit".', () => {
214+
tabListInputs.selectionMode.set('explicit');
215+
expect(tabPatterns[0].selected()).toBeTrue();
216+
expect(tabPatterns[1].selected()).toBeFalse();
217+
tabListPattern.onKeydown(right());
218+
expect(tabPatterns[0].selected()).toBeTrue();
219+
expect(tabPatterns[1].selected()).toBeFalse();
220+
tabListPattern.onKeydown(space());
221+
expect(tabPatterns[0].selected()).toBeFalse();
222+
expect(tabPatterns[1].selected()).toBeTrue();
223+
});
224+
225+
it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => {
226+
tabListInputs.activeIndex.set(1);
227+
expect(tabPatterns[1].active()).toBeTrue();
228+
tabListPattern.onKeydown(left());
229+
expect(tabPatterns[0].active()).toBeTrue();
230+
});
231+
232+
it('uses right key to navigate to the next tab when `orientation` is set to "horizontal".', () => {
233+
tabListInputs.activeIndex.set(1);
234+
expect(tabPatterns[1].active()).toBeTrue();
235+
tabListPattern.onKeydown(right());
236+
expect(tabPatterns[2].active()).toBeTrue();
237+
});
238+
239+
it('uses up key to navigate to the previous tab when `orientation` is set to "vertical".', () => {
240+
tabListInputs.orientation.set('vertical');
241+
tabListInputs.activeIndex.set(1);
242+
expect(tabPatterns[1].active()).toBeTrue();
243+
tabListPattern.onKeydown(up());
244+
expect(tabPatterns[0].active()).toBeTrue();
245+
});
246+
247+
it('uses down key to navigate to the next tab when `orientation` is set to "vertical".', () => {
248+
tabListInputs.orientation.set('vertical');
249+
tabListInputs.activeIndex.set(1);
250+
expect(tabPatterns[1].active()).toBeTrue();
251+
tabListPattern.onKeydown(down());
252+
expect(tabPatterns[2].active()).toBeTrue();
253+
});
254+
255+
it('uses home key to navigate to the first tab.', () => {
256+
tabListInputs.activeIndex.set(1);
257+
expect(tabPatterns[1].active()).toBeTrue();
258+
tabListPattern.onKeydown(home());
259+
expect(tabPatterns[0].active()).toBeTrue();
260+
});
261+
262+
it('uses end key to navigate to the last tab.', () => {
263+
tabListInputs.activeIndex.set(1);
264+
expect(tabPatterns[1].active()).toBeTrue();
265+
tabListPattern.onKeydown(end());
266+
expect(tabPatterns[2].active()).toBeTrue();
267+
});
268+
269+
it('moves to the last tab from first tab when navigating to the previous tab if `wrap` is set to true', () => {
270+
expect(tabPatterns[0].active()).toBeTrue();
271+
tabListPattern.onKeydown(left());
272+
expect(tabPatterns[2].active()).toBeTrue();
273+
});
274+
275+
it('moves to the first tab from last tab when navigating to the next tab if `wrap` is set to true', () => {
276+
tabListPattern.onKeydown(end());
277+
expect(tabPatterns[2].active()).toBeTrue();
278+
tabListPattern.onKeydown(right());
279+
expect(tabPatterns[0].active()).toBeTrue();
280+
});
281+
282+
it('stays on the first tab when navigating to the previous tab if `wrap` is set to false', () => {
283+
tabListInputs.wrap.set(false);
284+
expect(tabPatterns[0].active()).toBeTrue();
285+
tabListPattern.onKeydown(left());
286+
expect(tabPatterns[0].active()).toBeTrue();
287+
});
288+
289+
it('stays on the last tab when navigating to the next tab if `wrap` is set to false', () => {
290+
tabListInputs.wrap.set(false);
291+
tabListPattern.onKeydown(end());
292+
expect(tabPatterns[2].active()).toBeTrue();
293+
tabListPattern.onKeydown(right());
294+
expect(tabPatterns[2].active()).toBeTrue();
295+
});
296+
297+
it('changes the navigation direction with `rtl` mode.', () => {
298+
tabListInputs.textDirection.set('rtl');
299+
tabListInputs.activeIndex.set(1);
300+
tabListPattern.onKeydown(left());
301+
expect(tabPatterns[2].active()).toBeTrue();
302+
});
303+
});
304+
});

src/cdk-experimental/ui-patterns/tabs/tabs.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9-
import {computed, signal} from '@angular/core';
10-
9+
import {computed} from '@angular/core';
1110
import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager';
1211
import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager';
1312
import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus';
@@ -190,7 +189,7 @@ export class TabListPattern {
190189
this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager});
191190
this.selection = new ListSelection({
192191
...inputs,
193-
multi: signal(false),
192+
multi: () => false,
194193
focusManager: this.focusManager,
195194
});
196195
}

0 commit comments

Comments
 (0)