From d0613b3bdcacf561911ff34e01bd1c983a7da876 Mon Sep 17 00:00:00 2001 From: Andrey Dolgachev Date: Mon, 13 Apr 2026 20:47:12 -0700 Subject: [PATCH] refactor(aria/tabs): Clean up tab selection and linking to panels --- goldens/aria/private/index.api.md | 16 ++--- goldens/aria/tabs/index.api.md | 26 +++++--- src/aria/private/tabs/tabs.spec.ts | 32 ++++----- src/aria/private/tabs/tabs.ts | 88 ++++++++++++------------- src/aria/tabs/tab-list.ts | 102 ++++++++++++++++------------- src/aria/tabs/tab-panel.ts | 20 +++--- src/aria/tabs/tab-tokens.ts | 4 ++ src/aria/tabs/tab.ts | 36 ++++------ src/aria/tabs/tabs.spec.ts | 10 +-- src/aria/tabs/tabs.ts | 68 +++++++++++-------- 10 files changed, 205 insertions(+), 197 deletions(-) diff --git a/goldens/aria/private/index.api.md b/goldens/aria/private/index.api.md index b6c1d72b8fa7..e21af5b10be9 100644 --- a/goldens/aria/private/index.api.md +++ b/goldens/aria/private/index.api.md @@ -660,14 +660,14 @@ export type SignalLike = () => T; export function sortDirectives(a: HasElement, b: HasElement): 1 | -1; // @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'>; } @@ -690,7 +690,6 @@ export class TabListPattern { onClick(event: PointerEvent): void; onFocusIn(): void; onKeydown(event: KeyboardEvent): void; - open(value: string): boolean; open(tab?: TabPattern): boolean; readonly orientation: SignalLike<'vertical' | 'horizontal'>; readonly prevKey: SignalLike<"ArrowUp" | "ArrowRight" | "ArrowLeft">; @@ -703,8 +702,7 @@ export class TabListPattern { // @public export interface TabPanelInputs extends LabelControlOptionalInputs { id: SignalLike; - tab: SignalLike; - value: SignalLike; + readonly tab: SignalLike; } // @public @@ -717,7 +715,6 @@ export class TabPanelPattern { readonly labelledBy: SignalLike; readonly labelManager: LabelControl; readonly tabIndex: SignalLike<-1 | 0>; - readonly value: SignalLike; } // @public @@ -728,15 +725,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 0430af759711..9cb495531bf1 100644 --- a/goldens/aria/tabs/index.api.md +++ b/goldens/aria/tabs/index.api.md @@ -8,6 +8,7 @@ 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 { @@ -20,6 +21,7 @@ export class Tab implements HasElement, OnInit, OnDestroy { // (undocumented) ngOnInit(): void; open(): void; + readonly panel: _angular_core.Signal; readonly _pattern: TabPattern; readonly selected: _angular_core.Signal; readonly value: _angular_core.InputSignal; @@ -42,6 +44,8 @@ export class TabList implements OnInit, OnDestroy { constructor(); readonly disabled: _angular_core.InputSignalWithTransform; readonly element: HTMLElement; + // (undocumented) + findTab(value?: string): Tab | undefined; readonly focusMode: _angular_core.InputSignal<"roving" | "activedescendant">; // (undocumented) ngOnDestroy(): void; @@ -51,14 +55,14 @@ export class TabList implements OnInit, OnDestroy { readonly orientation: _angular_core.InputSignal<"vertical" | "horizontal">; readonly _pattern: TabListPattern; // (undocumented) - _register(child: Tab): void; + _registerTab(child: Tab): void; readonly selectedTab: _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>; + readonly _sortedTabs: _angular_core.Signal; + readonly textDirection: WritableSignal<_angular_cdk_bidi.Direction>; // (undocumented) - _unregister(child: Tab): void; + _unregisterTab(child: Tab): void; readonly wrap: _angular_core.InputSignalWithTransform; // (undocumented) static ɵdir: _angular_core.ɵɵDirectiveDeclaration; @@ -76,6 +80,7 @@ export class TabPanel implements OnInit, OnDestroy { // (undocumented) ngOnInit(): void; readonly _pattern: TabPanelPattern; + readonly _tabPattern: WritableSignal; readonly value: _angular_core.InputSignal; readonly visible: _angular_core.Signal; // (undocumented) @@ -86,13 +91,18 @@ export class TabPanel implements OnInit, OnDestroy { // @public export class Tabs { + constructor(); readonly element: HTMLElement; // (undocumented) - _register(child: TabList | TabPanel): void; - readonly _tabPatterns: _angular_core.Signal; - readonly _unorderedTabpanelPatterns: _angular_core.Signal; + findTabPanel(value?: string): TabPanel | undefined; + // (undocumented) + _registerList(list: TabList): void; + // (undocumented) + _registerPanel(panel: TabPanel): void; + // (undocumented) + _unregisterList(list: TabList): void; // (undocumented) - _unregister(child: TabList | TabPanel): void; + _unregisterPanel(panel: TabPanel): void; // (undocumented) static ɵdir: _angular_core.ɵɵDirectiveDeclaration; // (undocumented) diff --git a/src/aria/private/tabs/tabs.spec.ts b/src/aria/private/tabs/tabs.spec.ts index 6712ca417098..be888aa391ba 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 08702392b664..7346f2dc3af5 100644 --- a/src/aria/private/tabs/tabs.ts +++ b/src/aria/private/tabs/tabs.ts @@ -7,47 +7,39 @@ */ import {KeyboardEventManager, ClickEventManager} from '../behaviors/event-manager'; -import {ExpansionItem, ListExpansionInputs, ListExpansion} from '../behaviors/expansion/expansion'; +import {ExpansionItem, ListExpansion, ListExpansionInputs} from '../behaviors/expansion/expansion'; import { SignalLike, + WritableSignalLike, computed, + linkedSignal, signal, - WritableSignalLike, } 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 +47,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 +86,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 +111,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 +127,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. */ @@ -148,16 +147,16 @@ export class TabListPattern { readonly hasBeenInteracted = signal(false); /** 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()); @@ -211,6 +210,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({ @@ -280,19 +284,11 @@ export class TabListPattern { this.hasBeenInteracted.set(true); } - /** 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/tab-list.ts b/src/aria/tabs/tab-list.ts index d9fda7adb7ce..9dd842392ecf 100644 --- a/src/aria/tabs/tab-list.ts +++ b/src/aria/tabs/tab-list.ts @@ -8,21 +8,23 @@ import {Directionality} from '@angular/cdk/bidi'; import { - booleanAttribute, - computed, Directive, ElementRef, + OnDestroy, + OnInit, + WritableSignal, + afterRenderEffect, + booleanAttribute, + computed, inject, input, + linkedSignal, model, signal, - afterRenderEffect, - OnInit, - OnDestroy, } from '@angular/core'; import {TabListPattern, TabPattern, sortDirectives} from '../private'; -import {TABS} from './tab-tokens'; -import type {Tab} from './tab'; +import {Tab} from './tab'; +import {TABS, TAB_LIST} from './tab-tokens'; /** * A TabList container. @@ -55,6 +57,7 @@ import type {Tab} from './tab'; '(click)': '_pattern.onClick($event)', '(focusin)': '_pattern.onFocusIn()', }, + providers: [{provide: TAB_LIST, useExisting: TabList}], }) export class TabList implements OnInit, OnDestroy { /** A reference to the host element. */ @@ -63,23 +66,24 @@ export class TabList implements OnInit, OnDestroy { /** A reference to the host element. */ readonly element = this._elementRef.nativeElement as HTMLElement; - /** The parent Tabs. */ - private readonly _tabs = inject(TABS); + /** The parent Tabs container. */ + private readonly _tabsParent = inject(TABS); - /** The Tabs nested inside of the TabList. */ - private readonly _unorderedTabs = signal(new Set()); + /** The Tabs registered for this TabList. */ + private readonly _tabs = signal(new Set()); - /** Text direction. */ - readonly textDirection = inject(Directionality).valueSignal; + /** The Tabs registered for this TabList. */ + readonly _sortedTabs = computed(() => [...this._tabs()].sort(sortDirectives)); /** The Tab UIPatterns of the child Tabs. */ - readonly _tabPatterns = computed(() => - [...this._unorderedTabs()].sort(sortDirectives).map(tab => tab._pattern), - ); + private readonly _tabPatterns = computed(() => [...this._sortedTabs()].map(tab => tab._pattern)); /** Whether the tablist is vertically or horizontally oriented. */ readonly orientation = input<'vertical' | 'horizontal'>('horizontal'); + /** Text direction. */ + readonly textDirection = inject(Directionality).valueSignal; + /** Whether focus should wrap when navigating. */ readonly wrap = input(true, {transform: booleanAttribute}); @@ -103,63 +107,69 @@ export class TabList implements OnInit, OnDestroy { */ readonly selectionMode = input<'follow' | 'explicit'>('follow'); - /** The current selected tab. */ + /** The current selected tab as a model input. */ readonly selectedTab = model(); + /** The current selected Tab pattern, passed to the List pattern. */ + private readonly _selectedTabPattern: WritableSignal = linkedSignal( + () => { + const tab = this.findTab(this.selectedTab()); + + return tab?._pattern; + }, + ); + /** Whether the tablist is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); /** 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, }); constructor() { - afterRenderEffect(() => { - this._pattern.setDefaultStateEffect(); - }); - - afterRenderEffect(() => { - const tab = this._pattern.selectedTab(); - if (tab) { - this.selectedTab.set(tab.value()); - } + // This needs to be in an afterRenderEffect to ensure the tabs have all been initialized. + // Otherwise, the lookup here can fail and it does not get re-run afterwards. + afterRenderEffect({ + write: () => { + const pattern = this._selectedTabPattern(); + const tab = this._sortedTabs().find(tab => tab._pattern == pattern); + + 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); - } - }); + afterRenderEffect({write: () => this._pattern.setDefaultStateEffect()}); } ngOnInit() { - this._tabs._register(this); + this._tabsParent._registerList(this); } ngOnDestroy() { - this._tabs._unregister(this); + this._tabsParent._registerList(this); } - _register(child: Tab) { - this._unorderedTabs().add(child); - this._unorderedTabs.set(new Set(this._unorderedTabs())); + _registerTab(child: Tab) { + this._tabs().add(child); + this._tabs.set(new Set(this._tabs())); } - _unregister(child: Tab) { - this._unorderedTabs().delete(child); - this._unorderedTabs.set(new Set(this._unorderedTabs())); + _unregisterTab(child: Tab) { + this._tabs().delete(child); + this._tabs.set(new Set(this._tabs())); } /** Opens the tab panel with the specified value. */ open(value: string): boolean { - return this._pattern.open(value); + return this._pattern.open(this.findTab(value)?._pattern); + } + + findTab(value?: string) { + return value ? this._sortedTabs().find(tab => tab.value() === value) : undefined; } } diff --git a/src/aria/tabs/tab-panel.ts b/src/aria/tabs/tab-panel.ts index 13b5513c662f..fc707b0a97d8 100644 --- a/src/aria/tabs/tab-panel.ts +++ b/src/aria/tabs/tab-panel.ts @@ -8,16 +8,18 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import { - computed, Directive, ElementRef, + OnDestroy, + OnInit, + WritableSignal, + afterRenderEffect, + computed, inject, input, - afterRenderEffect, - OnInit, - OnDestroy, + signal, } from '@angular/core'; -import {TabPanelPattern, DeferredContentAware} from '../private'; +import {TabPattern, TabPanelPattern, DeferredContentAware} from '../private'; import {TABS} from './tab-tokens'; /** @@ -73,9 +75,7 @@ export class TabPanel implements OnInit, OnDestroy { 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()), - ); + readonly _tabPattern: WritableSignal = signal(undefined); /** A local unique identifier for the tabpanel. */ readonly value = input.required(); @@ -94,10 +94,10 @@ export class TabPanel implements OnInit, OnDestroy { } ngOnInit() { - this._tabs._register(this); + this._tabs._registerPanel(this); } ngOnDestroy() { - this._tabs._unregister(this); + this._tabs._unregisterPanel(this); } } diff --git a/src/aria/tabs/tab-tokens.ts b/src/aria/tabs/tab-tokens.ts index 83b3ea089a6e..ee21244b165e 100644 --- a/src/aria/tabs/tab-tokens.ts +++ b/src/aria/tabs/tab-tokens.ts @@ -8,6 +8,10 @@ import {InjectionToken} from '@angular/core'; import type {Tabs} from './tabs'; +import type {TabList} from './tab-list'; /** Token used to expose the `Tabs` directive to child directives. */ export const TABS = new InjectionToken('TABS'); + +/** 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 c446cec5a17a..e69ee7ef7110 100644 --- a/src/aria/tabs/tab.ts +++ b/src/aria/tabs/tab.ts @@ -8,19 +8,17 @@ import {_IdGenerator} from '@angular/cdk/a11y'; import { - booleanAttribute, - computed, Directive, ElementRef, + OnDestroy, + OnInit, + booleanAttribute, + computed, inject, input, - signal, - OnInit, - OnDestroy, } from '@angular/core'; import {TabPattern, HasElement} from '../private'; -import {TabList} from './tab-list'; -import {TABS} from './tab-tokens'; +import {TABS, TAB_LIST} from './tab-tokens'; /** * A selectable tab in a TabList. @@ -58,22 +56,17 @@ export class Tab implements HasElement, OnInit, OnDestroy { /** A reference to the host element. */ readonly element = this._elementRef.nativeElement as HTMLElement; - /** The parent Tabs. */ - private readonly _tabs = inject(TABS); + /** The parent Tabs wrapper. */ + private readonly _tabsWrapper = 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 = computed(() => this._tabsWrapper.findTabPanel(this.value())); /** Whether a tab is disabled. */ readonly disabled = input(false, {transform: booleanAttribute}); @@ -90,10 +83,9 @@ 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), }); /** Opens this tab panel. */ @@ -102,10 +94,10 @@ export class Tab implements HasElement, OnInit, OnDestroy { } ngOnInit() { - this._tabList._register(this); + this._tabList._registerTab(this); } ngOnDestroy() { - this._tabList._unregister(this); + this._tabList._unregisterTab(this); } } diff --git a/src/aria/tabs/tabs.spec.ts b/src/aria/tabs/tabs.spec.ts index 39c86b60afcf..ad781a4b6d6e 100644 --- a/src/aria/tabs/tabs.spec.ts +++ b/src/aria/tabs/tabs.spec.ts @@ -1,4 +1,4 @@ -import {Component, DebugElement, signal, ChangeDetectionStrategy} from '@angular/core'; +import {ChangeDetectionStrategy, Component, DebugElement, signal} from '@angular/core'; import {ComponentFixture, TestBed} from '@angular/core/testing'; import {By} from '@angular/platform-browser'; import {Direction} from '@angular/cdk/bidi'; @@ -27,12 +27,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[]; @@ -107,12 +105,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); @@ -127,8 +123,8 @@ describe('Tabs', () => { } afterEach(async () => { - if (tabsElement) { - await runAccessibilityChecks(tabsElement); + if (fixture.nativeElement) { + await runAccessibilityChecks(fixture.nativeElement); } }); diff --git a/src/aria/tabs/tabs.ts b/src/aria/tabs/tabs.ts index 33600a1c38b0..b3cca2e7d4d0 100644 --- a/src/aria/tabs/tabs.ts +++ b/src/aria/tabs/tabs.ts @@ -6,11 +6,10 @@ * found in the LICENSE file at https://angular.dev/license */ -import {computed, Directive, ElementRef, inject, signal} from '@angular/core'; +import {Directive, ElementRef, afterRenderEffect, computed, inject, signal} from '@angular/core'; import {TabList} from './tab-list'; import {TabPanel} from './tab-panel'; import {TABS} from './tab-tokens'; -import {TabPanelPattern, TabPattern} from '../private'; /** * A Tabs container. @@ -55,39 +54,52 @@ export class Tabs { /** 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 TabList registered for this container. */ + private readonly _tabList = signal(undefined); - /** The TabPanels nested inside of the container. */ - private readonly _unorderedPanels = signal(new Set()); + /** The TabPanels registered for this container. */ + private readonly _tabPanels = signal(new Set()); - /** The Tab UIPattern of the child Tabs. */ - readonly _tabPatterns = computed(() => this._tablist()?._tabPatterns()); + /** The TabPanels registered for this container. */ + private readonly _tabPanelsList = computed(() => [...this._tabPanels()]); - /** The TabPanel UIPattern of the child TabPanels. */ - readonly _unorderedTabpanelPatterns = computed(() => - [...this._unorderedPanels()].map(tabpanel => tabpanel._pattern), - ); + constructor() { + // This needs to be in an afterRenderEffect to ensure the tabs have all been initialized. + // Otherwise, the lookup here can fail and it does not get re-run afterwards. + afterRenderEffect({ + write: () => { + if (this._tabList()) { + for (const tab of this._tabList()!._sortedTabs()) { + const panel = this._tabPanelsList().find(panel => panel === tab.panel()); - _register(child: TabList | TabPanel) { - if (child instanceof TabList) { - this._tablist.set(child); - } + if (panel) { + panel._tabPattern.set(tab._pattern); + } + } + } + }, + }); + } + + _registerList(list: TabList) { + this._tabList.set(list); + } - if (child instanceof TabPanel) { - this._unorderedPanels().add(child); - this._unorderedPanels.set(new Set(this._unorderedPanels())); - } + _unregisterList(list: TabList) { + this._tabList.set(undefined); } - _unregister(child: TabList | TabPanel) { - if (child instanceof TabList) { - this._tablist.set(undefined); - } + _registerPanel(panel: TabPanel) { + this._tabPanels().add(panel); + this._tabPanels.set(new Set(this._tabPanels())); + } + + _unregisterPanel(panel: TabPanel) { + this._tabPanels().delete(panel); + this._tabPanels.set(new Set(this._tabPanels())); + } - if (child instanceof TabPanel) { - this._unorderedPanels().delete(child); - this._unorderedPanels.set(new Set(this._unorderedPanels())); - } + findTabPanel(value?: string) { + return value ? this._tabPanelsList().find(panel => panel.value() === value) : undefined; } }