From 3656dd3c9e905555a9870844c33c2e11d58917c3 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 19 May 2025 14:43:51 +0200 Subject: [PATCH 1/2] fix(material/tabs): avoid not having any focusable tabs In #26397 we made it possible to navigate to disabled tabs with the keyboard, however in the tabs nav bar they're still set to `tabindex="-1"` unless they're active. This means that if the nav bar only has disabled tabs, there's no way to enter it. These changes make it so the tab index is based on the focus index which ensures that at least one tab is always focusable. It also aligns the behavior with `mat-tab-group`. --- goldens/material/tabs/index.api.md | 4 ++ src/material/tabs/paginated-tab-header.ts | 10 +++-- .../tabs/tab-nav-bar/tab-nav-bar.spec.ts | 38 ++++++++++--------- src/material/tabs/tab-nav-bar/tab-nav-bar.ts | 10 ++--- 4 files changed, 35 insertions(+), 27 deletions(-) diff --git a/goldens/material/tabs/index.api.md b/goldens/material/tabs/index.api.md index 27b38e5a595d..95a7d19c9298 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) @@ -458,6 +460,8 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit // (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..d51b1c86f2ac 100644 --- a/src/material/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/material/tabs/tab-nav-bar/tab-nav-bar.ts @@ -219,6 +219,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; + } } /** @@ -395,11 +399,7 @@ export class MatTabLink } _getTabIndex(): number { - if (this._tabNavBar.tabPanel) { - return this._isActive && !this.disabled ? 0 : -1; - } else { - return this.disabled ? -1 : this.tabIndex; - } + return this._tabNavBar._hasFocus(this) ? this.tabIndex : -1; } } From ef0e2d0b79b3e5c7e3742a38183d4442aeff6143 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 19 May 2025 15:03:02 +0200 Subject: [PATCH 2/2] fixup! fix(material/tabs): avoid not having any focusable tabs --- goldens/material/tabs/index.api.md | 6 +++-- src/material/tabs/tab-nav-bar/tab-nav-bar.ts | 28 +++++++++++++------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/goldens/material/tabs/index.api.md b/goldens/material/tabs/index.api.md index 95a7d19c9298..7b8ba16d046a 100644 --- a/goldens/material/tabs/index.api.md +++ b/goldens/material/tabs/index.api.md @@ -414,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; @@ -438,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; @@ -458,6 +458,8 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit // (undocumented) _fitInkBarToContent: BehaviorSubject; // (undocumented) + _focusedItem: i0.WritableSignal; + // (undocumented) _getRole(): string | null; // (undocumented) _hasFocus(link: MatTabLink): boolean; 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 d51b1c86f2ac..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; } } @@ -242,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', @@ -264,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 { @@ -397,10 +411,6 @@ export class MatTabLink _getRole(): string | null { return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role'); } - - _getTabIndex(): number { - return this._tabNavBar._hasFocus(this) ? this.tabIndex : -1; - } } /**