From f237ad8a82f96875a73162a564aa3f810c3eef04 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Wed, 25 Feb 2026 20:10:55 -0500 Subject: [PATCH 1/2] refactor(aria/tabs): simplify code by using template references instead of user id to link tabs and panels --- goldens/aria/private/index.api.md | 16 +- goldens/aria/tabs/index.api.md | 56 ++--- src/aria/private/tabs/tabs.spec.ts | 32 +-- src/aria/private/tabs/tabs.ts | 89 ++++--- src/aria/tabs/public-api.ts | 1 - src/aria/tabs/tab-list.ts | 117 ++++------ src/aria/tabs/tab-panel.ts | 40 +--- src/aria/tabs/tab-tokens.ts | 13 ++ src/aria/tabs/tab.ts | 52 ++--- src/aria/tabs/tabs.spec.ts | 218 +++++++++--------- src/aria/tabs/tabs.ts | 93 -------- src/aria/tabs/utils.ts | 26 --- .../tabs-active-descendant-example.html | 24 +- .../tabs-active-descendant-example.ts | 4 +- .../tabs-disabled-focusable-example.html | 24 +- .../tabs-disabled-focusable-example.ts | 4 +- .../tabs-disabled-skipped-example.html | 24 +- .../tabs-disabled-skipped-example.ts | 4 +- .../tabs/disabled/tabs-disabled-example.html | 24 +- .../tabs/disabled/tabs-disabled-example.ts | 4 +- .../tabs-explicit-selection-example.html | 24 +- .../tabs-explicit-selection-example.ts | 4 +- .../aria/tabs/rtl/tabs-rtl-example.html | 24 +- .../aria/tabs/rtl/tabs-rtl-example.ts | 4 +- .../tabs-selection-follows-focus-example.html | 24 +- .../tabs-selection-follows-focus-example.ts | 4 +- .../tabs-configurable-example.html | 36 +-- .../tabs-configurable-example.ts | 5 +- .../tabs-vertical-example.html | 24 +- .../tabs-vertical-example.ts | 4 +- 30 files changed, 393 insertions(+), 625 deletions(-) create mode 100644 src/aria/tabs/tab-tokens.ts delete mode 100644 src/aria/tabs/tabs.ts delete mode 100644 src/aria/tabs/utils.ts diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index 669af92cc7ef..cbb959738185 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -664,14 +664,14 @@ export function signal(initialValue: T): WritableSignalLike; export type SignalLike = () => T; // @public -export interface TabInputs extends Omit, Omit { - tablist: SignalLike; - tabpanel: SignalLike; - value: SignalLike; +export interface TabInputs extends Omit, Omit { + tabList: SignalLike; + tabPanel: SignalLike; } // @public export interface TabListInputs extends Omit, 'multi'>, Omit { + selectedTab: WritableSignalLike; selectionMode: SignalLike<'follow' | 'explicit'>; } @@ -691,7 +691,6 @@ export class TabListPattern { readonly nextKey: SignalLike<"ArrowRight" | "ArrowLeft" | "ArrowDown">; onKeydown(event: KeyboardEvent): void; onPointerdown(event: PointerEvent): void; - open(value: string): boolean; open(tab?: TabPattern): boolean; readonly orientation: SignalLike<'vertical' | 'horizontal'>; readonly pointerdown: SignalLike>; @@ -704,8 +703,7 @@ export class TabListPattern { // @public export interface TabPanelInputs extends LabelControlOptionalInputs { id: SignalLike; - tab: SignalLike; - value: SignalLike; + readonly tab: SignalLike; } // @public @@ -718,7 +716,6 @@ export class TabPanelPattern { readonly labelledBy: SignalLike; readonly labelManager: LabelControl; readonly tabIndex: SignalLike<-1 | 0>; - readonly value: SignalLike; } // @public @@ -729,15 +726,14 @@ export class TabPattern { readonly disabled: SignalLike; readonly element: SignalLike; readonly expandable: SignalLike; + // (undocumented) readonly expanded: WritableSignalLike; readonly id: SignalLike; - readonly index: SignalLike; // (undocumented) readonly inputs: TabInputs; open(): boolean; readonly selected: SignalLike; readonly tabIndex: SignalLike<0 | -1>; - readonly value: SignalLike; } // @public diff --git a/goldens/aria/tabs/index.api.md b/goldens/aria/tabs/index.api.md index 1cdaf2286e95..01de4e37dcd7 100644 --- a/goldens/aria/tabs/index.api.md +++ b/goldens/aria/tabs/index.api.md @@ -4,27 +4,26 @@ ```ts +import { AfterViewInit } from '@angular/core'; import * as _angular_cdk_bidi from '@angular/cdk/bidi'; import * as _angular_core from '@angular/core'; import { OnDestroy } from '@angular/core'; -import { OnInit } from '@angular/core'; +import { WritableSignal } from '@angular/core'; // @public -export class Tab implements HasElement, OnInit, OnDestroy { +export class Tab implements AfterViewInit { readonly active: _angular_core.Signal; readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly id: _angular_core.InputSignal; // (undocumented) - ngOnDestroy(): void; - // (undocumented) - ngOnInit(): void; + ngAfterViewInit(): void; open(): void; + readonly panel: _angular_core.InputSignal; readonly _pattern: TabPattern; readonly selected: _angular_core.Signal; - readonly value: _angular_core.InputSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -38,69 +37,42 @@ export class TabContent { } // @public -export class TabList implements OnInit, OnDestroy { +export class TabList implements AfterViewInit { constructor(); readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; // (undocumented) - ngOnDestroy(): void; - // (undocumented) - ngOnInit(): void; - // (undocumented) - _onFocus(): void; - open(value: string): boolean; + ngAfterViewInit(): void; readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">; readonly _pattern: TabListPattern; - // (undocumented) - _register(child: Tab): void; - readonly selectedTab: _angular_core.ModelSignal; + readonly selectedTabIndex: _angular_core.ModelSignal; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; readonly _tabPatterns: _angular_core.Signal; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; - // (undocumented) - _unregister(child: Tab): void; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } // @public -export class TabPanel implements OnInit, OnDestroy { +export class TabPanel { constructor(); readonly element: HTMLElement; readonly id: _angular_core.InputSignal; - // (undocumented) - ngOnDestroy(): void; - // (undocumented) - ngOnInit(): void; readonly _pattern: TabPanelPattern; - readonly value: _angular_core.InputSignal; + // (undocumented) + readonly _tabPattern: WritableSignal; readonly visible: _angular_core.Signal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } -// @public -export class Tabs { - readonly element: HTMLElement; - // (undocumented) - _register(child: TabList | TabPanel): void; - readonly _tabPatterns: _angular_core.Signal; - readonly _unorderedTabpanelPatterns: _angular_core.Signal; - // (undocumented) - _unregister(child: TabList | TabPanel): void; - // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; - // (undocumented) - static ɵfac: _angular_core.ɵɵFactoryDeclaration; -} - // (No @packageDocumentation comment for this package) ``` diff --git a/src/aria/private/tabs/tabs.spec.ts b/src/aria/private/tabs/tabs.spec.ts index 71129712e423..a19416e85e96 100644 --- a/src/aria/private/tabs/tabs.spec.ts +++ b/src/aria/private/tabs/tabs.spec.ts @@ -65,37 +65,32 @@ describe('Tabs Pattern', () => { softDisabled: signal(true), items: signal([]), element: signal(document.createElement('div')), + selectedTab: signal(undefined), }; tabListPattern = new TabListPattern(tabListInputs); // Initiate a list of TabPatterns. tabInputs = [ { - tablist: signal(tabListPattern), - tabpanel: signal(undefined), + tabList: signal(tabListPattern), + tabPanel: signal(undefined), id: signal('tab-1-id'), element: signal(createTabElement()), disabled: signal(false), - value: signal('tab-1'), - expanded: signal(false), }, { - tablist: signal(tabListPattern), - tabpanel: signal(undefined), + tabList: signal(tabListPattern), + tabPanel: signal(undefined), id: signal('tab-2-id'), element: signal(createTabElement()), disabled: signal(false), - value: signal('tab-2'), - expanded: signal(false), }, { - tablist: signal(tabListPattern), - tabpanel: signal(undefined), + tabList: signal(tabListPattern), + tabPanel: signal(undefined), id: signal('tab-3-id'), element: signal(createTabElement()), disabled: signal(false), - value: signal('tab-3'), - expanded: signal(false), }, ]; tabPatterns = [ @@ -109,17 +104,14 @@ describe('Tabs Pattern', () => { { 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 = [ @@ -129,9 +121,9 @@ describe('Tabs Pattern', () => { ]; // Binding between tabs and tabpanels. - tabInputs[0].tabpanel.set(tabPanelPatterns[0]); - tabInputs[1].tabpanel.set(tabPanelPatterns[1]); - tabInputs[2].tabpanel.set(tabPanelPatterns[2]); + 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]); @@ -143,8 +135,8 @@ describe('Tabs Pattern', () => { describe('#open', () => { it('should open a tab with value', () => { expect(tabListPattern.selectedTab()).toBeUndefined(); - tabListPattern.open('tab-1'); - expect(tabListPattern.selectedTab()!.value()).toBe('tab-1'); + tabListPattern.open(tabPatterns[0]); + expect(tabListPattern.selectedTab()!).toBe(tabPatterns[0]); }); it('should open a tab with tab pattern instance', () => { diff --git a/src/aria/private/tabs/tabs.ts b/src/aria/private/tabs/tabs.ts index 044b3f68370a..ba7703d86b85 100644 --- a/src/aria/private/tabs/tabs.ts +++ b/src/aria/private/tabs/tabs.ts @@ -7,47 +7,38 @@ */ import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; -import {ExpansionItem, ListExpansionInputs, ListExpansion} from '../behaviors/expansion/expansion'; +import {ExpansionItem, ListExpansion, ListExpansionInputs} from '../behaviors/expansion/expansion'; import { SignalLike, - computed, - signal, WritableSignalLike, + computed, + linkedSignal, } from '../behaviors/signal-like/signal-like'; import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label'; import {ListFocus} from '../behaviors/list-focus/list-focus'; import { - ListNavigationItem, ListNavigation, ListNavigationInputs, + ListNavigationItem, } from '../behaviors/list-navigation/list-navigation'; /** The required inputs to tabs. */ export interface TabInputs - extends Omit, Omit { + extends Omit, Omit { /** The parent tablist that controls the tab. */ - tablist: SignalLike; + tabList: SignalLike; /** The remote tabpanel controlled by the tab. */ - tabpanel: SignalLike; - - /** The remote tabpanel unique identifier. */ - value: SignalLike; + tabPanel: SignalLike; } /** A tab in a tablist. */ export class TabPattern { /** A global unique identifier for the tab. */ - readonly id: SignalLike = () => this.inputs.id(); - - /** The index of the tab. */ - readonly index = computed(() => this.inputs.tablist().inputs.items().indexOf(this)); - - /** The remote tabpanel unique identifier. */ - readonly value: SignalLike = () => this.inputs.value(); + readonly id: SignalLike; // set from inputs /** Whether the tab is disabled. */ - readonly disabled: SignalLike = () => this.inputs.disabled(); + readonly disabled: SignalLike; // set from inputs /** The html element that should receive focus. */ readonly element: SignalLike = () => this.inputs.element()!; @@ -55,28 +46,36 @@ export class TabPattern { /** Whether this tab has expandable panel. */ readonly expandable: SignalLike = () => true; - /** Whether the tab panel is expanded. */ - readonly expanded: WritableSignalLike; + /* + * Whether the tab panel is expanded. + * Primarily controlled by the behavior, which will read/write this value. + * The consumer of this pattern will instead only use the selectedTab input. + * The pattern will be responsible for synchronizing their state. + */ + readonly expanded: WritableSignalLike = linkedSignal( + () => this.inputs.tabList().selectedTab() === this, + ); /** Whether the tab is active. */ - readonly active = computed(() => this.inputs.tablist().inputs.activeItem() === this); + readonly active = computed(() => this.inputs.tabList().inputs.activeItem() === this); /** Whether the tab is selected. */ - readonly selected = computed(() => this.inputs.tablist().selectedTab() === this); + readonly selected = computed(() => this.inputs.tabList().selectedTab() === this); /** The tab index of the tab. */ - readonly tabIndex = computed(() => this.inputs.tablist().focusBehavior.getItemTabIndex(this)); + readonly tabIndex = computed(() => this.inputs.tabList().focusBehavior.getItemTabIndex(this)); /** The id of the tabpanel associated with the tab. */ - readonly controls = computed(() => this.inputs.tabpanel()?.id()); + readonly controls = computed(() => this.inputs.tabPanel()?.id()); constructor(readonly inputs: TabInputs) { - this.expanded = inputs.expanded; + this.id = inputs.id; + this.disabled = inputs.disabled; } /** Opens the tab. */ open(): boolean { - return this.inputs.tablist().open(this); + return this.inputs.tabList().open(this); } } @@ -86,19 +85,13 @@ export interface TabPanelInputs extends LabelControlOptionalInputs { id: SignalLike; /** The tab that controls this tabpanel. */ - tab: SignalLike; - - /** A local unique identifier for the tabpanel. */ - value: SignalLike; + readonly tab: SignalLike; } /** A tabpanel associated with a tab. */ export class TabPanelPattern { /** A global unique identifier for the tabpanel. */ - readonly id: SignalLike = () => this.inputs.id(); - - /** A local unique identifier for the tabpanel. */ - readonly value: SignalLike = () => this.inputs.value(); + readonly id: SignalLike; // set from inputs /** Controls label for this tabpanel. */ readonly labelManager: LabelControl; @@ -117,6 +110,8 @@ export class TabPanelPattern { ); constructor(readonly inputs: TabPanelInputs) { + this.id = inputs.id; + this.labelManager = new LabelControl({ ...inputs, defaultLabelledBy: computed(() => (this.inputs.tab() ? [this.inputs.tab()!.id()] : [])), @@ -131,6 +126,9 @@ export interface TabListInputs Omit { /** The selection strategy used by the tablist. */ selectionMode: SignalLike<'follow' | 'explicit'>; + + /** The currently selected tab. */ + selectedTab: WritableSignalLike; } /** Controls the state of a tablist. */ @@ -145,16 +143,16 @@ export class TabListPattern { readonly expansionBehavior: ListExpansion; /** The currently active tab. */ - readonly activeTab: SignalLike = () => this.inputs.activeItem(); + readonly activeTab: SignalLike; // set from inputs /** The currently selected tab. */ - readonly selectedTab: WritableSignalLike = signal(undefined); + readonly selectedTab: WritableSignalLike; // set from inputs /** Whether the tablist is vertically or horizontally oriented. */ - readonly orientation: SignalLike<'vertical' | 'horizontal'> = () => this.inputs.orientation(); + readonly orientation: SignalLike<'vertical' | 'horizontal'>; // set from inputs /** Whether the tablist is disabled. */ - readonly disabled: SignalLike = () => this.inputs.disabled(); + readonly disabled: SignalLike; // set from inputs /** The tab index of the tablist. */ readonly tabIndex = computed(() => this.focusBehavior.getListTabIndex()); @@ -208,6 +206,11 @@ export class TabListPattern { }); constructor(readonly inputs: TabListInputs) { + this.selectedTab = inputs.selectedTab; + this.activeTab = inputs.activeItem; + this.orientation = inputs.orientation; + this.disabled = inputs.disabled; + this.focusBehavior = new ListFocus(inputs); this.navigationBehavior = new ListNavigation({ @@ -263,19 +266,11 @@ export class TabListPattern { } } - /** Opens the tab by given value. */ - open(value: string): boolean; - /** Opens the given tab or the current active tab. */ open(tab?: TabPattern): boolean; - - open(tab: TabPattern | string | undefined): boolean { + open(tab: TabPattern | undefined): boolean { tab ??= this.activeTab(); - if (typeof tab === 'string') { - tab = this.inputs.items().find(t => t.value() === tab); - } - if (tab === undefined) return false; const success = this.expansionBehavior.open(tab); diff --git a/src/aria/tabs/public-api.ts b/src/aria/tabs/public-api.ts index 301245ea153e..b25592d22344 100644 --- a/src/aria/tabs/public-api.ts +++ b/src/aria/tabs/public-api.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -export {Tabs} from './tabs'; export {TabList} from './tab-list'; export {Tab} from './tab'; export {TabPanel} from './tab-panel'; diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 7e9d4b12f413..83c600bc0120 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -8,33 +8,33 @@ import {Directionality} from '@angular/cdk/bidi'; import { - booleanAttribute, - computed, + AfterViewInit, Directive, ElementRef, + afterRenderEffect, + booleanAttribute, + computed, + contentChildren, inject, input, + linkedSignal, model, signal, - afterRenderEffect, - OnInit, - OnDestroy, } from '@angular/core'; import {TabListPattern, TabPattern} from '../private'; -import {sortDirectives, TABS} from './utils'; -import type {Tab} from './tab'; +import {Tab} from './tab'; +import {TAB_LIST} from './tab-tokens'; /** * A TabList container. * - * The `ngTabList` directive controls a list of `ngTab` elements. It manages keyboard - * navigation, selection, and the overall orientation of the tabs. It should be placed - * within an `ngTabs` container. + * The `ngTabList` directive controls a list of `ngTab` elements, linked to their corresponding tab + * panels. It manages keyboard navigation, selection, and the overall orientation of the tabs. * * ```html - *
    - *
  • First Tab
  • - *
  • Second Tab
  • + *
      + *
    • First Tab
    • + *
    • Second Tab
    • *
    * ``` * @@ -53,30 +53,25 @@ import type {Tab} from './tab'; '[attr.aria-activedescendant]': '_pattern.activeDescendant()', '(keydown)': '_pattern.onKeydown($event)', '(pointerdown)': '_pattern.onPointerdown($event)', - '(focusin)': '_onFocus()', }, + providers: [{provide: TAB_LIST, useExisting: TabList}], }) -export class TabList implements OnInit, OnDestroy { +export class TabList implements AfterViewInit { /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); /** A reference to the host element. */ readonly element = this._elementRef.nativeElement as HTMLElement; - /** The parent Tabs. */ - private readonly _tabs = inject(TABS); + /** The tabs nested inside this list. */ + private readonly _tabs = contentChildren(Tab, {descendants: true}); - /** The Tabs nested inside of the TabList. */ - private readonly _unorderedTabs = signal(new Set()); + /** The corresponding patterns for the child tabs. */ + readonly _tabPatterns = computed(() => this._tabs().map(tab => tab._pattern)); /** Text direction. */ readonly textDirection = inject(Directionality).valueSignal; - /** The Tab UIPatterns of the child Tabs. */ - readonly _tabPatterns = computed(() => - [...this._unorderedTabs()].sort(sortDirectives).map(tab => tab._pattern), - ); - /** Whether the tablist is vertically or horizontally oriented. */ readonly orientation = input<'vertical' | 'horizontal'>('horizontal'); @@ -103,72 +98,40 @@ export class TabList implements OnInit, OnDestroy { */ readonly selectionMode = input<'follow' | 'explicit'>('follow'); - /** The current selected tab. */ - readonly selectedTab = model(); - /** Whether the tablist is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); + /** + * The current selected tab index. + * + * Can be used to set the initially selected tab, or to programmatically force a tab + * to be selected. + */ + readonly selectedTabIndex = model(0); + + /** The current selected tab pattern. */ + private readonly _selectedTabPattern = linkedSignal( + () => this._tabPatterns()[this.selectedTabIndex()], + ); + /** The TabList UIPattern. */ readonly _pattern: TabListPattern = new TabListPattern({ ...this, - items: this._tabPatterns, - activeItem: signal(undefined), element: () => this._elementRef.nativeElement, + activeItem: signal(undefined), + items: this._tabPatterns, + selectedTab: this._selectedTabPattern, }); - /** Whether the tree has received focus yet. */ - private _hasFocused = signal(false); - constructor() { afterRenderEffect(() => { - if (!this._hasFocused()) { - this._pattern.setDefaultState(); - } + const tab = this._selectedTabPattern(); + const index = tab && this._tabPatterns().includes(tab) ? this._tabPatterns().indexOf(tab) : 0; + this.selectedTabIndex.set(index); }); - - afterRenderEffect(() => { - const tab = this._pattern.selectedTab(); - if (tab) { - this.selectedTab.set(tab.value()); - } - }); - - afterRenderEffect(() => { - const value = this.selectedTab(); - if (value) { - this._tabPatterns().forEach(tab => tab.expanded.set(false)); - const tab = this._tabPatterns().find(t => t.value() === value); - this._pattern.selectedTab.set(tab); - tab?.expanded.set(true); - } - }); - } - - _onFocus() { - this._hasFocused.set(true); - } - - ngOnInit() { - this._tabs._register(this); - } - - ngOnDestroy() { - this._tabs._unregister(this); - } - - _register(child: Tab) { - this._unorderedTabs().add(child); - this._unorderedTabs.set(new Set(this._unorderedTabs())); - } - - _unregister(child: Tab) { - this._unorderedTabs().delete(child); - this._unorderedTabs.set(new Set(this._unorderedTabs())); } - /** Opens the tab panel with the specified value. */ - open(value: string): boolean { - return this._pattern.open(value); + ngAfterViewInit() { + this._pattern.setDefaultState(); } } diff --git a/src/aria/tabs/tab-panel.ts b/src/aria/tabs/tab-panel.ts index 31f594e7443c..c4a94a892a13 100644 --- a/src/aria/tabs/tab-panel.ts +++ b/src/aria/tabs/tab-panel.ts @@ -8,27 +8,26 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import { - computed, Directive, ElementRef, + WritableSignal, + afterRenderEffect, + computed, inject, input, - afterRenderEffect, - OnInit, - OnDestroy, + signal, } from '@angular/core'; -import {TabPanelPattern, DeferredContentAware} from '../private'; -import {TABS} from './utils'; +import {DeferredContentAware, TabPanelPattern, TabPattern} from '../private'; /** * A TabPanel container for the resources of layered content associated with a tab. * - * The `ngTabPanel` directive holds the content for a specific tab. It is linked to an - * `ngTab` by a matching `value`. If a tab panel is hidden, the `inert` attribute will be + * The `ngTabPanel` directive holds the content for a specific tab. It will be referenced by an + * `ngTab`. If a tab panel is hidden, the `inert` attribute will be * applied to remove it from the accessibility tree. Proper styling is required for visual hiding. * * ```html - *
    + *
    * * * @@ -56,7 +55,7 @@ import {TABS} from './utils'; }, ], }) -export class TabPanel implements OnInit, OnDestroy { +export class TabPanel { /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); @@ -66,23 +65,14 @@ export class TabPanel implements OnInit, OnDestroy { /** The DeferredContentAware host directive. */ private readonly _deferredContentAware = inject(DeferredContentAware); - /** The parent Tabs. */ - private readonly _tabs = inject(TABS); - /** A global unique identifier for the tab. */ readonly id = input(inject(_IdGenerator).getId('ng-tabpanel-', true)); - /** The Tab UIPattern associated with the tabpanel */ - private readonly _tabPattern = computed(() => - this._tabs._tabPatterns()?.find(tab => tab.value() === this.value()), - ); - - /** A local unique identifier for the tabpanel. */ - readonly value = input.required(); - /** Whether the tab panel is visible. */ readonly visible = computed(() => !this._pattern.hidden()); + readonly _tabPattern: WritableSignal = signal(undefined); + /** The TabPanel UIPattern. */ readonly _pattern: TabPanelPattern = new TabPanelPattern({ ...this, @@ -92,12 +82,4 @@ export class TabPanel implements OnInit, OnDestroy { constructor() { afterRenderEffect(() => this._deferredContentAware.contentVisible.set(this.visible())); } - - ngOnInit() { - this._tabs._register(this); - } - - ngOnDestroy() { - this._tabs._unregister(this); - } } diff --git a/src/aria/tabs/tab-tokens.ts b/src/aria/tabs/tab-tokens.ts new file mode 100644 index 000000000000..c346ebb4df5b --- /dev/null +++ b/src/aria/tabs/tab-tokens.ts @@ -0,0 +1,13 @@ +/** + * @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 {InjectionToken} from '@angular/core'; +import type {TabList} from './tab-list'; + +/** Token used to expose the tab list. */ +export const TAB_LIST = new InjectionToken('TAB_LIST'); diff --git a/src/aria/tabs/tab.ts b/src/aria/tabs/tab.ts index fcc6c07a5763..b3518c0329bf 100644 --- a/src/aria/tabs/tab.ts +++ b/src/aria/tabs/tab.ts @@ -8,28 +8,26 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import { - booleanAttribute, - computed, + AfterViewInit, Directive, ElementRef, + booleanAttribute, + computed, inject, input, - signal, - OnInit, - OnDestroy, } from '@angular/core'; import {TabPattern} from '../private'; -import {TabList} from './tab-list'; -import {HasElement, TABS} from './utils'; +import {TabPanel} from './tab-panel'; +import {TAB_LIST} from './tab-tokens'; /** * A selectable tab in a TabList. * * The `ngTab` directive represents an individual tab control within an `ngTabList`. It - * requires a `value` that uniquely identifies it and links it to a corresponding `ngTabPanel`. + * requires a `panel` that references a corresponding `ngTabPanel`. * * ```html - *
  • + *
  • * My Tab Label *
  • * ``` @@ -51,36 +49,25 @@ import {HasElement, TABS} from './utils'; '[attr.aria-controls]': '_pattern.controls()', }, }) -export class Tab implements HasElement, OnInit, OnDestroy { +export class Tab implements AfterViewInit { /** A reference to the host element. */ private readonly _elementRef = inject(ElementRef); /** A reference to the host element. */ readonly element = this._elementRef.nativeElement as HTMLElement; - /** The parent Tabs. */ - private readonly _tabs = inject(TABS); - /** The parent TabList. */ - private readonly _tabList = inject(TabList); + private readonly _tabList = inject(TAB_LIST); /** A unique identifier for the widget. */ readonly id = input(inject(_IdGenerator).getId('ng-tab-', true)); - /** The parent TabList UIPattern. */ - private readonly _tablistPattern = computed(() => this._tabList._pattern); - - /** The TabPanel UIPattern associated with the tab */ - private readonly _tabpanelPattern = computed(() => - this._tabs._unorderedTabpanelPatterns().find(tabpanel => tabpanel.value() === this.value()), - ); + /** The panel associated with this tab. */ + readonly panel = input.required(); /** Whether a tab is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); - /** The remote tabpanel unique identifier. */ - readonly value = input.required(); - /** Whether the tab is active. */ readonly active = computed(() => this._pattern.active()); @@ -90,22 +77,17 @@ export class Tab implements HasElement, OnInit, OnDestroy { /** The Tab UIPattern. */ readonly _pattern: TabPattern = new TabPattern({ ...this, - tablist: this._tablistPattern, - tabpanel: this._tabpanelPattern, - expanded: signal(false), element: () => this.element, + tabList: () => this._tabList._pattern, + tabPanel: computed(() => this.panel()?._pattern), }); + ngAfterViewInit() { + this.panel()._tabPattern.set(this._pattern); + } + /** Opens this tab panel. */ open() { this._pattern.open(); } - - ngOnInit() { - this._tabList._register(this); - } - - ngOnDestroy() { - this._tabList._unregister(this); - } } diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index 39e25840f626..f138575336d9 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -1,9 +1,8 @@ -import {Component, DebugElement, signal, ChangeDetectionStrategy} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DebugElement, model, signal} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Direction} from '@angular/cdk/bidi'; import {provideFakeDirectionality, runAccessibilityChecks} from '@angular/cdk/testing/private'; -import {Tabs} from './tabs'; import {TabList} from './tab-list'; import {Tab} from './tab'; import {TabPanel} from './tab-panel'; @@ -17,7 +16,6 @@ interface ModifierKeys { } interface TestTabDefinition { - value: string; label: string; content: string; disabled?: boolean; @@ -27,12 +25,10 @@ describe('Tabs', () => { 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[]; @@ -79,7 +75,7 @@ describe('Tabs', () => { function updateTabs( options: { initialTabs?: TestTabDefinition[]; - selectedTab?: string | undefined; + selectedTab?: number | undefined; orientation?: 'horizontal' | 'vertical'; disabled?: boolean; wrap?: boolean; @@ -102,12 +98,10 @@ describe('Tabs', () => { } function defineTestVariables() { - tabsDebugElement = fixture.debugElement.query(By.directive(Tabs)); tabListDebugElement = fixture.debugElement.query(By.directive(TabList)); tabDebugElements = fixture.debugElement.queryAll(By.directive(Tab)); tabPanelDebugElements = fixture.debugElement.queryAll(By.directive(TabPanel)); - tabsElement = tabsDebugElement.nativeElement; tabListElement = tabListDebugElement.nativeElement; tabElements = tabDebugElements.map(debugEl => debugEl.nativeElement); tabPanelElements = tabPanelDebugElements.map(debugEl => debugEl.nativeElement); @@ -122,8 +116,8 @@ describe('Tabs', () => { } afterEach(async () => { - if (tabsElement) { - await runAccessibilityChecks(tabsElement); + if (fixture.nativeElement) { + await runAccessibilityChecks(fixture.nativeElement); } }); @@ -132,9 +126,9 @@ describe('Tabs', () => { 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2', disabled: true}, + {label: 'Tab 3', content: 'Content 3'}, ], }); }); @@ -165,12 +159,12 @@ describe('Tabs', () => { }); it('should set aria-activedescendant in activedescendant mode', () => { - updateTabs({focusMode: 'activedescendant', selectedTab: 'tab1'}); + updateTabs({focusMode: 'activedescendant', selectedTab: 0}); expect(tabListElement.getAttribute('aria-activedescendant')).toBe(tabElements[0].id); }); it('should not set aria-activedescendant in roving mode', () => { - updateTabs({selectedTab: 'tab1'}); + updateTabs({selectedTab: 0}); expect(tabListElement.hasAttribute('aria-activedescendant')).toBe(false); }); }); @@ -183,12 +177,12 @@ describe('Tabs', () => { }); it('should have aria-selected based on selection state', () => { - updateTabs({selectedTab: 'tab1'}); + updateTabs({selectedTab: 0}); 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'}); + updateTabs({selectedTab: 2}); expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); @@ -207,7 +201,7 @@ describe('Tabs', () => { }); it('should have tabindex set by focusMode and active state', () => { - updateTabs({focusMode: 'roving', selectedTab: 'tab1'}); + updateTabs({focusMode: 'roving', selectedTab: 0}); expect(tabElements[0].getAttribute('tabindex')).toBe('0'); expect(tabElements[1].getAttribute('tabindex')).toBe('-1'); expect(tabElements[2].getAttribute('tabindex')).toBe('-1'); @@ -227,12 +221,12 @@ describe('Tabs', () => { }); it('should have tabindex="0" when visible.', () => { - updateTabs({selectedTab: 'tab1'}); + updateTabs({selectedTab: 0}); expect(tabPanelElements[0].getAttribute('tabindex')).toBe('0'); }); it('should have tabindex="-1" when hidden.', () => { - updateTabs({selectedTab: 'tab1'}); + updateTabs({selectedTab: 0}); expect(tabPanelElements[1].getAttribute('tabindex')).toBe('-1'); expect(tabPanelElements[2].getAttribute('tabindex')).toBe('-1'); }); @@ -244,12 +238,12 @@ describe('Tabs', () => { }); it('should have inert attribute when hidden and not when visible', () => { - updateTabs({selectedTab: 'tab1'}); + updateTabs({selectedTab: 0}); expect(tabPanelElements[0].hasAttribute('inert')).toBe(false); expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); expect(tabPanelElements[2].hasAttribute('inert')).toBe(true); - updateTabs({selectedTab: 'tab3'}); + updateTabs({selectedTab: 2}); expect(tabPanelElements[0].hasAttribute('inert')).toBe(true); expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); expect(tabPanelElements[2].hasAttribute('inert')).toBe(false); @@ -265,12 +259,12 @@ describe('Tabs', () => { 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2', disabled: true}, + {label: 'Tab 3', content: 'Content 3'}, ], focusMode, - selectedTab: 'tab1', + selectedTab: 0, }); }); @@ -356,12 +350,12 @@ describe('Tabs', () => { 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2', disabled: true}, + {label: 'Tab 3', content: 'Content 3'}, ], focusMode, - selectedTab: 'tab1', + selectedTab: 0, }); }); it('should move focus with ArrowLeft (effectively next)', () => { @@ -401,12 +395,12 @@ describe('Tabs', () => { 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2', disabled: true}, + {label: 'Tab 3', content: 'Content 3'}, ], focusMode, - selectedTab: 'tab1', + selectedTab: 0, }); }); @@ -487,11 +481,11 @@ describe('Tabs', () => { 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2'}, + {label: 'Tab 3', content: 'Content 3'}, ], - selectedTab: 'tab1', + selectedTab: 0, }); }); @@ -501,49 +495,49 @@ describe('Tabs', () => { }); it('should select tab on focus via ArrowKeys', () => { - updateTabs({selectedTab: 'tab1'}); - expect(testComponent.selectedTab()).toBe('tab1'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); left(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); 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'); + updateTabs({selectedTab: 1}); + expect(testComponent.selectedTab()).toBe(1); home(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); end(); - expect(testComponent.selectedTab()).toBe('tab3'); + expect(testComponent.selectedTab()).toBe(2); expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); }); it('should select tab on click', () => { - updateTabs({selectedTab: 'tab1'}); - expect(testComponent.selectedTab()).toBe('tab1'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); pointerDown(tabElements[1]); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); 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'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); space(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); enter(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); }); }); @@ -553,84 +547,84 @@ describe('Tabs', () => { }); it('should not select tab on focus via ArrowKeys', () => { - updateTabs({selectedTab: 'tab1'}); - expect(testComponent.selectedTab()).toBe('tab1'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); 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(testComponent.selectedTab()).toBe(0); 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'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); right(); expect(isTabFocused(1)).toBe(true); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); space(); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); it('should select focused tab on Enter', () => { - updateTabs({selectedTab: 'tab1'}); - expect(testComponent.selectedTab()).toBe('tab1'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); right(); expect(isTabFocused(1)).toBe(true); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); enter(); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); it('should select tab on click', () => { - updateTabs({selectedTab: 'tab1'}); - expect(testComponent.selectedTab()).toBe('tab1'); + updateTabs({selectedTab: 0}); + expect(testComponent.selectedTab()).toBe(0); pointerDown(tabElements[1]); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); 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'); + updateTabs({selectedTab: 0, selectionMode: 'follow'}); + expect(testComponent.selectedTab()).toBe(0); right(); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); updateTabs({selectionMode: 'explicit'}); right(); - expect(testComponent.selectedTab()).toBe('tab2'); + expect(testComponent.selectedTab()).toBe(1); enter(); - expect(testComponent.selectedTab()).toBe('tab3'); + expect(testComponent.selectedTab()).toBe(2); pointerDown(tabElements[0]); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); }); it('should update selection when selectedTab model changes', () => { - updateTabs({selectedTab: 'tab1'}); + updateTabs({selectedTab: 0}); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); - updateTabs({selectedTab: 'tab2'}); + updateTabs({selectedTab: 1}); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); - updateTabs({selectedTab: 'tab3'}); + updateTabs({selectedTab: 2}); expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); }); @@ -638,49 +632,49 @@ describe('Tabs', () => { 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2', disabled: true}, + {label: 'Tab 3', content: 'Content 3'}, ], - selectedTab: 'tab1', + selectedTab: 0, }); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); pointerDown(tabElements[1]); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); 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'}, + {label: 'Tab 1', content: 'Content 1'}, + {label: 'Tab 2', content: 'Content 2', disabled: true}, + {label: 'Tab 3', content: 'Content 3'}, ], - selectedTab: 'tab1', + selectedTab: 0, selectionMode: 'explicit', softDisabled: true, }); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); right(); expect(isTabFocused(1)).toBe(true); enter(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); 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'); + updateTabs({selectedTab: 0, disabled: true}); + expect(testComponent.selectedTab()).toBe(0); pointerDown(tabElements[1]); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); right(); - expect(testComponent.selectedTab()).toBe('tab1'); + expect(testComponent.selectedTab()).toBe(0); }); it('should handle initial selection via input', () => { - updateTabs({selectedTab: 'tab2'}); - expect(testComponent.selectedTab()).toBe('tab2'); + updateTabs({selectedTab: 1}); + expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); }); @@ -696,7 +690,7 @@ describe('Tabs', () => { expect(tabPanelElements[1].hasAttribute('inert')).toBe(true); expect(tabPanelElements[2].hasAttribute('inert')).toBe(true); - updateTabs({selectedTab: 'tab2'}); + updateTabs({selectedTab: 1}); expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); @@ -711,53 +705,53 @@ describe('Tabs', () => { @Component({ template: ` -
    +
      - @for (tabDef of tabsData(); track tabDef.value) { -
    • {{ tabDef.label }}
    • - } +
    • {{ tabsData()[0].label }}
    • +
    • {{ tabsData()[1].label }}
    • +
    • {{ tabsData()[2].label }}
    - - @for (tabDef of tabsData(); track tabDef.value) { -
    - {{ tabDef.content }} -
    - } +
    + {{ tabsData()[0].content }} +
    +
    + {{ tabsData()[1].content }} +
    +
    + {{ tabsData()[2].content }} +
    `, - imports: [Tabs, TabList, Tab, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], changeDetection: ChangeDetectionStrategy.Eager, }) 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); + selectedTab = model(0); orientation = signal<'horizontal' | 'vertical'>('horizontal'); disabled = signal(false); wrap = signal(true); diff --git a/src/aria/tabs/tabs.ts b/src/aria/tabs/tabs.ts deleted file mode 100644 index 0c8d616f411e..000000000000 --- a/src/aria/tabs/tabs.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * @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 {computed, Directive, ElementRef, inject, signal} from '@angular/core'; -import {TabList} from './tab-list'; -import {TabPanel} from './tab-panel'; -import {TABS} from './utils'; -import {TabPanelPattern, TabPattern} from '../private'; - -/** - * A Tabs container. - * - * The `ngTabs` directive represents a set of layered sections of content. It acts as the - * overarching container for a tabbed interface, coordinating the behavior of `ngTabList`, - * `ngTab`, and `ngTabPanel` directives. - * - * ```html - *
    - *
      - *
    • Tab 1
    • - *
    • Tab 2
    • - *
    • Tab 3
    • - *
    - * - *
    - * Content for Tab 1 - *
    - *
    - * Content for Tab 2 - *
    - *
    - * Content for Tab 3 - *
    - *
    - * ``` - * - * @developerPreview 21.0 - * - * @see [Tabs](guide/aria/tabs) - */ -@Directive({ - selector: '[ngTabs]', - exportAs: 'ngTabs', - providers: [{provide: TABS, useExisting: Tabs}], -}) -export class Tabs { - /** A reference to the host element. */ - private readonly _elementRef = inject(ElementRef); - - /** A reference to the host element. */ - readonly element = this._elementRef.nativeElement as HTMLElement; - - /** The TabList nested inside of the container. */ - private readonly _tablist = signal(undefined); - - /** The TabPanels nested inside of the container. */ - private readonly _unorderedPanels = signal(new Set()); - - /** The Tab UIPattern of the child Tabs. */ - readonly _tabPatterns = computed(() => this._tablist()?._tabPatterns()); - - /** The TabPanel UIPattern of the child TabPanels. */ - readonly _unorderedTabpanelPatterns = computed(() => - [...this._unorderedPanels()].map(tabpanel => tabpanel._pattern), - ); - - _register(child: TabList | TabPanel) { - if (child instanceof TabList) { - this._tablist.set(child); - } - - if (child instanceof TabPanel) { - this._unorderedPanels().add(child); - this._unorderedPanels.set(new Set(this._unorderedPanels())); - } - } - - _unregister(child: TabList | TabPanel) { - if (child instanceof TabList) { - this._tablist.set(undefined); - } - - if (child instanceof TabPanel) { - this._unorderedPanels().delete(child); - this._unorderedPanels.set(new Set(this._unorderedPanels())); - } - } -} diff --git a/src/aria/tabs/utils.ts b/src/aria/tabs/utils.ts deleted file mode 100644 index 8a00da60c027..000000000000 --- a/src/aria/tabs/utils.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * @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 {InjectionToken} from '@angular/core'; -import type {Tabs} from './tabs'; - -/** Token used to expose the `Tabs` directive to child directives. */ -export const TABS = new InjectionToken('TABS'); - -export interface HasElement { - element: HTMLElement; -} - -/** - * Sort directives by their document order. - */ -export function sortDirectives(a: HasElement, b: HasElement) { - return (a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_PRECEDING) > 0 - ? 1 - : -1; -} diff --git a/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.html b/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.html index 320cb123ad4c..7cef4f780819 100644 --- a/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.html +++ b/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.ts b/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.ts index 9edf19de666c..e9c7ab2cff0b 100644 --- a/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.ts +++ b/src/components-examples/aria/tabs/active-descendant/tabs-active-descendant-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Active Descendant */ @Component({ selector: 'tabs-active-descendant-example', templateUrl: 'tabs-active-descendant-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsActiveDescendantExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.html b/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.html index cd2192fcf3cd..30b09de67468 100644 --- a/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.html +++ b/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.ts b/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.ts index 7cc4e6007c84..b7c552ab05a3 100644 --- a/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.ts +++ b/src/components-examples/aria/tabs/disabled-focusable/tabs-disabled-focusable-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Disabled Tabs are Focusable */ @Component({ selector: 'tabs-disabled-focusable-example', templateUrl: 'tabs-disabled-focusable-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsDisabledFocusableExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.html b/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.html index e74ad12ae53b..7e1332f0635a 100644 --- a/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.html +++ b/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.ts b/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.ts index 6ffcef883c33..da43ae827cfe 100644 --- a/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.ts +++ b/src/components-examples/aria/tabs/disabled-skipped/tabs-disabled-skipped-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Disabled Tabs are Skipped */ @Component({ selector: 'tabs-disabled-skipped-example', templateUrl: 'tabs-disabled-skipped-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsDisabledSkippedExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/disabled/tabs-disabled-example.html b/src/components-examples/aria/tabs/disabled/tabs-disabled-example.html index d356a4f7f564..791364a6912b 100644 --- a/src/components-examples/aria/tabs/disabled/tabs-disabled-example.html +++ b/src/components-examples/aria/tabs/disabled/tabs-disabled-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/disabled/tabs-disabled-example.ts b/src/components-examples/aria/tabs/disabled/tabs-disabled-example.ts index 5391f71df936..cb392e523122 100644 --- a/src/components-examples/aria/tabs/disabled/tabs-disabled-example.ts +++ b/src/components-examples/aria/tabs/disabled/tabs-disabled-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Disabled */ @Component({ selector: 'tabs-disabled-example', templateUrl: 'tabs-disabled-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsDisabledExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.html b/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.html index 6ee281a4f16a..62e85cd4c0ef 100644 --- a/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.html +++ b/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.ts b/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.ts index bd91d20cdc84..47cc69dddd47 100644 --- a/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.ts +++ b/src/components-examples/aria/tabs/explicit-selection/tabs-explicit-selection-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Explicit selection */ @Component({ selector: 'tabs-explicit-selection-example', templateUrl: 'tabs-explicit-selection-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsExplicitSelectionExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/rtl/tabs-rtl-example.html b/src/components-examples/aria/tabs/rtl/tabs-rtl-example.html index 2c0f2cad2ee4..d63504cd9258 100644 --- a/src/components-examples/aria/tabs/rtl/tabs-rtl-example.html +++ b/src/components-examples/aria/tabs/rtl/tabs-rtl-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/rtl/tabs-rtl-example.ts b/src/components-examples/aria/tabs/rtl/tabs-rtl-example.ts index af28e8ec9c42..252cbe1da7bd 100644 --- a/src/components-examples/aria/tabs/rtl/tabs-rtl-example.ts +++ b/src/components-examples/aria/tabs/rtl/tabs-rtl-example.ts @@ -1,13 +1,13 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; import {Dir} from '@angular/cdk/bidi'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** * @title RTL */ @Component({ selector: 'tabs-rtl-example', templateUrl: 'tabs-rtl-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent, Dir], + imports: [TabList, Tab, TabPanel, TabContent, Dir], }) export class TabsRtlExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.html b/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.html index b92774de566b..68097efa795c 100644 --- a/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.html +++ b/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.ts b/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.ts index 7e2d4cb079d6..326d87744ca8 100644 --- a/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.ts +++ b/src/components-examples/aria/tabs/selection-follows-focus/tabs-selection-follows-focus-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Selection Follows Focus */ @Component({ selector: 'tabs-selection-follows-focus-example', templateUrl: 'tabs-selection-follows-focus-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsSelectionFollowsFocusExample { tabs = viewChildren(Tab); diff --git a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html index 74effd360596..63ed04382aee 100644 --- a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html +++ b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html @@ -29,17 +29,17 @@ Tab selection - - Tab 1 - Tab 2 - Tab 3 - Tab 4 - Tab 5 + + Tab 1 + Tab 2 + Tab 3 + Tab 4 + Tab 5
    -
    +
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts index bf80136ee106..e420474405f0 100644 --- a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts +++ b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.ts @@ -1,6 +1,6 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; import {MatCheckboxModule} from '@angular/material/checkbox'; -import {Tabs, TabList, Tab, TabPanel, TabContent} from '@angular/aria/tabs'; +import {TabList, Tab, TabPanel, TabContent} from '@angular/aria/tabs'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatSelectModule} from '@angular/material/select'; import {FormControl, ReactiveFormsModule} from '@angular/forms'; @@ -11,7 +11,6 @@ import {FormControl, ReactiveFormsModule} from '@angular/forms'; templateUrl: 'tabs-configurable-example.html', styleUrls: ['../tabs-common.css', 'tabs-configurable-example.css'], imports: [ - Tabs, TabList, Tab, TabPanel, @@ -26,7 +25,7 @@ export class TabsConfigurableExample { orientation: 'vertical' | 'horizontal' = 'horizontal'; focusMode: 'roving' | 'activedescendant' = 'roving'; selectionMode: 'explicit' | 'follow' = 'follow'; - tabSelection = 'tab-1'; + tabSelectionIndex = 0; wrap = new FormControl(true, {nonNullable: true}); disabled = new FormControl(false, {nonNullable: true}); diff --git a/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.html b/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.html index 132f776c106c..ce59f1c03128 100644 --- a/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.html +++ b/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.html @@ -1,29 +1,29 @@ -
    -
    -
    Tab 1
    -
    Tab 2
    -
    Tab 3
    -
    Tab 4
    -
    Tab 5
    +
    +
    +
    Tab 1
    +
    Tab 2
    +
    Tab 3
    +
    Tab 4
    +
    Tab 5
    -
    +
    Panel 1
    -
    +
    Panel 2
    -
    +
    Panel 3
    -
    +
    Panel 4
    -
    +
    Panel 5
    diff --git a/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.ts b/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.ts index 626a1b9bee3d..afcc2852494b 100644 --- a/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.ts +++ b/src/components-examples/aria/tabs/vertical-orientation/tabs-vertical-example.ts @@ -1,12 +1,12 @@ import {afterRenderEffect, Component, viewChildren} from '@angular/core'; -import {Tab, Tabs, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; +import {Tab, TabList, TabPanel, TabContent} from '@angular/aria/tabs'; /** @title Vertical */ @Component({ selector: 'tabs-vertical-example', templateUrl: 'tabs-vertical-example.html', styleUrls: ['../tabs-common.css'], - imports: [TabList, Tab, Tabs, TabPanel, TabContent], + imports: [TabList, Tab, TabPanel, TabContent], }) export class TabsVerticalExample { tabs = viewChildren(Tab); From fd5f0bbf2903c2aa17f4746a05c0c2f9eb872f0e Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 30 Mar 2026 15:50:40 -0700 Subject: [PATCH 2/2] refactor(aria/tabs): move selection of tabs to each tab --- goldens/aria/private/index.api.md | 5 +- goldens/aria/tabs/index.api.md | 11 ++-- src/aria/private/tabs/tabs.spec.ts | 64 ++++++++----------- src/aria/private/tabs/tabs.ts | 47 +++++--------- src/aria/tabs/tab-list.ts | 31 +-------- src/aria/tabs/tab.ts | 3 +- src/aria/tabs/tabs.spec.ts | 59 ++++++++--------- .../tabs-configurable-example.html | 11 ++-- 8 files changed, 89 insertions(+), 142 deletions(-) diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index cbb959738185..ba662bf9fa34 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -665,13 +665,13 @@ export type SignalLike = () => T; // @public export interface TabInputs extends Omit, Omit { + readonly selected: WritableSignalLike; tabList: SignalLike; tabPanel: SignalLike; } // @public export interface TabListInputs extends Omit, 'multi'>, Omit { - selectedTab: WritableSignalLike; selectionMode: SignalLike<'follow' | 'explicit'>; } @@ -695,7 +695,6 @@ export class TabListPattern { readonly orientation: SignalLike<'vertical' | 'horizontal'>; readonly pointerdown: SignalLike>; readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">; - readonly selectedTab: WritableSignalLike; setDefaultState(): void; readonly tabIndex: SignalLike<0 | -1>; } @@ -726,13 +725,11 @@ export class TabPattern { readonly disabled: SignalLike; readonly element: SignalLike; readonly expandable: SignalLike; - // (undocumented) readonly expanded: WritableSignalLike; readonly id: SignalLike; // (undocumented) readonly inputs: TabInputs; open(): boolean; - readonly selected: SignalLike; readonly tabIndex: SignalLike<0 | -1>; } diff --git a/goldens/aria/tabs/index.api.md b/goldens/aria/tabs/index.api.md index 01de4e37dcd7..eff1f4b010bb 100644 --- a/goldens/aria/tabs/index.api.md +++ b/goldens/aria/tabs/index.api.md @@ -5,6 +5,7 @@ ```ts import { AfterViewInit } from '@angular/core'; +import * as _angular_aria_private_public_api from '@angular/aria/private/public-api'; import * as _angular_cdk_bidi from '@angular/cdk/bidi'; import * as _angular_core from '@angular/core'; import { OnDestroy } from '@angular/core'; @@ -21,9 +22,9 @@ export class Tab implements AfterViewInit { open(): void; readonly panel: _angular_core.InputSignal; readonly _pattern: TabPattern; - readonly selected: _angular_core.Signal; + readonly selected: _angular_core.ModelSignal; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } @@ -38,7 +39,6 @@ export class TabContent { // @public export class TabList implements AfterViewInit { - constructor(); readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; @@ -46,14 +46,13 @@ export class TabList implements AfterViewInit { ngAfterViewInit(): void; readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">; readonly _pattern: TabListPattern; - readonly selectedTabIndex: _angular_core.ModelSignal; readonly selectionMode: _angular_core.InputSignal<"follow" | "explicit">; readonly softDisabled: _angular_core.InputSignalWithTransform; - readonly _tabPatterns: _angular_core.Signal; + readonly _tabPatterns: _angular_core.Signal<_angular_aria_private_public_api.TabPattern[]>; readonly textDirection: _angular_core.WritableSignal<_angular_cdk_bidi.Direction>; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) - static ɵdir: _angular_core.ɵɵDirectiveDeclaration; + static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/src/aria/private/tabs/tabs.spec.ts b/src/aria/private/tabs/tabs.spec.ts index a19416e85e96..068fa6a9244d 100644 --- a/src/aria/private/tabs/tabs.spec.ts +++ b/src/aria/private/tabs/tabs.spec.ts @@ -65,7 +65,6 @@ describe('Tabs Pattern', () => { softDisabled: signal(true), items: signal([]), element: signal(document.createElement('div')), - selectedTab: signal(undefined), }; tabListPattern = new TabListPattern(tabListInputs); @@ -77,6 +76,7 @@ describe('Tabs Pattern', () => { id: signal('tab-1-id'), element: signal(createTabElement()), disabled: signal(false), + selected: signal(false), }, { tabList: signal(tabListPattern), @@ -84,6 +84,7 @@ describe('Tabs Pattern', () => { id: signal('tab-2-id'), element: signal(createTabElement()), disabled: signal(false), + selected: signal(false), }, { tabList: signal(tabListPattern), @@ -91,6 +92,7 @@ describe('Tabs Pattern', () => { id: signal('tab-3-id'), element: signal(createTabElement()), disabled: signal(false), + selected: signal(false), }, ]; tabPatterns = [ @@ -133,23 +135,15 @@ describe('Tabs Pattern', () => { describe('TabListPattern', () => { describe('#open', () => { - it('should open a tab with value', () => { - expect(tabListPattern.selectedTab()).toBeUndefined(); - tabListPattern.open(tabPatterns[0]); - expect(tabListPattern.selectedTab()!).toBe(tabPatterns[0]); - }); - it('should open a tab with tab pattern instance', () => { - expect(tabListPattern.selectedTab()).toBeUndefined(); - tabListPattern.open(tabPatterns[0]); - expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]); + tabListPattern.open(tabPatterns[1]); + expect(tabPatterns[1].expanded()).toBeTrue(); }); it('should open the active tab', () => { - expect(tabListPattern.selectedTab()).toBeUndefined(); expect(tabListPattern.activeTab()).toBe(tabPatterns[0]); tabListPattern.open(); - expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]); + expect(tabPatterns[0].expanded()).toBeTrue(); }); }); @@ -172,7 +166,6 @@ describe('Tabs Pattern', () => { it('should set activeIndex to the first focusable tab if no tabs are selected', () => { tabListInputs.softDisabled.set(false); tabListInputs.activeItem.set(tabPatterns[2]); - tabListPattern.selectedTab.set(undefined); tabInputs[0].disabled.set(true); tabListPattern.setDefaultState(); expect(tabListInputs.activeItem()).toBe(tabPatterns[1]); @@ -180,14 +173,14 @@ describe('Tabs Pattern', () => { it('should set activeIndex to the first focusable and selected tab', () => { tabListInputs.activeItem.set(tabPatterns[0]); - tabListPattern.selectedTab.set(tabPatterns[2]); + tabPatterns[2].expanded.set(true); tabListPattern.setDefaultState(); expect(tabListInputs.activeItem()).toBe(tabPatterns[2]); }); it('should set activeIndex to the first focusable tab when the selected tab is not focusable', () => { tabListInputs.softDisabled.set(false); - tabListPattern.selectedTab.set(tabPatterns[1]); + tabPatterns[1].expanded.set(true); tabInputs[1].disabled.set(true); tabListPattern.setDefaultState(); expect(tabListInputs.activeItem()).toBe(tabPatterns[0]); @@ -221,37 +214,37 @@ describe('Tabs Pattern', () => { it('selects a tab by focus if `selectionMode` is "follow".', () => { tabListPattern.onKeydown(space()); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPatterns[0].expanded()).toBeTrue(); + expect(tabPatterns[1].expanded()).toBeFalse(); tabListPattern.onKeydown(right()); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); + expect(tabPatterns[0].expanded()).toBeFalse(); + expect(tabPatterns[1].expanded()).toBeTrue(); }); it('selects a tab by enter key if `selectionMode` is "explicit".', () => { tabListInputs.selectionMode.set('explicit'); tabListPattern.onKeydown(space()); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPatterns[0].expanded()).toBeTrue(); + expect(tabPatterns[1].expanded()).toBeFalse(); tabListPattern.onKeydown(right()); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPatterns[0].expanded()).toBeTrue(); + expect(tabPatterns[1].expanded()).toBeFalse(); tabListPattern.onKeydown(enter()); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); + expect(tabPatterns[0].expanded()).toBeFalse(); + expect(tabPatterns[1].expanded()).toBeTrue(); }); it('selects a tab by space key if `selectionMode` is "explicit".', () => { tabListInputs.selectionMode.set('explicit'); tabListPattern.onKeydown(space()); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPatterns[0].expanded()).toBeTrue(); + expect(tabPatterns[1].expanded()).toBeFalse(); tabListPattern.onKeydown(right()); - expect(tabPatterns[0].selected()).toBeTrue(); - expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPatterns[0].expanded()).toBeTrue(); + expect(tabPatterns[1].expanded()).toBeFalse(); tabListPattern.onKeydown(space()); - expect(tabPatterns[0].selected()).toBeFalse(); - expect(tabPatterns[1].selected()).toBeTrue(); + expect(tabPatterns[0].expanded()).toBeFalse(); + expect(tabPatterns[1].expanded()).toBeTrue(); }); it('uses left key to navigate to the previous tab when `orientation` is set to "horizontal".', () => { @@ -347,9 +340,8 @@ describe('Tabs Pattern', () => { describe('#open', () => { it('should open the current tab', () => { - expect(tabListPattern.selectedTab()).toBeUndefined(); - tabPatterns[0].open(); - expect(tabListPattern.selectedTab()).toBe(tabPatterns[0]); + tabPatterns[1].open(); + expect(tabPatterns[1].expanded()).toBeTrue(); }); }); }); @@ -357,12 +349,12 @@ describe('Tabs Pattern', () => { describe('TabPanelPattern', () => { it('should set a tabpanel to be not hidden if a tab is opened', () => { tabPatterns[0].open(); - expect(tabPatterns[0].selected()).toBeTrue(); + expect(tabPatterns[0].expanded()).toBeTrue(); expect(tabPanelPatterns[0].hidden()).toBeFalse(); }); it('sets a tabpanel to be hidden if a tab is not opened', () => { - expect(tabPatterns[1].selected()).toBeFalse(); + expect(tabPatterns[1].expanded()).toBeFalse(); expect(tabPanelPatterns[1].hidden()).toBeTrue(); }); diff --git a/src/aria/private/tabs/tabs.ts b/src/aria/private/tabs/tabs.ts index ba7703d86b85..3c52bea4ce57 100644 --- a/src/aria/private/tabs/tabs.ts +++ b/src/aria/private/tabs/tabs.ts @@ -8,12 +8,7 @@ import {KeyboardEventManager, PointerEventManager} from '../behaviors/event-manager'; import {ExpansionItem, ListExpansion, ListExpansionInputs} from '../behaviors/expansion/expansion'; -import { - SignalLike, - WritableSignalLike, - computed, - linkedSignal, -} from '../behaviors/signal-like/signal-like'; +import {SignalLike, WritableSignalLike, computed} from '../behaviors/signal-like/signal-like'; import {LabelControl, LabelControlOptionalInputs} from '../behaviors/label/label'; import {ListFocus} from '../behaviors/list-focus/list-focus'; import { @@ -30,6 +25,9 @@ export interface TabInputs /** The remote tabpanel controlled by the tab. */ tabPanel: SignalLike; + + /** Whether the tab is selected. */ + readonly selected: WritableSignalLike; } /** A tab in a tablist. */ @@ -46,22 +44,12 @@ export class TabPattern { /** Whether this tab has expandable panel. */ readonly expandable: SignalLike = () => true; - /* - * Whether the tab panel is expanded. - * Primarily controlled by the behavior, which will read/write this value. - * The consumer of this pattern will instead only use the selectedTab input. - * The pattern will be responsible for synchronizing their state. - */ - readonly expanded: WritableSignalLike = linkedSignal( - () => this.inputs.tabList().selectedTab() === this, - ); + /** Whether the tab panel is expanded. */ + readonly expanded: WritableSignalLike; // set from inputs (selected) /** Whether the tab is active. */ readonly active = computed(() => this.inputs.tabList().inputs.activeItem() === this); - /** Whether the tab is selected. */ - readonly selected = computed(() => this.inputs.tabList().selectedTab() === this); - /** The tab index of the tab. */ readonly tabIndex = computed(() => this.inputs.tabList().focusBehavior.getItemTabIndex(this)); @@ -71,6 +59,7 @@ export class TabPattern { constructor(readonly inputs: TabInputs) { this.id = inputs.id; this.disabled = inputs.disabled; + this.expanded = inputs.selected; } /** Opens the tab. */ @@ -126,9 +115,6 @@ export interface TabListInputs Omit { /** The selection strategy used by the tablist. */ selectionMode: SignalLike<'follow' | 'explicit'>; - - /** The currently selected tab. */ - selectedTab: WritableSignalLike; } /** Controls the state of a tablist. */ @@ -146,7 +132,6 @@ export class TabListPattern { readonly activeTab: SignalLike; // set from inputs /** The currently selected tab. */ - readonly selectedTab: WritableSignalLike; // set from inputs /** Whether the tablist is vertically or horizontally oriented. */ readonly orientation: SignalLike<'vertical' | 'horizontal'>; // set from inputs @@ -206,7 +191,6 @@ export class TabListPattern { }); constructor(readonly inputs: TabListInputs) { - this.selectedTab = inputs.selectedTab; this.activeTab = inputs.activeItem; this.orientation = inputs.orientation; this.disabled = inputs.disabled; @@ -242,7 +226,7 @@ export class TabListPattern { firstItem = item; } - if (item.selected()) { + if (item.expanded()) { this.inputs.activeItem.set(item); return; } @@ -250,6 +234,14 @@ export class TabListPattern { if (firstItem !== undefined) { this.inputs.activeItem.set(firstItem); } + + const selectedTabs = this.inputs.items().filter(tab => tab.expanded()); + if (selectedTabs.length == 0) { + firstItem?.expanded.set(true); + } else if (selectedTabs.length > 1) { + // If multiple tabs are selected, only the first one should be expanded. + selectedTabs.slice(1).forEach(tab => tab.expanded.set(false)); + } } /** Handles keydown events for the tablist. */ @@ -273,12 +265,7 @@ export class TabListPattern { if (tab === undefined) return false; - const success = this.expansionBehavior.open(tab); - if (success) { - this.selectedTab.set(tab); - } - - return success; + return this.expansionBehavior.open(tab); } /** Executes a navigation operation and expand the active tab if needed. */ diff --git a/src/aria/tabs/tab-list.ts b/src/aria/tabs/tab-list.ts index 83c600bc0120..35bfe0dbd9d3 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -11,17 +11,14 @@ import { AfterViewInit, Directive, ElementRef, - afterRenderEffect, booleanAttribute, computed, contentChildren, inject, input, - linkedSignal, - model, signal, } from '@angular/core'; -import {TabListPattern, TabPattern} from '../private'; +import {TabListPattern} from '../private'; import {Tab} from './tab'; import {TAB_LIST} from './tab-tokens'; @@ -32,8 +29,8 @@ import {TAB_LIST} from './tab-tokens'; * panels. It manages keyboard navigation, selection, and the overall orientation of the tabs. * * ```html - *
      - *
    • First Tab
    • + *
        + *
      • First Tab
      • *
      • Second Tab
      • *
      * ``` @@ -101,36 +98,14 @@ export class TabList implements AfterViewInit { /** Whether the tablist is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); - /** - * The current selected tab index. - * - * Can be used to set the initially selected tab, or to programmatically force a tab - * to be selected. - */ - readonly selectedTabIndex = model(0); - - /** The current selected tab pattern. */ - private readonly _selectedTabPattern = linkedSignal( - () => this._tabPatterns()[this.selectedTabIndex()], - ); - /** The TabList UIPattern. */ readonly _pattern: TabListPattern = new TabListPattern({ ...this, element: () => this._elementRef.nativeElement, activeItem: signal(undefined), items: this._tabPatterns, - selectedTab: this._selectedTabPattern, }); - constructor() { - afterRenderEffect(() => { - const tab = this._selectedTabPattern(); - const index = tab && this._tabPatterns().includes(tab) ? this._tabPatterns().indexOf(tab) : 0; - this.selectedTabIndex.set(index); - }); - } - ngAfterViewInit() { this._pattern.setDefaultState(); } diff --git a/src/aria/tabs/tab.ts b/src/aria/tabs/tab.ts index b3518c0329bf..334824c50f1f 100644 --- a/src/aria/tabs/tab.ts +++ b/src/aria/tabs/tab.ts @@ -15,6 +15,7 @@ import { computed, inject, input, + model, } from '@angular/core'; import {TabPattern} from '../private'; import {TabPanel} from './tab-panel'; @@ -72,7 +73,7 @@ export class Tab implements AfterViewInit { readonly active = computed(() => this._pattern.active()); /** Whether the tab is selected. */ - readonly selected = computed(() => this._pattern.selected()); + readonly selected = model(false); /** The Tab UIPattern. */ readonly _pattern: TabPattern = new TabPattern({ diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index f138575336d9..521bb620c08f 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -496,48 +496,45 @@ describe('Tabs', () => { it('should select tab on focus via ArrowKeys', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); - expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); expect(tabElements[0].getAttribute('aria-selected')).toBe('false'); left(); - expect(testComponent.selectedTab()).toBe(0); 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: 1}); - expect(testComponent.selectedTab()).toBe(1); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); home(); - expect(testComponent.selectedTab()).toBe(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); end(); - expect(testComponent.selectedTab()).toBe(2); expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); }); it('should select tab on click', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + pointerDown(tabElements[1]); - expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); it('should not change selection with Space/Enter on already selected tab', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + space(); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + enter(); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); }); }); @@ -548,17 +545,14 @@ describe('Tabs', () => { it('should not select tab on focus via ArrowKeys', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); - expect(testComponent.selectedTab()).toBe(0); 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(0); expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); expect(tabElements[1].getAttribute('aria-selected')).toBe('false'); expect(isTabFocused(0)).toBe(true); @@ -566,54 +560,52 @@ describe('Tabs', () => { it('should select focused tab on Space', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); expect(isTabFocused(1)).toBe(true); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); space(); - expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); it('should select focused tab on Enter', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); expect(isTabFocused(1)).toBe(true); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); enter(); - expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); it('should select tab on click', () => { updateTabs({selectedTab: 0}); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); + pointerDown(tabElements[1]); - expect(testComponent.selectedTab()).toBe(1); expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); }); }); it('should update selectedTab model on selection change', () => { updateTabs({selectedTab: 0, selectionMode: 'follow'}); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); right(); - expect(testComponent.selectedTab()).toBe(1); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); updateTabs({selectionMode: 'explicit'}); right(); - expect(testComponent.selectedTab()).toBe(1); + expect(tabElements[1].getAttribute('aria-selected')).toBe('true'); enter(); - expect(testComponent.selectedTab()).toBe(2); + expect(tabElements[2].getAttribute('aria-selected')).toBe('true'); pointerDown(tabElements[0]); - expect(testComponent.selectedTab()).toBe(0); + expect(tabElements[0].getAttribute('aria-selected')).toBe('true'); }); it('should update selection when selectedTab model changes', () => { @@ -707,16 +699,21 @@ describe('Tabs', () => { template: `
        -
      • {{ tabsData()[0].label }}
      • -
      • {{ tabsData()[1].label }}
      • -
      • {{ tabsData()[2].label }}
      • +
      • + {{ tabsData()[0].label }} +
      • +
      • + {{ tabsData()[1].label }} +
      • +
      • + {{ tabsData()[2].label }} +
      {{ tabsData()[0].content }} diff --git a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html index 63ed04382aee..a925d8403de9 100644 --- a/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html +++ b/src/components-examples/aria/tabs/tabs-configurable/tabs-configurable-example.html @@ -47,14 +47,13 @@ [softDisabled]="softDisabled.value" [orientation]="orientation" [focusMode]="focusMode" - [selectedTabIndex]="tabSelectionIndex" selectionMode="explicit" > -
      Tab 1
      -
      Tab 2
      -
      Tab 3
      -
      Tab 4
      -
      Tab 5
      +
      Tab 1
      +
      Tab 2
      +
      Tab 3
      +
      Tab 4
      +
      Tab 5