Skip to content

fix(material/tabs): avoid not having any focusable tabs #31144

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions goldens/material/tabs/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -98,6 +99,7 @@ export abstract class MatPaginatedTabHeader implements AfterContentChecked, Afte
// (undocumented)
abstract _items: QueryList<MatPaginatedTabHeaderItem>;
protected abstract _itemSelected(event: KeyboardEvent): void;
protected _keyManager: FocusKeyManager<MatPaginatedTabHeaderItem> | undefined;
// (undocumented)
abstract _nextPaginator: ElementRef<HTMLElement>;
// (undocumented)
Expand Down Expand Up @@ -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;
Expand All @@ -436,6 +436,8 @@ export class MatTabLink extends InkBarItem implements AfterViewInit, OnDestroy,
// (undocumented)
tabIndex: number;
// (undocumented)
protected _tabIndex: i0.Signal<number>;
// (undocumented)
static ɵcmp: i0.ɵɵComponentDeclaration<MatTabLink, "[mat-tab-link], [matTabLink]", ["matTabLink"], { "active": { "alias": "active"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "disableRipple": { "alias": "disableRipple"; "required": false; }; "tabIndex": { "alias": "tabIndex"; "required": false; }; "id": { "alias": "id"; "required": false; }; }, {}, never, ["*"], true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<MatTabLink, never>;
Expand All @@ -456,8 +458,12 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit
// (undocumented)
_fitInkBarToContent: BehaviorSubject<boolean>;
// (undocumented)
_focusedItem: i0.WritableSignal<MatPaginatedTabHeaderItem | null>;
// (undocumented)
_getRole(): string | null;
// (undocumented)
_hasFocus(link: MatTabLink): boolean;
// (undocumented)
_inkBar: MatInkBar;
_items: QueryList<MatTabLink>;
// (undocumented)
Expand Down
10 changes: 6 additions & 4 deletions src/material/tabs/paginated-tab-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export abstract class MatPaginatedTabHeader
private _scrollDistanceChanged: boolean;

/** Used to manage focus between the tabs. */
private _keyManager: FocusKeyManager<MatPaginatedTabHeaderItem>;
protected _keyManager: FocusKeyManager<MatPaginatedTabHeaderItem> | undefined;

/** Cached text content of the header. */
private _currentTextContent: string;
Expand Down Expand Up @@ -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,
Expand All @@ -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`
Expand Down Expand Up @@ -339,7 +341,7 @@ export abstract class MatPaginatedTabHeader
}
break;
default:
this._keyManager.onKeydown(event);
this._keyManager?.onKeydown(event);
}
}

Expand Down
38 changes: 20 additions & 18 deletions src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -642,3 +633,14 @@ class TabBarWithInactiveTabsOnInit {
class TabsWithCustomAnimationDuration {
links = ['First', 'Second', 'Third'];
}

@Component({
template: `
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
<a mat-tab-link disabled>Hello</a>
</nav>
<mat-tab-nav-panel #tabPanel>Tab panel</mat-tab-nav-panel>
`,
imports: [MatTabsModule],
})
class TabBarWithDisabledTabOnInit {}
36 changes: 23 additions & 13 deletions src/material/tabs/tab-nav-bar/tab-nav-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import {
ViewEncapsulation,
inject,
HostAttributeToken,
signal,
computed,
} from '@angular/core';
import {
MAT_RIPPLE_GLOBAL_OPTIONS,
Expand All @@ -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';

Expand Down Expand Up @@ -70,6 +72,8 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
imports: [MatRipple, CdkObserveContent],
})
export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit, AfterViewInit {
_focusedItem = signal<MatPaginatedTabHeaderItem | null>(null);

/** Whether the ink bar should fit its width to the size of the tab label content. */
@Input({transform: booleanAttribute})
get fitInkBarToContent(): boolean {
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
}
}
Expand All @@ -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;
}
}

/**
Expand All @@ -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',
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}
}
}

/**
Expand Down
Loading