diff --git a/src/cdk-experimental/tabs/BUILD.bazel b/src/cdk-experimental/tabs/BUILD.bazel index d9993da6e049..96828e6d98be 100644 --- a/src/cdk-experimental/tabs/BUILD.bazel +++ b/src/cdk-experimental/tabs/BUILD.bazel @@ -1,4 +1,4 @@ -load("//tools:defaults.bzl", "ng_project") +load("//tools:defaults.bzl", "ng_project", "ng_web_test_suite", "ts_project") package(default_visibility = ["//visibility:public"]) @@ -16,3 +16,22 @@ ng_project( "//src/cdk/bidi", ], ) + +ts_project( + name = "unit_test_sources", + testonly = True, + srcs = [ + "tabs.spec.ts", + ], + deps = [ + ":tabs", + "//:node_modules/@angular/core", + "//:node_modules/@angular/platform-browser", + "//src/cdk/testing/private", + ], +) + +ng_web_test_suite( + name = "unit_tests", + deps = [":unit_test_sources"], +) diff --git a/src/cdk-experimental/tabs/tabs.spec.ts b/src/cdk-experimental/tabs/tabs.spec.ts new file mode 100644 index 000000000000..957aba2de09e --- /dev/null +++ b/src/cdk-experimental/tabs/tabs.spec.ts @@ -0,0 +1,719 @@ +import {Component, DebugElement, signal} from '@angular/core'; +import {ComponentFixture, TestBed} from '@angular/core/testing'; +import {By} from '@angular/platform-browser'; +import {BidiModule, Direction} from '@angular/cdk/bidi'; +import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; +import {CdkTabs, CdkTabList, CdkTab, CdkTabPanel, CdkTabContent} from './tabs'; + +interface ModifierKeys { + ctrlKey?: boolean; + shiftKey?: boolean; + altKey?: boolean; + metaKey?: boolean; +} + +interface TestTabDefinition { + value: string; + label: string; + content: string; + disabled?: boolean; +} + +describe('CdkTabs', () => { + let fixture: ComponentFixture; + let testComponent: TestTabsComponent; + + let tabsDebugElement: DebugElement; + let tabListDebugElement: DebugElement; + let tabDebugElements: DebugElement[]; + let tabPanelDebugElements: DebugElement[]; + + let tabsElement: HTMLElement; + let tabListElement: HTMLElement; + let tabElements: HTMLElement[]; + let tabPanelElements: HTMLElement[]; + + const keydown = (key: string, modifierKeys: ModifierKeys = {}) => { + tabListElement.dispatchEvent( + new KeyboardEvent('keydown', { + key, + bubbles: true, + ...modifierKeys, + }), + ); + fixture.detectChanges(); + defineTestVariables(); + }; + + const pointerDown = (target: HTMLElement, eventInit?: PointerEventInit) => { + target.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, ...eventInit})); + fixture.detectChanges(); + defineTestVariables(); + }; + + const space = (modifierKeys?: ModifierKeys) => keydown(' ', modifierKeys); + const enter = (modifierKeys?: ModifierKeys) => keydown('Enter', modifierKeys); + const up = (modifierKeys?: ModifierKeys) => keydown('ArrowUp', modifierKeys); + const down = (modifierKeys?: ModifierKeys) => keydown('ArrowDown', modifierKeys); + const left = (modifierKeys?: ModifierKeys) => keydown('ArrowLeft', modifierKeys); + const right = (modifierKeys?: ModifierKeys) => keydown('ArrowRight', modifierKeys); + const home = (modifierKeys?: ModifierKeys) => keydown('Home', modifierKeys); + const end = (modifierKeys?: ModifierKeys) => keydown('End', modifierKeys); + + function setupTestTabs(options: {textDirection?: Direction} = {}) { + TestBed.configureTestingModule({ + providers: [provideFakeDirectionality(options.textDirection ?? 'ltr')], + imports: [BidiModule, TestTabsComponent], + }); + + fixture = TestBed.createComponent(TestTabsComponent); + testComponent = fixture.componentInstance; + + fixture.detectChanges(); + defineTestVariables(); + } + + function updateTabs( + options: { + initialTabs?: TestTabDefinition[]; + selectedTab?: string | undefined; + orientation?: 'horizontal' | 'vertical'; + disabled?: boolean; + wrap?: boolean; + skipDisabled?: boolean; + focusMode?: 'roving' | 'activedescendant'; + selectionMode?: 'follow' | 'explicit'; + } = {}, + ) { + if (options.initialTabs !== undefined) testComponent.tabsData.set(options.initialTabs); + if (options.selectedTab !== undefined) testComponent.selectedTab.set(options.selectedTab); + if (options.orientation !== undefined) testComponent.orientation.set(options.orientation); + if (options.disabled !== undefined) testComponent.disabled.set(options.disabled); + if (options.wrap !== undefined) testComponent.wrap.set(options.wrap); + if (options.skipDisabled !== undefined) testComponent.skipDisabled.set(options.skipDisabled); + if (options.focusMode !== undefined) testComponent.focusMode.set(options.focusMode); + if (options.selectionMode !== undefined) testComponent.selectionMode.set(options.selectionMode); + + fixture.detectChanges(); + defineTestVariables(); + } + + function defineTestVariables() { + tabsDebugElement = fixture.debugElement.query(By.directive(CdkTabs)); + tabListDebugElement = fixture.debugElement.query(By.directive(CdkTabList)); + tabDebugElements = fixture.debugElement.queryAll(By.directive(CdkTab)); + tabPanelDebugElements = fixture.debugElement.queryAll(By.directive(CdkTabPanel)); + + tabsElement = tabsDebugElement.nativeElement; + tabListElement = tabListDebugElement.nativeElement; + tabElements = tabDebugElements.map(debugEl => debugEl.nativeElement); + tabPanelElements = tabPanelDebugElements.map(debugEl => debugEl.nativeElement); + } + + function isTabFocused(index: number): boolean { + if (testComponent.focusMode() === 'roving') { + return tabElements[index]?.getAttribute('tabindex') === '0'; + } else { + return tabListElement?.getAttribute('aria-activedescendant') === tabElements[index]?.id; + } + } + + afterEach(async () => { + if (tabsElement) { + await runAccessibilityChecks(tabsElement); + } + }); + + describe('ARIA attributes and roles', () => { + beforeEach(() => { + setupTestTabs(); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + }); + }); + + describe('CdkTabList', () => { + it('should have role="tablist"', () => { + expect(tabListElement.getAttribute('role')).toBe('tablist'); + }); + + it('should set aria-orientation based on input', () => { + expect(tabListElement.getAttribute('aria-orientation')).toBe('horizontal'); + updateTabs({orientation: 'vertical'}); + expect(tabListElement.getAttribute('aria-orientation')).toBe('vertical'); + }); + + it('should set aria-disabled based on input', () => { + expect(tabListElement.getAttribute('aria-disabled')).toBe('false'); + updateTabs({disabled: true}); + expect(tabListElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should have tabindex set by focusMode', () => { + updateTabs({focusMode: 'roving'}); + expect(tabListElement.getAttribute('tabindex')).toBe('-1'); + + updateTabs({focusMode: 'activedescendant'}); + expect(tabListElement.getAttribute('tabindex')).toBe('0'); + }); + + it('should set aria-activedescendant in activedescendant mode', () => { + updateTabs({focusMode: 'activedescendant', selectedTab: 'tab1'}); + expect(tabListElement.getAttribute('aria-activedescendant')).toBe(tabElements[0].id); + }); + + it('should not set aria-activedescendant in roving mode', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabListElement.hasAttribute('aria-activedescendant')).toBe(false); + }); + }); + + describe('CdkTab', () => { + it('should have role="tab"', () => { + tabElements.forEach(tabElement => { + expect(tabElement.getAttribute('role')).toBe('tab'); + }); + }); + + it('should have aria-selected based on selection state', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(tabElements[2].getAttribute('aria-selected')).toBe('false'); + + updateTabs({selectedTab: 'tab3'}); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); + }); + + it('should have aria-controls pointing to its panel id', () => { + tabElements.forEach((tabElement, index) => { + expect(tabElement.getAttribute('aria-controls')).toBe(tabPanelElements[index].id); + }); + }); + + it('should have aria-disabled based on input', () => { + expect(tabElements[0].getAttribute('aria-disabled')).toBe('false'); + expect(tabElements[1].getAttribute('aria-disabled')).toBe('true'); + expect(tabElements[2].getAttribute('aria-disabled')).toBe('false'); + }); + + it('should have tabindex set by focusMode and active state', () => { + updateTabs({focusMode: 'roving', selectedTab: 'tab1'}); + expect(tabElements[0].getAttribute('tabindex')).toBe('0'); + expect(tabElements[1].getAttribute('tabindex')).toBe('-1'); + expect(tabElements[2].getAttribute('tabindex')).toBe('-1'); + + updateTabs({focusMode: 'activedescendant'}); + tabElements.forEach(tabElement => { + expect(tabElement.getAttribute('tabindex')).toBe('-1'); + }); + }); + }); + + describe('CdkTabPanel', () => { + it('should have role="tabpanel"', () => { + tabPanelElements.forEach(panelElement => { + expect(panelElement.getAttribute('role')).toBe('tabpanel'); + }); + }); + + it('should have tabindex="0"', () => { + tabPanelElements.forEach(panelElement => { + expect(panelElement.getAttribute('tabindex')).toBe('0'); + }); + }); + + it('should have inert attribute when hidden and not when visible', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabPanelElements[0].hasAttribute('inert')).toBe(false); + expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); + expect(tabPanelElements[2].hasAttribute('inert')).toBe(true); + + updateTabs({selectedTab: 'tab3'}); + expect(tabPanelElements[0].hasAttribute('inert')).toBe(true); + expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); + expect(tabPanelElements[2].hasAttribute('inert')).toBe(false); + }); + }); + }); + + describe('Keyboard navigation', () => { + for (const focusMode of ['roving', 'activedescendant'] as const) { + describe(`focusMode="${focusMode}"`, () => { + describe('LTR', () => { + beforeEach(() => { + setupTestTabs({textDirection: 'ltr'}); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + focusMode, + selectedTab: 'tab1', + }); + }); + + it('should move focus with ArrowRight', () => { + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should move focus with ArrowLeft', () => { + right(); + expect(isTabFocused(2)).toBe(true); + left(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should wrap focus with ArrowRight if wrap is true', () => { + updateTabs({wrap: true}); + right(); + expect(isTabFocused(2)).toBe(true); + right(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should not wrap focus with ArrowRight if wrap is false', () => { + updateTabs({wrap: false}); + right(); + expect(isTabFocused(2)).toBe(true); + right(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should wrap focus with ArrowLeft if wrap is true', () => { + updateTabs({wrap: true}); + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not wrap focus with ArrowLeft if wrap is false', () => { + updateTabs({wrap: false}); + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to first tab with Home', () => { + left(); + expect(isTabFocused(2)).toBe(true); + home(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to last tab with End', () => { + expect(isTabFocused(0)).toBe(true); + end(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should skip disabled tabs if skipDisabled is true', () => { + updateTabs({skipDisabled: true}); + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not skip disabled tabs if skipDisabled is false', () => { + updateTabs({skipDisabled: false}); + tabListElement.focus(); + fixture.detectChanges(); + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(1)).toBe(true); + }); + }); + + describe('RTL', () => { + beforeEach(() => { + setupTestTabs({textDirection: 'rtl'}); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + focusMode, + selectedTab: 'tab1', + }); + }); + it('should move focus with ArrowLeft (effectively next)', () => { + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should move focus with ArrowRight (effectively previous)', () => { + left(); + expect(isTabFocused(2)).toBe(true); + right(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should wrap focus with ArrowLeft if wrap is true', () => { + updateTabs({wrap: true}); + left(); + left(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should not wrap focus with ArrowLeft if wrap is false', () => { + updateTabs({wrap: false}); + left(); + left(); + expect(isTabFocused(2)).toBe(true); + }); + }); + + describe('orientation="vertical"', () => { + beforeEach(() => { + setupTestTabs({textDirection: 'ltr'}); + updateTabs({ + orientation: 'vertical', + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + focusMode, + selectedTab: 'tab1', + }); + }); + + it('should move focus with ArrowDown', () => { + expect(isTabFocused(0)).toBe(true); + down(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should move focus with ArrowUp', () => { + down(); + expect(isTabFocused(2)).toBe(true); + up(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should wrap focus with ArrowDown if wrap is true', () => { + updateTabs({wrap: true}); + down(); + expect(isTabFocused(2)).toBe(true); + down(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should not wrap focus with ArrowDown if wrap is false', () => { + updateTabs({wrap: false}); + down(); + expect(isTabFocused(2)).toBe(true); + down(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should wrap focus with ArrowUp if wrap is true', () => { + updateTabs({wrap: true}); + expect(isTabFocused(0)).toBe(true); + up(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not wrap focus with ArrowUp if wrap is false', () => { + updateTabs({wrap: false}); + expect(isTabFocused(0)).toBe(true); + up(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to first tab with Home', () => { + down(); + expect(isTabFocused(2)).toBe(true); + home(); + expect(isTabFocused(0)).toBe(true); + }); + + it('should move focus to last tab with End', () => { + expect(isTabFocused(0)).toBe(true); + end(); + expect(isTabFocused(2)).toBe(true); + }); + + it('should not move focus with ArrowLeft/ArrowRight', () => { + expect(isTabFocused(0)).toBe(true); + left(); + expect(isTabFocused(0)).toBe(true); + right(); + expect(isTabFocused(0)).toBe(true); + }); + }); + }); + } + }); + + describe('Tab selection', () => { + beforeEach(() => { + setupTestTabs(); + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2'}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab1', + }); + }); + + describe('selectionMode="follow"', () => { + beforeEach(() => { + updateTabs({selectionMode: 'follow'}); + }); + + it('should select tab on focus via ArrowKeys', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + right(); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + + left(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should select tab on focus via Home/End', () => { + updateTabs({selectedTab: 'tab2'}); + expect(testComponent.selectedTab()).toBe('tab2'); + + home(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + end(); + expect(testComponent.selectedTab()).toBe('tab3'); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); + }); + + it('should select tab on click', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('should not change selection with Space/Enter on already selected tab', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + space(); + expect(testComponent.selectedTab()).toBe('tab1'); + enter(); + expect(testComponent.selectedTab()).toBe('tab1'); + }); + }); + + describe('selectionMode="explicit"', () => { + beforeEach(() => { + updateTabs({selectionMode: 'explicit'}); + }); + + it('should not select tab on focus via ArrowKeys', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + right(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(isTabFocused(1)).toBe(true); + + left(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + expect(isTabFocused(0)).toBe(true); + }); + + it('should select focused tab on Space', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + + right(); + expect(isTabFocused(1)).toBe(true); + expect(testComponent.selectedTab()).toBe('tab1'); + + space(); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('should select focused tab on Enter', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + + right(); + expect(isTabFocused(1)).toBe(true); + expect(testComponent.selectedTab()).toBe('tab1'); + + enter(); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + + it('should select tab on click', () => { + updateTabs({selectedTab: 'tab1'}); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + }); + }); + + it('should update selectedTab model on selection change', () => { + updateTabs({selectedTab: 'tab1', selectionMode: 'follow'}); + expect(testComponent.selectedTab()).toBe('tab1'); + + right(); + expect(testComponent.selectedTab()).toBe('tab2'); + + updateTabs({selectionMode: 'explicit'}); + right(); + expect(testComponent.selectedTab()).toBe('tab2'); + enter(); + expect(testComponent.selectedTab()).toBe('tab3'); + + pointerDown(tabElements[0]); + expect(testComponent.selectedTab()).toBe('tab1'); + }); + + it('should update selection when selectedTab model changes', () => { + updateTabs({selectedTab: 'tab1'}); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + + updateTabs({selectedTab: 'tab2'}); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + + updateTabs({selectedTab: 'tab3'}); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should not select a disabled tab via click', () => { + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab1', + }); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should not select a disabled tab via keyboard', () => { + updateTabs({ + initialTabs: [ + {value: 'tab1', label: 'Tab 1', content: 'Content 1'}, + {value: 'tab2', label: 'Tab 2', content: 'Content 2', disabled: true}, + {value: 'tab3', label: 'Tab 3', content: 'Content 3'}, + ], + selectedTab: 'tab1', + selectionMode: 'explicit', + skipDisabled: false, + }); + expect(testComponent.selectedTab()).toBe('tab1'); + right(); + expect(isTabFocused(1)).toBe(true); + enter(); + expect(testComponent.selectedTab()).toBe('tab1'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); + }); + + it('should not change selection if tablist is disabled', () => { + updateTabs({selectedTab: 'tab1', disabled: true}); + expect(testComponent.selectedTab()).toBe('tab1'); + pointerDown(tabElements[1]); + expect(testComponent.selectedTab()).toBe('tab1'); + right(); + expect(testComponent.selectedTab()).toBe('tab1'); + }); + + it('should handle initial selection via input', () => { + updateTabs({selectedTab: 'tab2'}); + expect(testComponent.selectedTab()).toBe('tab2'); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); + expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); + }); + }); +}); + +@Component({ + template: ` +
+ + + @for (tabDef of tabsData(); track tabDef.value) { +
+ {{ tabDef.content }} +
+ } +
+ `, + imports: [CdkTabs, CdkTabList, CdkTab, CdkTabPanel, CdkTabContent], +}) +class TestTabsComponent { + tabsData = signal([ + { + value: 'tab1', + label: 'Tab 1', + content: 'Content 1', + disabled: false, + }, + { + value: 'tab2', + label: 'Tab 2', + content: 'Content 2', + disabled: false, + }, + { + value: 'tab3', + label: 'Tab 3', + content: 'Content 3', + disabled: true, + }, + ]); + + selectedTab = signal(undefined); + orientation = signal<'horizontal' | 'vertical'>('horizontal'); + disabled = signal(false); + wrap = signal(true); + skipDisabled = signal(true); + focusMode = signal<'roving' | 'activedescendant'>('roving'); + selectionMode = signal<'follow' | 'explicit'>('follow'); +} diff --git a/src/cdk-experimental/tabs/tabs.ts b/src/cdk-experimental/tabs/tabs.ts index 5194d655c9fc..cfe77ac0cbc3 100644 --- a/src/cdk-experimental/tabs/tabs.ts +++ b/src/cdk-experimental/tabs/tabs.ts @@ -12,19 +12,33 @@ import {Directionality} from '@angular/cdk/bidi'; import { booleanAttribute, computed, - contentChild, - contentChildren, Directive, - effect, ElementRef, inject, input, model, linkedSignal, + signal, + Signal, + afterRenderEffect, + OnInit, + OnDestroy, } from '@angular/core'; -import {toSignal} from '@angular/core/rxjs-interop'; import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns'; +interface HasElement { + element: Signal; +} + +/** + * Sort directives by their document order. + */ +function sortDirectives(a: HasElement, b: HasElement) { + return (a.element().compareDocumentPosition(b.element()) & Node.DOCUMENT_POSITION_PRECEDING) > 0 + ? 1 + : -1; +} + /** * A Tabs container. * @@ -59,16 +73,40 @@ import {TabListPattern, TabPanelPattern, TabPattern} from '../ui-patterns'; }) export class CdkTabs { /** The CdkTabList nested inside of the container. */ - private readonly _cdkTabList = contentChild(CdkTabList); + private readonly _tablist = signal(undefined); /** The CdkTabPanels nested inside of the container. */ - private readonly _cdkTabPanels = contentChildren(CdkTabPanel); + private readonly _unorderedPanels = signal(new Set()); /** The Tab UIPattern of the child Tabs. */ - tabs = computed(() => this._cdkTabList()?.tabs()); + tabs = computed(() => this._tablist()?.tabs()); /** The TabPanel UIPattern of the child TabPanels. */ - tabpanels = computed(() => this._cdkTabPanels().map(tabpanel => tabpanel.pattern)); + unorderedTabpanels = computed(() => + [...this._unorderedPanels()].map(tabpanel => tabpanel.pattern), + ); + + register(child: CdkTabList | CdkTabPanel) { + if (child instanceof CdkTabList) { + this._tablist.set(child); + } + + if (child instanceof CdkTabPanel) { + this._unorderedPanels().add(child); + this._unorderedPanels.set(new Set(this._unorderedPanels())); + } + } + + deregister(child: CdkTabList | CdkTabPanel) { + if (child instanceof CdkTabList) { + this._tablist.set(undefined); + } + + if (child instanceof CdkTabPanel) { + this._unorderedPanels().delete(child); + this._unorderedPanels.set(new Set(this._unorderedPanels())); + } + } } /** @@ -88,61 +126,89 @@ export class CdkTabs { '[attr.aria-activedescendant]': 'pattern.activedescendant()', '(keydown)': 'pattern.onKeydown($event)', '(pointerdown)': 'pattern.onPointerdown($event)', + '(focusin)': 'onFocus()', }, }) -export class CdkTabList { - /** The directionality (LTR / RTL) context for the application (or a subtree of it). */ - private readonly _directionality = inject(Directionality); +export class CdkTabList implements OnInit, OnDestroy { + /** The parent CdkTabs. */ + private readonly _cdkTabs = inject(CdkTabs); /** The CdkTabs nested inside of the CdkTabList. */ - private readonly _cdkTabs = contentChildren(CdkTab); + private readonly _unorderedTabs = signal(new Set()); /** 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, - }); + /** Text direction. */ + readonly textDirection = inject(Directionality).valueSignal; /** The Tab UIPatterns of the child Tabs. */ - tabs = computed(() => this._cdkTabs().map(tab => tab.pattern)); + readonly tabs = computed(() => + [...this._unorderedTabs()].sort(sortDirectives).map(tab => tab.pattern), + ); /** Whether the tablist is vertically or horizontally oriented. */ - orientation = input<'vertical' | 'horizontal'>('horizontal'); + readonly orientation = input<'vertical' | 'horizontal'>('horizontal'); /** Whether focus should wrap when navigating. */ - wrap = input(true, {transform: booleanAttribute}); + readonly wrap = input(true, {transform: booleanAttribute}); /** Whether disabled items in the list should be skipped when navigating. */ - skipDisabled = input(true, {transform: booleanAttribute}); + readonly skipDisabled = input(true, {transform: booleanAttribute}); /** The focus strategy used by the tablist. */ - focusMode = input<'roving' | 'activedescendant'>('roving'); + readonly focusMode = input<'roving' | 'activedescendant'>('roving'); /** The selection strategy used by the tablist. */ - selectionMode = input<'follow' | 'explicit'>('follow'); + readonly selectionMode = input<'follow' | 'explicit'>('follow'); /** Whether the tablist is disabled. */ - disabled = input(false, {transform: booleanAttribute}); - - /** The current index that has been navigated to. */ - activeIndex = model(0); + readonly disabled = input(false, {transform: booleanAttribute}); - // TODO(ok7sai): Provides a default state when there is no pre-select tab. /** The current selected tab. */ - tab = model(); + readonly tab = model(); /** The TabList UIPattern. */ - pattern: TabListPattern = new TabListPattern({ + readonly pattern: TabListPattern = new TabListPattern({ ...this, items: this.tabs, - textDirection: this.textDirection, value: this._selection, + activeIndex: signal(0), }); + /** Whether the tree has received focus yet. */ + private _hasFocused = signal(false); + constructor() { - effect(() => this.tab.set(this._selection()[0])); + afterRenderEffect(() => this.tab.set(this._selection()[0])); + + afterRenderEffect(() => { + if (!this._hasFocused()) { + this.pattern.setDefaultState(); + } + }); + } + + onFocus() { + this._hasFocused.set(true); + } + + ngOnInit() { + this._cdkTabs.register(this); + } + + ngOnDestroy() { + this._cdkTabs.deregister(this); + } + + register(child: CdkTab) { + this._unorderedTabs().add(child); + this._unorderedTabs.set(new Set(this._unorderedTabs())); + } + + deregister(child: CdkTab) { + this._unorderedTabs().delete(child); + this._unorderedTabs.set(new Set(this._unorderedTabs())); } } @@ -161,7 +227,7 @@ export class CdkTabList { '[attr.aria-controls]': 'pattern.controls()', }, }) -export class CdkTab { +export class CdkTab implements HasElement, OnInit, OnDestroy { /** A reference to the tab element. */ private readonly _elementRef = inject(ElementRef); @@ -174,29 +240,39 @@ export class CdkTab { /** A global unique identifier for the tab. */ private readonly _id = inject(_IdGenerator).getId('cdk-tab-'); + /** The host native element. */ + readonly element = computed(() => this._elementRef.nativeElement); + /** The parent TabList UIPattern. */ - protected tablist = computed(() => this._cdkTabList.pattern); + readonly tablist = computed(() => this._cdkTabList.pattern); /** The TabPanel UIPattern associated with the tab */ - protected tabpanel = computed(() => - this._cdkTabs.tabpanels().find(tabpanel => tabpanel.value() === this.value()), + readonly tabpanel = computed(() => + this._cdkTabs.unorderedTabpanels().find(tabpanel => tabpanel.value() === this.value()), ); /** Whether a tab is disabled. */ - disabled = input(false, {transform: booleanAttribute}); + readonly disabled = input(false, {transform: booleanAttribute}); /** A local unique identifier for the tab. */ - value = input.required(); + readonly value = input.required(); /** The Tab UIPattern. */ - pattern: TabPattern = new TabPattern({ + readonly pattern: TabPattern = new TabPattern({ ...this, id: () => this._id, - element: () => this._elementRef.nativeElement, tablist: this.tablist, tabpanel: this.tabpanel, value: this.value, }); + + ngOnInit() { + this._cdkTabList.register(this); + } + + ngOnDestroy() { + this._cdkTabList.deregister(this); + } } /** @@ -224,7 +300,7 @@ export class CdkTab { }, ], }) -export class CdkTabPanel { +export class CdkTabPanel implements OnInit, OnDestroy { /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware); @@ -235,20 +311,28 @@ export class CdkTabPanel { private readonly _id = inject(_IdGenerator).getId('cdk-tabpanel-'); /** The Tab UIPattern associated with the tabpanel */ - protected tab = computed(() => this._cdkTabs.tabs()?.find(tab => tab.value() === this.value())); + readonly tab = computed(() => this._cdkTabs.tabs()?.find(tab => tab.value() === this.value())); /** A local unique identifier for the tabpanel. */ - value = input.required(); + readonly value = input.required(); /** The TabPanel UIPattern. */ - pattern: TabPanelPattern = new TabPanelPattern({ + readonly pattern: TabPanelPattern = new TabPanelPattern({ ...this, id: () => this._id, tab: this.tab, }); constructor() { - effect(() => this._deferredContentAware.contentVisible.set(!this.pattern.hidden())); + afterRenderEffect(() => this._deferredContentAware.contentVisible.set(!this.pattern.hidden())); + } + + ngOnInit() { + this._cdkTabs.register(this); + } + + ngOnDestroy() { + this._cdkTabs.deregister(this); } } diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts index 8840626708dd..4e3010b49680 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.spec.ts @@ -165,6 +165,44 @@ describe('Tabs Pattern', () => { expect(tabPatterns[2].controls()).toBe('tabpanel-3-id'); }); + describe('#setDefaultState', () => { + it('should not set activeIndex if there are no tabs', () => { + tabListInputs.items.set([]); + tabListInputs.activeIndex.set(10); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(10); + }); + + it('should not set activeIndex if no tabs are focusable', () => { + tabInputs.forEach(input => input.disabled.set(true)); + tabListInputs.activeIndex.set(10); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(10); + }); + + it('should set activeIndex to the first focusable tab if no tabs are selected', () => { + tabListInputs.activeIndex.set(2); + tabListInputs.value.set([]); + tabInputs[0].disabled.set(true); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(1); + }); + + it('should set activeIndex to the first focusable and selected tab', () => { + tabListInputs.activeIndex.set(0); + tabListInputs.value.set([tabPatterns[2].value()]); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(2); + }); + + it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { + tabListInputs.value.set([tabPatterns[1].value()]); + tabInputs[1].disabled.set(true); + tabListPattern.setDefaultState(); + expect(tabListInputs.activeIndex()).toBe(0); + }); + }); + describe('Keyboard Navigation', () => { it('does not handle keyboard event if a tablist is disabled.', () => { expect(tabPatterns[1].active()).toBeFalse(); diff --git a/src/cdk-experimental/ui-patterns/tabs/tabs.ts b/src/cdk-experimental/ui-patterns/tabs/tabs.ts index 9342ba6aa01e..244b15cf5e82 100644 --- a/src/cdk-experimental/ui-patterns/tabs/tabs.ts +++ b/src/cdk-experimental/ui-patterns/tabs/tabs.ts @@ -43,55 +43,55 @@ export interface TabInputs /** A tab in a tablist. */ export class TabPattern { + /** Controls expansion for this tab. */ + readonly expansion: ExpansionControl; + /** A global unique identifier for the tab. */ - id: SignalLike; + readonly id: SignalLike; /** A local unique identifier for the tab. */ - value: SignalLike; + readonly value: SignalLike; /** Whether the tab is disabled. */ - disabled: SignalLike; + readonly disabled: SignalLike; /** The html element that should receive focus. */ - element: SignalLike; + readonly element: SignalLike; /** Whether this tab has expandable content. */ - expandable: SignalLike; + readonly expandable = computed(() => this.expansion.expandable()); /** The unique identifier used by the expansion behavior. */ - expansionId: SignalLike; + readonly expansionId = computed(() => this.expansion.expansionId()); /** Whether the tab is expanded. */ - expanded: SignalLike; + readonly expanded = computed(() => this.expansion.isExpanded()); /** Whether the tab is active. */ - active = computed(() => this.inputs.tablist().focusManager.activeItem() === this); + readonly active = computed(() => this.inputs.tablist().focusManager.activeItem() === this); /** Whether the tab is selected. */ - selected = computed( + readonly selected = computed( () => !!this.inputs.tablist().selection.inputs.value().includes(this.value()), ); /** The tabindex of the tab. */ - tabindex = computed(() => this.inputs.tablist().focusManager.getItemTabindex(this)); + readonly tabindex = computed(() => this.inputs.tablist().focusManager.getItemTabindex(this)); /** The id of the tabpanel associated with the tab. */ - controls = computed(() => this.inputs.tabpanel()?.id()); + readonly controls = computed(() => this.inputs.tabpanel()?.id()); constructor(readonly inputs: TabInputs) { this.id = inputs.id; this.value = inputs.value; this.disabled = inputs.disabled; this.element = inputs.element; - const expansionControl = new ExpansionControl({ + this.expansion = new ExpansionControl({ ...inputs, expansionId: inputs.value, expandable: () => true, expansionManager: inputs.tablist().expansionManager, }); - this.expansionId = expansionControl.expansionId; - this.expandable = expansionControl.isExpandable; - this.expanded = expansionControl.isExpanded; } } @@ -105,13 +105,13 @@ export interface TabPanelInputs { /** A tabpanel associated with a tab. */ export class TabPanelPattern { /** A global unique identifier for the tabpanel. */ - id: SignalLike; + readonly id: SignalLike; /** A local unique identifier for the tabpanel. */ - value: SignalLike; + readonly value: SignalLike; /** Whether the tabpanel is hidden. */ - hidden = computed(() => this.inputs.tab()?.expanded() === false); + readonly hidden = computed(() => this.inputs.tab()?.expanded() === false); constructor(readonly inputs: TabPanelInputs) { this.id = inputs.id; @@ -133,34 +133,34 @@ export type TabListInputs = ListNavigationInputs & /** Controls the state of a tablist. */ export class TabListPattern { /** Controls navigation for the tablist. */ - navigation: ListNavigation; + readonly navigation: ListNavigation; /** Controls selection for the tablist. */ - selection: ListSelection; + readonly selection: ListSelection; /** Controls focus for the tablist. */ - focusManager: ListFocus; + readonly focusManager: ListFocus; /** Controls expansion for the tablist. */ - expansionManager: ListExpansion; + readonly expansionManager: ListExpansion; /** Whether the tablist is vertically or horizontally oriented. */ - orientation: SignalLike<'vertical' | 'horizontal'>; + readonly orientation: SignalLike<'vertical' | 'horizontal'>; /** Whether the tablist is disabled. */ - disabled: SignalLike; + readonly disabled: SignalLike; /** The tabindex of the tablist. */ - tabindex = computed(() => this.focusManager.getListTabindex()); + readonly tabindex = computed(() => this.focusManager.getListTabindex()); /** The id of the current active tab. */ - activedescendant = computed(() => this.focusManager.getActiveDescendant()); + readonly activedescendant = computed(() => this.focusManager.getActiveDescendant()); /** Whether selection should follow focus. */ - followFocus = computed(() => this.inputs.selectionMode() === 'follow'); + readonly followFocus = computed(() => this.inputs.selectionMode() === 'follow'); /** The key used to navigate to the previous tab in the tablist. */ - prevKey = computed(() => { + readonly prevKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowUp'; } @@ -168,7 +168,7 @@ export class TabListPattern { }); /** The key used to navigate to the next item in the list. */ - nextKey = computed(() => { + readonly nextKey = computed(() => { if (this.inputs.orientation() === 'vertical') { return 'ArrowDown'; } @@ -176,7 +176,7 @@ export class TabListPattern { }); /** The keydown event manager for the tablist. */ - keydown = computed(() => { + readonly keydown = computed(() => { return new KeyboardEventManager() .on(this.prevKey, () => this.prev({select: this.followFocus()})) .on(this.nextKey, () => this.next({select: this.followFocus()})) @@ -187,7 +187,7 @@ export class TabListPattern { }); /** The pointerdown event manager for the tablist. */ - pointerdown = computed(() => { + readonly pointerdown = computed(() => { return new PointerEventManager().on(e => this.goto(e, {select: true})); }); @@ -211,6 +211,34 @@ export class TabListPattern { }); } + /** + * Sets the tablist to its default initial state. + * + * Sets the active index of the tablist to the first focusable selected + * tab if one exists. Otherwise, sets focus to the first focusable tab. + * + * This method should be called once the tablist and its tabs are properly initialized. + */ + setDefaultState() { + let firstItemIndex: number | undefined; + + for (const [index, item] of this.inputs.items().entries()) { + if (!this.focusManager.isFocusable(item)) continue; + + if (firstItemIndex === undefined) { + firstItemIndex = index; + } + + if (item.selected()) { + this.inputs.activeIndex.set(index); + return; + } + } + if (firstItemIndex !== undefined) { + this.inputs.activeIndex.set(firstItemIndex); + } + } + /** Handles keydown events for the tablist. */ onKeydown(event: KeyboardEvent) { if (!this.disabled()) { @@ -261,7 +289,7 @@ export class TabListPattern { /** Handles updating selection for the tablist. */ private _select(opts?: SelectOptions) { - if (opts?.select) { + if (opts?.select && !this.focusManager.activeItem().disabled()) { this.selection.selectOne(); this.expansionManager.open(this.focusManager.activeItem()); }