Skip to content

Commit 33d8f33

Browse files
committed
fix(material/tabs): avoid not having any focusable tabs (#31144)
* 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`. * fixup! fix(material/tabs): avoid not having any focusable tabs (cherry picked from commit 98065ce)
1 parent f407aef commit 33d8f33

File tree

4 files changed

+57
-37
lines changed

4 files changed

+57
-37
lines changed

goldens/material/tabs/index.api.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { Direction } from '@angular/cdk/bidi';
1515
import { ElementRef } from '@angular/core';
1616
import { EventEmitter } from '@angular/core';
1717
import { FocusableOption } from '@angular/cdk/a11y';
18+
import { FocusKeyManager } from '@angular/cdk/a11y';
1819
import { FocusOrigin } from '@angular/cdk/a11y';
1920
import * as i0 from '@angular/core';
2021
import * as i1 from '@angular/cdk/bidi';
@@ -98,6 +99,7 @@ export abstract class MatPaginatedTabHeader implements AfterContentChecked, Afte
9899
// (undocumented)
99100
abstract _items: QueryList<MatPaginatedTabHeaderItem>;
100101
protected abstract _itemSelected(event: KeyboardEvent): void;
102+
protected _keyManager: FocusKeyManager<MatPaginatedTabHeaderItem> | undefined;
101103
// (undocumented)
102104
abstract _nextPaginator: ElementRef<HTMLElement>;
103105
// (undocumented)
@@ -412,8 +414,6 @@ export class MatTabLink extends InkBarItem implements AfterViewInit, OnDestroy,
412414
// (undocumented)
413415
_getRole(): string | null;
414416
// (undocumented)
415-
_getTabIndex(): number;
416-
// (undocumented)
417417
_handleFocus(): void;
418418
// (undocumented)
419419
_handleKeydown(event: KeyboardEvent): void;
@@ -436,6 +436,8 @@ export class MatTabLink extends InkBarItem implements AfterViewInit, OnDestroy,
436436
// (undocumented)
437437
tabIndex: number;
438438
// (undocumented)
439+
protected _tabIndex: i0.Signal<number>;
440+
// (undocumented)
439441
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>;
440442
// (undocumented)
441443
static ɵfac: i0.ɵɵFactoryDeclaration<MatTabLink, never>;
@@ -456,8 +458,12 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit
456458
// (undocumented)
457459
_fitInkBarToContent: BehaviorSubject<boolean>;
458460
// (undocumented)
461+
_focusedItem: i0.WritableSignal<MatPaginatedTabHeaderItem | null>;
462+
// (undocumented)
459463
_getRole(): string | null;
460464
// (undocumented)
465+
_hasFocus(link: MatTabLink): boolean;
466+
// (undocumented)
461467
_inkBar: MatInkBar;
462468
_items: QueryList<MatTabLink>;
463469
// (undocumented)

src/material/tabs/paginated-tab-header.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export abstract class MatPaginatedTabHeader
119119
private _scrollDistanceChanged: boolean;
120120

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

124124
/** Cached text content of the header. */
125125
private _currentTextContent: string;
@@ -220,7 +220,9 @@ export abstract class MatPaginatedTabHeader
220220
// Allow focus to land on disabled tabs, as per https://w3c.github.io/aria-practices/#kbd_disabled_controls
221221
.skipPredicate(() => false);
222222

223-
this._keyManager.updateActiveItem(this._selectedIndex);
223+
// Fall back to the first link as being active if there isn't a selected one.
224+
// This is relevant primarily for the tab nav bar.
225+
this._keyManager.updateActiveItem(Math.max(this._selectedIndex, 0));
224226

225227
// Note: We do not need to realign after the first render for proper functioning of the tabs
226228
// the resize events above should fire when we first start observing the element. However,
@@ -245,7 +247,7 @@ export abstract class MatPaginatedTabHeader
245247
realign();
246248
});
247249
});
248-
this._keyManager.withHorizontalOrientation(this._getLayoutDirection());
250+
this._keyManager?.withHorizontalOrientation(this._getLayoutDirection());
249251
});
250252

251253
// If there is a change in the focus key manager we need to emit the `indexFocused`
@@ -341,7 +343,7 @@ export abstract class MatPaginatedTabHeader
341343
}
342344
break;
343345
default:
344-
this._keyManager.onKeydown(event);
346+
this._keyManager?.onKeydown(event);
345347
}
346348
}
347349

src/material/tabs/tab-nav-bar/tab-nav-bar.spec.ts

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -97,24 +97,6 @@ describe('MatTabNavBar', () => {
9797
.toBe(true);
9898
});
9999

100-
it('should update the tabindex if links are disabled', () => {
101-
const tabLinkElements = fixture.debugElement
102-
.queryAll(By.css('a'))
103-
.map(tabLinkDebugEl => tabLinkDebugEl.nativeElement);
104-
105-
expect(tabLinkElements.map(tabLink => tabLink.tabIndex))
106-
.withContext('Expected first element to be keyboard focusable by default')
107-
.toEqual([0, -1, -1]);
108-
109-
fixture.componentInstance.disabled = true;
110-
fixture.changeDetectorRef.markForCheck();
111-
fixture.detectChanges();
112-
113-
expect(tabLinkElements.every(tabLink => tabLink.tabIndex === -1))
114-
.withContext('Expected element to no longer be keyboard focusable if disabled.')
115-
.toBe(true);
116-
});
117-
118100
it('should mark disabled links', () => {
119101
const tabLinkElement = fixture.debugElement.query(By.css('a')).nativeElement;
120102

@@ -293,6 +275,15 @@ describe('MatTabNavBar', () => {
293275
expect(tabLinks[2].tabIndex).toBe(-1);
294276
});
295277

278+
it('should set a tabindex even if the only tab is disabled', () => {
279+
const fixture = TestBed.createComponent(TabBarWithDisabledTabOnInit);
280+
fixture.detectChanges();
281+
282+
const tab: HTMLElement = fixture.nativeElement.querySelector('.mat-mdc-tab-link');
283+
expect(tab.getAttribute('aria-disabled')).toBe('true');
284+
expect(tab.tabIndex).toBe(0);
285+
});
286+
296287
it('should setup aria-controls properly', () => {
297288
const fixture = TestBed.createComponent(SimpleTabNavBarTestApp);
298289
fixture.detectChanges();
@@ -632,3 +623,14 @@ class TabBarWithInactiveTabsOnInit {
632623
class TabsWithCustomAnimationDuration {
633624
links = ['First', 'Second', 'Third'];
634625
}
626+
627+
@Component({
628+
template: `
629+
<nav mat-tab-nav-bar [tabPanel]="tabPanel">
630+
<a mat-tab-link disabled>Hello</a>
631+
</nav>
632+
<mat-tab-nav-panel #tabPanel>Tab panel</mat-tab-nav-panel>
633+
`,
634+
imports: [MatTabsModule],
635+
})
636+
class TabBarWithDisabledTabOnInit {}

src/material/tabs/tab-nav-bar/tab-nav-bar.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import {
2525
ANIMATION_MODULE_TYPE,
2626
inject,
2727
HostAttributeToken,
28+
signal,
29+
computed,
2830
} from '@angular/core';
2931
import {
3032
MAT_RIPPLE_GLOBAL_OPTIONS,
@@ -44,7 +46,7 @@ import {BehaviorSubject, Subject} from 'rxjs';
4446
import {startWith, takeUntil} from 'rxjs/operators';
4547
import {ENTER, SPACE} from '@angular/cdk/keycodes';
4648
import {MAT_TABS_CONFIG, MatTabsConfig} from '../tab-config';
47-
import {MatPaginatedTabHeader} from '../paginated-tab-header';
49+
import {MatPaginatedTabHeader, MatPaginatedTabHeaderItem} from '../paginated-tab-header';
4850
import {CdkObserveContent} from '@angular/cdk/observers';
4951
import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
5052

@@ -75,6 +77,8 @@ import {_CdkPrivateStyleLoader} from '@angular/cdk/private';
7577
imports: [MatRipple, CdkObserveContent],
7678
})
7779
export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit, AfterViewInit {
80+
_focusedItem = signal<MatPaginatedTabHeaderItem | null>(null);
81+
7882
/** Whether the ink bar should fit its width to the size of the tab label content. */
7983
@Input({transform: booleanAttribute})
8084
get fitInkBarToContent(): boolean {
@@ -195,6 +199,11 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit
195199
.subscribe(() => this.updateActiveLink());
196200

197201
super.ngAfterContentInit();
202+
203+
// Turn the `change` stream into a signal to try and avoid "changed after checked" errors.
204+
this._keyManager!.change.pipe(startWith(null), takeUntil(this._destroyed)).subscribe(() =>
205+
this._focusedItem.set(this._keyManager?.activeItem || null),
206+
);
198207
}
199208

200209
override ngAfterViewInit() {
@@ -215,12 +224,13 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit
215224
for (let i = 0; i < items.length; i++) {
216225
if (items[i].active) {
217226
this.selectedIndex = i;
218-
this._changeDetectorRef.markForCheck();
219-
220227
if (this.tabPanel) {
221228
this.tabPanel._activeTabId = items[i].id;
222229
}
223-
230+
// Updating the `selectedIndex` won't trigger the `change` event on
231+
// the key manager so we need to set the signal from here.
232+
this._focusedItem.set(items[i]);
233+
this._changeDetectorRef.markForCheck();
224234
return;
225235
}
226236
}
@@ -231,6 +241,10 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit
231241
_getRole(): string | null {
232242
return this.tabPanel ? 'tablist' : this._elementRef.nativeElement.getAttribute('role');
233243
}
244+
245+
_hasFocus(link: MatTabLink): boolean {
246+
return this._keyManager?.activeItem === link;
247+
}
234248
}
235249

236250
/**
@@ -250,7 +264,7 @@ export class MatTabNav extends MatPaginatedTabHeader implements AfterContentInit
250264
'[attr.aria-disabled]': 'disabled',
251265
'[attr.aria-selected]': '_getAriaSelected()',
252266
'[attr.id]': 'id',
253-
'[attr.tabIndex]': '_getTabIndex()',
267+
'[attr.tabIndex]': '_tabIndex()',
254268
'[attr.role]': '_getRole()',
255269
'[class.mat-mdc-tab-disabled]': 'disabled',
256270
'[class.mdc-tab--active]': 'active',
@@ -272,6 +286,10 @@ export class MatTabLink
272286
/** Whether the tab link is active or not. */
273287
protected _isActive: boolean = false;
274288

289+
protected _tabIndex = computed(() =>
290+
this._tabNavBar._focusedItem() === this ? this.tabIndex : -1,
291+
);
292+
275293
/** Whether the link is active. */
276294
@Input({transform: booleanAttribute})
277295
get active(): boolean {
@@ -407,14 +425,6 @@ export class MatTabLink
407425
_getRole(): string | null {
408426
return this._tabNavBar.tabPanel ? 'tab' : this.elementRef.nativeElement.getAttribute('role');
409427
}
410-
411-
_getTabIndex(): number {
412-
if (this._tabNavBar.tabPanel) {
413-
return this._isActive && !this.disabled ? 0 : -1;
414-
} else {
415-
return this.disabled ? -1 : this.tabIndex;
416-
}
417-
}
418428
}
419429

420430
/**

0 commit comments

Comments
 (0)