Skip to content

Commit 1819d30

Browse files
committed
fix(material/tabs): allow focus on disabled tabs (#26397)
According to the WCAG best practices focus should be allowed on disabled tabs (see https://w3c.github.io/aria-practices/#kbd_disabled_controls). These changes update our tabs to be in line with the recommendation. Fixes #26395. (cherry picked from commit bd07f7b)
1 parent e536ce0 commit 1819d30

File tree

10 files changed

+96
-63
lines changed

10 files changed

+96
-63
lines changed

src/material/legacy-tabs/_tabs-theme.scss

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
@each $name, $color in $theme-colors {
6161
// Set the foreground color of the tabs
6262
&.mat-#{$name} {
63-
@include _label-focus-color($color);
63+
@include _label-focus-color($foreground, $color);
6464
@include _ink-bar-color($color);
6565

6666
// Override ink bar when background color is the same
@@ -75,7 +75,7 @@
7575
@each $name, $color in $theme-colors {
7676
// Set background color of the tabs and override focus color
7777
&.mat-background-#{$name} {
78-
@include _label-focus-color($color);
78+
@include _label-focus-color($foreground, $color);
7979
@include _tabs-background($color);
8080
}
8181
}
@@ -88,13 +88,15 @@
8888
}
8989
}
9090

91-
@mixin _label-focus-color($tab-focus-color) {
91+
@mixin _label-focus-color($foreground, $tab-focus-color) {
9292
.mat-tab-label,
9393
.mat-tab-link {
9494
&.cdk-keyboard-focused,
9595
&.cdk-program-focused {
96-
&:not(.mat-tab-disabled) {
97-
background-color: theming.get-color-from-palette($tab-focus-color, lighter, 0.3);
96+
background-color: theming.get-color-from-palette($tab-focus-color, lighter, 0.3);
97+
98+
&.mat-tab-disabled {
99+
background-color: theming.get-color-from-palette($foreground, disabled, 0.1);
98100
}
99101
}
100102
}

src/material/legacy-tabs/tab-group.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
cdkMonitorElementFocus
99
*ngFor="let tab of _tabs; let i = index"
1010
[id]="_getTabLabelId(i)"
11-
[attr.tabIndex]="_getTabIndex(tab, i)"
11+
[attr.tabIndex]="_getTabIndex(i)"
1212
[attr.aria-posinset]="i + 1"
1313
[attr.aria-setsize]="_tabs.length"
1414
[attr.aria-controls]="_getTabContentId(i)"

src/material/legacy-tabs/tab-header.spec.ts

Lines changed: 32 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -74,35 +74,32 @@ describe('MatTabHeader', () => {
7474
expect(appComponent.tabHeader.focusIndex).toBe(2);
7575
});
7676

77-
it('should not set focus a disabled tab', () => {
77+
it('should be able to focus a disabled tab', () => {
7878
appComponent.tabHeader.focusIndex = 0;
7979
fixture.detectChanges();
8080
expect(appComponent.tabHeader.focusIndex).toBe(0);
8181

82-
// Set focus on the disabled tab, but focus should remain 0
8382
appComponent.tabHeader.focusIndex = appComponent.disabledTabIndex;
8483
fixture.detectChanges();
85-
expect(appComponent.tabHeader.focusIndex).toBe(0);
84+
expect(appComponent.tabHeader.focusIndex).toBe(appComponent.disabledTabIndex);
8685
});
8786

88-
it('should move focus right and skip disabled tabs', () => {
87+
it('should move focus right including over disabled tabs', () => {
8988
appComponent.tabHeader.focusIndex = 0;
9089
fixture.detectChanges();
9190
expect(appComponent.tabHeader.focusIndex).toBe(0);
9291

93-
// Move focus right, verify that the disabled tab is 1 and should be skipped
9492
expect(appComponent.disabledTabIndex).toBe(1);
9593
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
9694
fixture.detectChanges();
97-
expect(appComponent.tabHeader.focusIndex).toBe(2);
95+
expect(appComponent.tabHeader.focusIndex).toBe(1);
9896

99-
// Move focus right to index 3
10097
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
10198
fixture.detectChanges();
102-
expect(appComponent.tabHeader.focusIndex).toBe(3);
99+
expect(appComponent.tabHeader.focusIndex).toBe(2);
103100
});
104101

105-
it('should move focus left and skip disabled tabs', () => {
102+
it('should move focus left including over disabled tabs', () => {
106103
appComponent.tabHeader.focusIndex = 3;
107104
fixture.detectChanges();
108105
expect(appComponent.tabHeader.focusIndex).toBe(3);
@@ -112,31 +109,47 @@ describe('MatTabHeader', () => {
112109
fixture.detectChanges();
113110
expect(appComponent.tabHeader.focusIndex).toBe(2);
114111

115-
// Move focus left, verify that the disabled tab is 1 and should be skipped
116112
expect(appComponent.disabledTabIndex).toBe(1);
117113
dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW);
118114
fixture.detectChanges();
119-
expect(appComponent.tabHeader.focusIndex).toBe(0);
115+
expect(appComponent.tabHeader.focusIndex).toBe(1);
120116
});
121117

122118
it('should support key down events to move and select focus', () => {
123119
appComponent.tabHeader.focusIndex = 0;
124120
fixture.detectChanges();
125121
expect(appComponent.tabHeader.focusIndex).toBe(0);
126122

123+
// Move focus right to 1
124+
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
125+
fixture.detectChanges();
126+
expect(appComponent.tabHeader.focusIndex).toBe(1);
127+
128+
// Try to select 1. Should not work since it's disabled.
129+
expect(appComponent.selectedIndex).toBe(0);
130+
const firstEnterEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', ENTER);
131+
fixture.detectChanges();
132+
expect(appComponent.selectedIndex).toBe(0);
133+
expect(firstEnterEvent.defaultPrevented).toBe(false);
134+
127135
// Move focus right to 2
128136
dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW);
129137
fixture.detectChanges();
130138
expect(appComponent.tabHeader.focusIndex).toBe(2);
131139

132-
// Select the focused index 2
140+
// Select 2 which is enabled.
133141
expect(appComponent.selectedIndex).toBe(0);
134-
const enterEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', ENTER);
142+
const secondEnterEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', ENTER);
135143
fixture.detectChanges();
136144
expect(appComponent.selectedIndex).toBe(2);
137-
expect(enterEvent.defaultPrevented).toBe(true);
145+
expect(secondEnterEvent.defaultPrevented).toBe(true);
146+
147+
// Move focus left to 1
148+
dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW);
149+
fixture.detectChanges();
150+
expect(appComponent.tabHeader.focusIndex).toBe(1);
138151

139-
// Move focus right to 0
152+
// Move again to 0
140153
dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW);
141154
fixture.detectChanges();
142155
expect(appComponent.tabHeader.focusIndex).toBe(0);
@@ -174,7 +187,7 @@ describe('MatTabHeader', () => {
174187
expect(event.defaultPrevented).toBe(true);
175188
});
176189

177-
it('should skip disabled items when moving focus using HOME', () => {
190+
it('should focus disabled items when moving focus using HOME', () => {
178191
appComponent.tabHeader.focusIndex = 3;
179192
appComponent.tabs[0].disabled = true;
180193
fixture.detectChanges();
@@ -183,8 +196,7 @@ describe('MatTabHeader', () => {
183196
dispatchKeyboardEvent(tabListContainer, 'keydown', HOME);
184197
fixture.detectChanges();
185198

186-
// Note that the second tab is disabled by default already.
187-
expect(appComponent.tabHeader.focusIndex).toBe(2);
199+
expect(appComponent.tabHeader.focusIndex).toBe(0);
188200
});
189201

190202
it('should move focus to the last tab when pressing END', () => {
@@ -199,7 +211,7 @@ describe('MatTabHeader', () => {
199211
expect(event.defaultPrevented).toBe(true);
200212
});
201213

202-
it('should skip disabled items when moving focus using END', () => {
214+
it('should focus disabled items when moving focus using END', () => {
203215
appComponent.tabHeader.focusIndex = 0;
204216
appComponent.tabs[3].disabled = true;
205217
fixture.detectChanges();
@@ -208,7 +220,7 @@ describe('MatTabHeader', () => {
208220
dispatchKeyboardEvent(tabListContainer, 'keydown', END);
209221
fixture.detectChanges();
210222

211-
expect(appComponent.tabHeader.focusIndex).toBe(2);
223+
expect(appComponent.tabHeader.focusIndex).toBe(3);
212224
});
213225

214226
it('should not do anything if a modifier key is pressed', () => {

src/material/tabs/_tabs-theme.scss

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
$primary: theming.get-color-from-palette(map.get($config, primary));
1717
$accent: theming.get-color-from-palette(map.get($config, accent));
1818
$warn: theming.get-color-from-palette(map.get($config, warn));
19+
$foreground: map.get($config, foreground);
1920

2021
@include mdc-helpers.using-mdc-theme($config) {
2122
.mat-mdc-tab, .mat-mdc-tab-link {
@@ -27,6 +28,13 @@
2728
// Disable for now for backwards compatibility. These styles are inside the theme in order
2829
// to avoid CSS specificity issues.
2930
background-color: transparent;
31+
32+
&.mat-mdc-tab-disabled {
33+
.mdc-tab__ripple::before,
34+
.mat-ripple-element {
35+
background-color: theming.get-color-from-palette($foreground, disabled);
36+
}
37+
}
3038
}
3139

3240
@include _palette-styles($primary);

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

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,9 @@ export abstract class MatPaginatedTabHeader
215215
this._keyManager = new FocusKeyManager<MatPaginatedTabHeaderItem>(this._items)
216216
.withHorizontalOrientation(this._getLayoutDirection())
217217
.withHomeAndEnd()
218-
.withWrap();
218+
.withWrap()
219+
// Allow focus to land on disabled tabs, as per https://w3c.github.io/aria-practices/#kbd_disabled_controls
220+
.skipPredicate(() => false);
219221

220222
this._keyManager.updateActiveItem(this._selectedIndex);
221223

@@ -329,8 +331,12 @@ export abstract class MatPaginatedTabHeader
329331
case ENTER:
330332
case SPACE:
331333
if (this.focusIndex !== this.selectedIndex) {
332-
this.selectFocusedIndex.emit(this.focusIndex);
333-
this._itemSelected(event);
334+
const item = this._items.get(this.focusIndex);
335+
336+
if (item && !item.disabled) {
337+
this.selectFocusedIndex.emit(this.focusIndex);
338+
this._itemSelected(event);
339+
}
334340
}
335341
break;
336342
default:
@@ -392,12 +398,7 @@ export abstract class MatPaginatedTabHeader
392398
* providing a valid index and return true.
393399
*/
394400
_isValidIndex(index: number): boolean {
395-
if (!this._items) {
396-
return true;
397-
}
398-
399-
const tab = this._items ? this._items.toArray()[index] : null;
400-
return !!tab && !tab.disabled;
401+
return this._items ? !!this._items.toArray()[index] : true;
401402
}
402403

403404
/**

src/material/tabs/tab-group.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
cdkMonitorElementFocus
1212
*ngFor="let tab of _tabs; let i = index"
1313
[id]="_getTabLabelId(i)"
14-
[attr.tabIndex]="_getTabIndex(tab, i)"
14+
[attr.tabIndex]="_getTabIndex(i)"
1515
[attr.aria-posinset]="i + 1"
1616
[attr.aria-setsize]="_tabs.length"
1717
[attr.aria-controls]="_getTabContentId(i)"

src/material/tabs/tab-group.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
// MDC doesn't support disabled tabs so we need to improvise.
1717
.mat-mdc-tab-disabled {
1818
opacity: 0.4;
19-
pointer-events: none;
2019
}
2120

2221
.mat-mdc-tab-group {

src/material/tabs/tab-group.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -477,16 +477,15 @@ export abstract class _MatTabGroupBase
477477

478478
/** Handle click events, setting new selected index if appropriate. */
479479
_handleClick(tab: MatTab, tabHeader: MatTabGroupBaseHeader, index: number) {
480+
tabHeader.focusIndex = index;
481+
480482
if (!tab.disabled) {
481-
this.selectedIndex = tabHeader.focusIndex = index;
483+
this.selectedIndex = index;
482484
}
483485
}
484486

485487
/** Retrieves the tabindex for the tab. */
486-
_getTabIndex(tab: MatTab, index: number): number | null {
487-
if (tab.disabled) {
488-
return null;
489-
}
488+
_getTabIndex(index: number): number {
490489
const targetIndex = this._lastFocusedTabIndex ?? this.selectedIndex;
491490
return index === targetIndex ? 0 : -1;
492491
}

0 commit comments

Comments
 (0)