diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts index 68768319462b..5194d655c9fc 100644 --- a/src/cdk-experimental/tabs/tabs.ts +++ b/src/cdk-experimental/tabs/tabs.ts @@ -20,7 +20,7 @@ import { inject, input, model, - signal, + linkedSignal, } from '@angular/core'; import {toSignal} from '@angular/core/rxjs-interop'; import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns'; @@ -97,6 +97,9 @@ export class CdkTabList { /** The CdkTabs nested inside of the CdkTabList. */ private readonly _cdkTabs = contentChildren(CdkTab); + /** The internal tab selection state. */ + private readonly _selection = linkedSignal(() => (this.tab() ? [this.tab()!] : [])); + /** A signal wrapper for directionality. */ protected textDirection = toSignal(this._directionality.change, { initialValue: this._directionality.value, @@ -126,13 +129,21 @@ export class CdkTabList { /** The current index that has been navigated to. */ activeIndex = model(0); + // TODO(ok7sai): Provides a default state when there is no pre-select tab. + /** The current selected tab. */ + tab = model(); + /** The TabList UIPattern. */ pattern: TabListPattern = new TabListPattern({ ...this, items: this.tabs, textDirection: this.textDirection, - value: signal([]), + value: this._selection, }); + + constructor() { + effect(() => this.tab.set(this._selection()[0])); + } } /** A selectable tab in a TabList. */ diff --git a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel index 01853ef19a33..c404b9c384c7 100644 --- a/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel +++ b/src/cdk-experimental/ui-patterns/tabs/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ts_project") +load("//tools:defaults.bzl", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -17,3 +17,22 @@ ts_project( "//src/cdk-experimental/ui-patterns/behaviors/signal-like", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tabs.spec.ts", + ], + deps = [ + ":tabs", + "//:node_modules/@angular/core", + "//src/cdk/keycodes", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts new file mode 100644 index 000000000000..8840626708dd --- /dev/null +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts @@ -0,0 +1,304 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.dev/license + */ + +import {signal} from '@angular/core'; +import { + TabInputs, + TabPattern, + TabListInputs, + TabListPattern, + TabPanelInputs, + TabPanelPattern, +} from './tabs'; +import {SignalLike, WritableSignalLike} from '../behaviors/signal-like/signal-like'; +import {createKeyboardEvent} from '@angular/cdk/testing/private'; +import {ModifierKeys} from '@angular/cdk/testing'; + +// Converts the SignalLike type to WritableSignalLike type for controlling test inputs. +type WritableSignalOverrides = { + [K in keyof O as O[K] extends SignalLike ? K : never]: O[K] extends SignalLike + ? WritableSignalLike + : never; +}; + +type TestTabListInputs = TabListInputs & WritableSignalOverrides; +type TestTabInputs = TabInputs & WritableSignalOverrides; +type TestTabPanelInputs = TabPanelInputs & WritableSignalOverrides; + +const up = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 38, 'ArrowUp', mods); +const down = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 40, 'ArrowDown', mods); +const left = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 37, 'ArrowLeft', mods); +const right = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 39, 'ArrowRight', mods); +const home = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 36, 'Home', mods); +const end = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 35, 'End', mods); +const space = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 32, ' ', mods); +const enter = (mods?: ModifierKeys) => createKeyboardEvent('keydown', 13, 'Enter', mods); + +function createTabElement(): HTMLElement { + const element = document.createElement('div'); + element.role = 'tab'; + return element; +} + +describe('Tabs Pattern', () => { + let tabListInputs: TestTabListInputs; + let tabListPattern: TabListPattern; + let tabInputs: TestTabInputs[]; + let tabPatterns: TabPattern[]; + let tabPanelInputs: TestTabPanelInputs[]; + let tabPanelPatterns: TabPanelPattern[]; + + beforeEach(() => { + // Initiate TabListPattern. + tabListInputs = { + orientation: signal('horizontal'), + wrap: signal(true), + textDirection: signal('ltr'), + selectionMode: signal('follow'), + focusMode: signal('roving'), + disabled: signal(false), + activeIndex: signal(0), + skipDisabled: signal(true), + items: signal([]), + value: signal(['tab-1']), + }; + tabListPattern = new TabListPattern(tabListInputs); + + // Initiate a list of TabPatterns. + tabInputs = [ + { + tablist: signal(tabListPattern), + tabpanel: signal(undefined), + id: signal('tab-1-id'), + element: signal(createTabElement()), + disabled: signal(false), + value: signal('tab-1'), + }, + { + tablist: signal(tabListPattern), + tabpanel: signal(undefined), + id: signal('tab-2-id'), + element: signal(createTabElement()), + disabled: signal(false), + value: signal('tab-2'), + }, + { + tablist: signal(tabListPattern), + tabpanel: signal(undefined), + id: signal('tab-3-id'), + element: signal(createTabElement()), + disabled: signal(false), + value: signal('tab-3'), + }, + ]; + tabPatterns = [ + new TabPattern(tabInputs[0]), + new TabPattern(tabInputs[1]), + new TabPattern(tabInputs[2]), + ]; + + // Initiate a list of TabPanelPatterns. + tabPanelInputs = [ + { + id: signal('tabpanel-1-id'), + tab: signal(undefined), + value: signal('tab-1'), + }, + { + id: signal('tabpanel-2-id'), + tab: signal(undefined), + value: signal('tab-2'), + }, + { + id: signal('tabpanel-3-id'), + tab: signal(undefined), + value: signal('tab-3'), + }, + ]; + tabPanelPatterns = [ + new TabPanelPattern(tabPanelInputs[0]), + new TabPanelPattern(tabPanelInputs[1]), + new TabPanelPattern(tabPanelInputs[2]), + ]; + + // Binding between tabs and tabpanels. + tabInputs[0].tabpanel.set(tabPanelPatterns[0]); + tabInputs[1].tabpanel.set(tabPanelPatterns[1]); + tabInputs[2].tabpanel.set(tabPanelPatterns[2]); + tabPanelInputs[0].tab.set(tabPatterns[0]); + tabPanelInputs[1].tab.set(tabPatterns[1]); + tabPanelInputs[2].tab.set(tabPatterns[2]); + tabListInputs.items.set(tabPatterns); + }); + + it('sets the selected tab by setting `value`.', () => { + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListInputs.value.set(['tab-2']); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('sets a tabpanel to be not hidden if a tab is selected.', () => { + tabListInputs.value.set(['tab-1']); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPanelPatterns[0].hidden()).toBeFalse(); + }); + + it('sets a tabpanel to be hidden if a tab is not selected.', () => { + tabListInputs.value.set(['tab-1']); + expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPanelPatterns[1].hidden()).toBeTrue(); + }); + + it('gets a controlled tabpanel id from a tab.', () => { + expect(tabPanelPatterns[0].id()).toBe('tabpanel-1-id'); + expect(tabPatterns[0].controls()).toBe('tabpanel-1-id'); + expect(tabPanelPatterns[1].id()).toBe('tabpanel-2-id'); + expect(tabPatterns[1].controls()).toBe('tabpanel-2-id'); + expect(tabPanelPatterns[2].id()).toBe('tabpanel-3-id'); + expect(tabPatterns[2].controls()).toBe('tabpanel-3-id'); + }); + + describe('Keyboard Navigation', () => { + it('does not handle keyboard event if a tablist is disabled.', () => { + expect(tabPatterns[1].active()).toBeFalse(); + tabListInputs.disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[1].active()).toBeFalse(); + }); + + it('skips the disabled tab when `skipDisabled` is set to true.', () => { + tabInputs[1].disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeFalse(); + expect(tabPatterns[1].active()).toBeFalse(); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('does not skip the disabled tab when `skipDisabled` is set to false.', () => { + tabListInputs.skipDisabled.set(false); + tabInputs[1].disabled.set(true); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeFalse(); + expect(tabPatterns[1].active()).toBeTrue(); + expect(tabPatterns[2].active()).toBeFalse(); + }); + + it('selects a tab by focus if `selectionMode` is "follow".', () => { + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('selects a tab by enter key if `selectionMode` is "explicit".', () => { + tabListInputs.selectionMode.set('explicit'); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(enter()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('selects a tab by space key if `selectionMode` is "explicit".', () => { + tabListInputs.selectionMode.set('explicit'); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[1].selected()).toBeFalse(); + tabListPattern.onKeydown(space()); + expect(tabPatterns[0].selected()).toBeFalse(); + expect(tabPatterns[1].selected()).toBeTrue(); + }); + + it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses right key to navigate to the next tab when `orientation` is set to "horizontal".', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('uses up key to navigate to the previous tab when `orientation` is set to "vertical".', () => { + tabListInputs.orientation.set('vertical'); + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(up()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses down key to navigate to the next tab when `orientation` is set to "vertical".', () => { + tabListInputs.orientation.set('vertical'); + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(down()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('uses home key to navigate to the first tab.', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(home()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('uses end key to navigate to the last tab.', () => { + tabListInputs.activeIndex.set(1); + expect(tabPatterns[1].active()).toBeTrue(); + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('moves to the last tab from first tab when navigating to the previous tab if `wrap` is set to true', () => { + expect(tabPatterns[0].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('moves to the first tab from last tab when navigating to the next tab if `wrap` is set to true', () => { + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('stays on the first tab when navigating to the previous tab if `wrap` is set to false', () => { + tabListInputs.wrap.set(false); + expect(tabPatterns[0].active()).toBeTrue(); + tabListPattern.onKeydown(left()); + expect(tabPatterns[0].active()).toBeTrue(); + }); + + it('stays on the last tab when navigating to the next tab if `wrap` is set to false', () => { + tabListInputs.wrap.set(false); + tabListPattern.onKeydown(end()); + expect(tabPatterns[2].active()).toBeTrue(); + tabListPattern.onKeydown(right()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + + it('changes the navigation direction with `rtl` mode.', () => { + tabListInputs.textDirection.set('rtl'); + tabListInputs.activeIndex.set(1); + tabListPattern.onKeydown(left()); + expect(tabPatterns[2].active()).toBeTrue(); + }); + }); +}); diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 2ee7375553a8..e824aee9a831 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, signal} from '@angular/core'; - +import {computed} from '@angular/core'; import {KeyboardEventManager} from '../behaviors/event-manager/keyboard-event-manager'; import {PointerEventManager} from '../behaviors/event-manager/pointer-event-manager'; import {ListFocus, ListFocusInputs, ListFocusItem} from '../behaviors/list-focus/list-focus'; @@ -190,7 +189,7 @@ export class TabListPattern { this.navigation = new ListNavigation({...inputs, focusManager: this.focusManager}); this.selection = new ListSelection({ ...inputs, - multi: signal(false), + multi: () => false, focusManager: this.focusManager, }); } diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html index 2c598324fb3e..13904f352675 100644 --- a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.html @@ -26,6 +26,15 @@ Active Descendant + + + Tab selection + + Tab 1 + Tab 2 + Tab 3 + + @@ -39,6 +48,7 @@ [orientation]="orientation" [focusMode]="focusMode" [selectionMode]="selectionMode" + [(tab)]="tabSelection" >
  • tab 1
  • tab 2
  • diff --git a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts index 055820584393..5179f4b053c6 100644 --- a/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts +++ b/src/components-examples/cdk-experimental/tabs/cdk-tabs/cdk-tabs-example.ts @@ -33,6 +33,7 @@ export class CdkTabsExample { orientation: 'vertical' | 'horizontal' = 'horizontal'; focusMode: 'roving' | 'activedescendant' = 'roving'; selectionMode: 'explicit' | 'follow' = 'follow'; + tabSelection = 'tab-1'; wrap = new FormControl(true, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true});