diff --git a/goldens/material/tabs/index.api.md b/goldens/material/tabs/index.api.md index 27b38e5a595d..7b8ba16d046a 100644 --- a/goldens/material/tabs/index.api.md +++ b/goldens/material/tabs/index.api.md @@ -15,6 +15,7 @@ import { Direction } from '@angular/cdk/bidi'; import { ElementRef } from '@angular/core'; import { EventEmitter } from '@angular/core'; import { FocusableOption } from '@angular/cdk/a11y'; +import { FocusKeyManager } from '@angular/cdk/a11y'; import { FocusOrigin } from '@angular/cdk/a11y'; import * as i0 from '@angular/core'; import * as i1 from '@angular/cdk/bidi'; @@ -98,6 +99,7 @@ export abstract class MatPaginatedTabHeader implements AfterContentChecked, Afte // (undocumented) abstract _items: QueryList; protected abstract _itemSelected(event: KeyboardEvent): void; + protected _keyManager: FocusKeyManager | undefined; // (undocumented) abstract _nextPaginator: ElementRef; // (undocumented) @@ -412,8 +414,6 @@ export class MatTabLink extends InkBarItem implements AfterViewInit, OnDestroy, // (undocumented) _getRole(): string | null; // (undocumented) - _getTabIndex(): number; - // (undocumented) _handleFocus(): void; // (undocumented) _handleKeydown(event: KeyboardEvent): void; @@ -436,6 +436,8 @@ export class MatTabLink extends InkBarItem implements AfterViewInit, OnDestroy, // (undocumented) tabIndex: number; // (undocumented) + protected _tabIndex: i0.Signal; + // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; @@ -456,8 +458,12 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit // (undocumented) _fitInkBarToContent: BehaviorSubject; // (undocumented) + _focusedItem: i0.WritableSignal; + // (undocumented) _getRole(): string | null; // (undocumented) + _hasFocus(link: MatTabLink): boolean; + // (undocumented) _inkBar: MatInkBar; _items: QueryList; // (undocumented) diff --git a/src/material/tabs/paginated-tab-header.ts b/src/material/tabs/paginated-tab-header.ts index 6dbde9e09556..407b49418c48 100644 --- a/src/material/tabs/paginated-tab-header.ts +++ b/src/material/tabs/paginated-tab-header.ts @@ -119,7 +119,7 @@ export abstract class MatPaginatedTabHeader private _scrollDistanceChanged: boolean; /** Used to manage focus between the tabs. */ - private _keyManager: FocusKeyManager; + protected _keyManager: FocusKeyManager | undefined; /** Cached text content of the header. */ private _currentTextContent: string; @@ -218,7 +218,9 @@ export abstract class MatPaginatedTabHeader // Allow focus to land on disabled tabs, as per https://w3c.github.io/aria-practices/#kbd_disabled_controls .skipPredicate(() => false); - this._keyManager.updateActiveItem(this._selectedIndex); + // Fall back to the first link as being active if there isn't a selected one. + // This is relevant primarily for the tab nav bar. + this._keyManager.updateActiveItem(Math.max(this._selectedIndex, 0)); // Note: We do not need to realign after the first render for proper functioning of the tabs // the resize events above should fire when we first start observing the element. However, @@ -243,7 +245,7 @@ export abstract class MatPaginatedTabHeader realign(); }); }); - this._keyManager.withHorizontalOrientation(this._getLayoutDirection()); + this._keyManager?.withHorizontalOrientation(this._getLayoutDirection()); }); // If there is a change in the focus key manager we need to emit the `indexFocused` @@ -339,7 +341,7 @@ export abstract class MatPaginatedTabHeader } break; default: - this._keyManager.onKeydown(event); + this._keyManager?.onKeydown(event); } } diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts index 51e2aad7cd41..beb385d71d4e 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -107,24 +107,6 @@ describe('MatTabNavBar', () => { .toBe(true); }); - it('should update the tabindex if links are disabled', () => { - const tabLinkElements = fixture.debugElement - .queryAll(By.css('a')) - .map(tabLinkDebugEl => tabLinkDebugEl.nativeElement); - - expect(tabLinkElements.map(tabLink => tabLink.tabIndex)) - .withContext('Expected first element to be keyboard focusable by default') - .toEqual([0, -1, -1]); - - fixture.componentInstance.disabled = true; - fixture.changeDetectorRef.markForCheck(); - fixture.detectChanges(); - - expect(tabLinkElements.every(tabLink => tabLink.tabIndex === -1)) - .withContext('Expected element to no longer be keyboard focusable if disabled.') - .toBe(true); - }); - it('should mark disabled links', () => { const tabLinkElement = fixture.debugElement.query(By.css('a')).nativeElement; @@ -303,6 +285,15 @@ describe('MatTabNavBar', () => { expect(tabLinks[2].tabIndex).toBe(-1); }); + it('should set a tabindex even if the only tab is disabled', () => { + const fixture = TestBed.createComponent(TabBarWithDisabledTabOnInit); + fixture.detectChanges(); + + const tab: HTMLElement = fixture.nativeElement.querySelector('.mat-mdc-tab-link'); + expect(tab.getAttribute('aria-disabled')).toBe('true'); + expect(tab.tabIndex).toBe(0); + }); + it('should setup aria-controls properly', () => { const fixture = TestBed.createComponent(SimpleTabNavBarTestApp); fixture.detectChanges(); @@ -642,3 +633,14 @@ class TabBarWithInactiveTabsOnInit { class TabsWithCustomAnimationDuration { links = ['First', 'Second', 'Third']; } + +@Component({ + template: ` + + Tab panel + `, + imports: [MatTabsModule], +}) +class TabBarWithDisabledTabOnInit {} diff --git a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts index 2ce296e3a663..b7bf3ed31799 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts @@ -22,6 +22,8 @@ import { ViewEncapsulation, inject, HostAttributeToken, + signal, + computed, } from '@angular/core'; import { MAT_RIPPLE_GLOBAL_OPTIONS, @@ -39,7 +41,7 @@ import {BehaviorSubject, Subject} from 'rxjs'; import {startWith, takeUntil} from 'rxjs/operators'; import {ENTER, SPACE} from '@angular/cdk/keycodes'; import {MAT_TABS_CONFIG, MatTabsConfig} from '../tab-config'; -import {MatPaginatedTabHeader} from '../paginated-tab-header'; +import {MatPaginatedTabHeader, MatPaginatedTabHeaderItem} from '../paginated-tab-header'; import {CdkObserveContent} from '@angular/cdk/observers'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; @@ -70,6 +72,8 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; imports: [MatRipple, CdkObserveContent], }) export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit, AfterViewInit { + _focusedItem = signal(null); + /** Whether the ink bar should fit its width to the size of the tab label content. */ @Input({transform: booleanAttribute}) get fitInkBarToContent(): boolean { @@ -183,6 +187,11 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit .subscribe(() => this.updateActiveLink()); super.ngAfterContentInit(); + + // Turn the `change` stream into a signal to try and avoid "changed after checked" errors. + this._keyManager!.change.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() => + this._focusedItem.set(this._keyManager?.activeItem || null), + ); } override ngAfterViewInit() { @@ -203,12 +212,13 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit for (let i = 0; i < items.length; i++) { if (items[i].active) { this.selectedIndex = i; - this._changeDetectorRef.markForCheck(); - if (this.tabPanel) { this.tabPanel._activeTabId = items[i].id; } - + // Updating the `selectedIndex` won't trigger the `change` event on + // the key manager so we need to set the signal from here. + this._focusedItem.set(items[i]); + this._changeDetectorRef.markForCheck(); return; } } @@ -219,6 +229,10 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit _getRole(): string | null { return this.tabPanel ? 'tablist' : this._elementRef.nativeElement.getAttribute('role'); } + + _hasFocus(link: MatTabLink): boolean { + return this._keyManager?.activeItem === link; + } } /** @@ -238,7 +252,7 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit '[attr.aria-disabled]': 'disabled', '[attr.aria-selected]': '_getAriaSelected()', '[attr.id]': 'id', - '[attr.tabIndex]': '_getTabIndex()', + '[attr.tabIndex]': '_tabIndex()', '[attr.role]': '_getRole()', '[class.mat-mdc-tab-disabled]': 'disabled', '[class.mdc-tab--active]': 'active', @@ -260,6 +274,10 @@ export class MatTabLink /** Whether the tab link is active or not. */ protected _isActive: boolean = false; + protected _tabIndex = computed(() => + this._tabNavBar._focusedItem() === this ? this.tabIndex : -1, + ); + /** Whether the link is active. */ @Input({transform: booleanAttribute}) get active(): boolean { @@ -393,14 +411,6 @@ export class MatTabLink _getRole(): string | null { return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role'); } - - _getTabIndex(): number { - if (this._tabNavBar.tabPanel) { - return this._isActive && !this.disabled ? 0 : -1; - } else { - return this.disabled ? -1 : this.tabIndex; - } - } } /**