Skip to content

Commit 6ad47bf

Browse files
authored
fix(material/core): allow keyboard navigation to disabled options (#26745)
Allow keyboard navigation to disabled options of Select and Autocomplete component. Align with WAI ARIA instructions for list options from [Developing a Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/). Fix accessibility issues where some screen readers are not able to navigate to disabled options. The user can focus disabled options using keyboard, but the user cannot click disabled options.
1 parent 75df8fe commit 6ad47bf

File tree

13 files changed

+246
-43
lines changed

13 files changed

+246
-43
lines changed

src/dev-app/autocomplete/autocomplete-demo.html

+41-12
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,40 @@
1111
<input matInput [matAutocomplete]="reactiveAuto" [formControl]="stateCtrl">
1212
</mat-form-field>
1313
<mat-autocomplete #reactiveAuto="matAutocomplete" [displayWith]="displayFn"
14-
[hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator">
15-
<mat-option *ngFor="let state of tempStates" [value]="state">
14+
[hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator"
15+
[autoActiveFirstOption]="reactiveAutoActiveFirstOption">
16+
<mat-option *ngFor="let state of tempStates; let index = index" [value]="state"
17+
[disabled]="reactiveIsStateDisabled(state.index)">
1618
<span>{{ state.name }}</span>
1719
<span class="demo-secondary-text"> ({{ state.code }}) </span>
1820
</mat-option>
1921
</mat-autocomplete>
2022

21-
<mat-card-actions>
23+
<p>
2224
<button mat-button (click)="stateCtrl.reset()">RESET</button>
2325
<button mat-button (click)="stateCtrl.setValue(states[10])">SET VALUE</button>
2426
<button mat-button (click)="stateCtrl.enabled ? stateCtrl.disable() : stateCtrl.enable()">
2527
TOGGLE DISABLED
2628
</button>
27-
</mat-card-actions>
28-
<mat-card-actions>
29+
</p>
30+
<p>
31+
<label for="reactive-disable-state-options">Disable States</label>
32+
<select [(ngModel)]="reactiveDisableStateOption" id="reactive-disable-state-options">
33+
<option value="none">None</option>
34+
<option value="first-middle-last">Disable First, Middle and Last States</option>
35+
<option value="all">Disable All States</option>
36+
</select>
37+
</p>
38+
<p>
2939
<mat-checkbox [(ngModel)]="reactiveHideSingleSelectionIndicator">
3040
Hide Single-Selection Indicator
3141
</mat-checkbox>
32-
</mat-card-actions>
42+
</p>
43+
<p>
44+
<mat-checkbox [(ngModel)]="reactiveAutoActiveFirstOption">
45+
Automatically activate first option
46+
</mat-checkbox>
47+
</p>
3348

3449
</mat-card>
3550

@@ -44,14 +59,16 @@
4459
<input matInput [matAutocomplete]="tdAuto" [(ngModel)]="currentState"
4560
(ngModelChange)="tdStates = filterStates(currentState)" [disabled]="tdDisabled">
4661
<mat-autocomplete #tdAuto="matAutocomplete"
47-
[hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator">
48-
<mat-option *ngFor="let state of tdStates" [value]="state.name">
62+
[hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator"
63+
[autoActiveFirstOption]="templateAutoActiveFirstOption">
64+
<mat-option *ngFor="let state of tdStates" [value]="state.name"
65+
[disabled]="templateIsStateDisabled(state.index)">
4966
<span>{{ state.name }}</span>
5067
</mat-option>
5168
</mat-autocomplete>
5269
</mat-form-field>
5370

54-
<mat-card-actions>
71+
<p>
5572
<button mat-button (click)="modelDir.reset()">RESET</button>
5673
<button mat-button (click)="currentState='California'">SET VALUE</button>
5774
<button mat-button (click)="tdDisabled=!tdDisabled">
@@ -62,12 +79,24 @@
6279
{{theme.name}}
6380
</option>
6481
</select>
65-
</mat-card-actions>
66-
<mat-card-actions>
82+
</p>
83+
<p>
6784
<mat-checkbox [(ngModel)]="templateHideSingleSelectionIndicator">
6885
Hide Single-Selection Indicator
6986
</mat-checkbox>
70-
</mat-card-actions>
87+
<p>
88+
<mat-checkbox [(ngModel)]="templateAutoActiveFirstOption">
89+
Automatically activate first option
90+
</mat-checkbox>
91+
</p>
92+
<p>
93+
<label for="template-disable-state-options">Disable States</label>
94+
<select [(ngModel)]="templateDisableStateOption" id="template-disable-state-options">
95+
<option value="none">None</option>
96+
<option value="first-middle-last">Disable First, Middle and Last States</option>
97+
<option value="all">Disable All States</option>
98+
</select>
99+
</p>
71100

72101
</mat-card>
73102

src/dev-app/autocomplete/autocomplete-demo.ts

+33-3
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,16 @@ import {ThemePalette} from '@angular/material/core';
2121
export interface State {
2222
code: string;
2323
name: string;
24+
index: number;
2425
}
2526

2627
export interface StateGroup {
2728
letter: string;
2829
states: State[];
2930
}
3031

32+
type DisableStateOption = 'none' | 'first-middle-last' | 'all';
33+
3134
@Component({
3235
selector: 'autocomplete-demo',
3336
templateUrl: 'autocomplete-demo.html',
@@ -55,7 +58,6 @@ export class AutocompleteDemo {
5558

5659
tdDisabled = false;
5760
hideSingleSelectionIndicators = false;
58-
5961
reactiveStatesTheme: ThemePalette = 'primary';
6062
templateStatesTheme: ThemePalette = 'primary';
6163

@@ -68,6 +70,12 @@ export class AutocompleteDemo {
6870
reactiveHideSingleSelectionIndicator = false;
6971
templateHideSingleSelectionIndicator = false;
7072

73+
reactiveAutoActiveFirstOption = false;
74+
templateAutoActiveFirstOption = false;
75+
76+
reactiveDisableStateOption: DisableStateOption = 'none';
77+
templateDisableStateOption: DisableStateOption = 'none';
78+
7179
@ViewChild(NgModel) modelDir: NgModel;
7280

7381
groupedStates: StateGroup[];
@@ -123,7 +131,7 @@ export class AutocompleteDemo {
123131
{code: 'WV', name: 'West Virginia'},
124132
{code: 'WI', name: 'Wisconsin'},
125133
{code: 'WY', name: 'Wyoming'},
126-
];
134+
].map((state, index) => ({...state, index}));
127135

128136
constructor() {
129137
this.tdStates = this.states;
@@ -142,7 +150,7 @@ export class AutocompleteDemo {
142150
groups.push(group);
143151
}
144152

145-
group.states.push({code: state.code, name: state.name});
153+
group.states.push({...state});
146154

147155
return groups;
148156
},
@@ -172,4 +180,26 @@ export class AutocompleteDemo {
172180
const filterValue = val.toLowerCase();
173181
return states.filter(state => state.name.toLowerCase().startsWith(filterValue));
174182
}
183+
184+
reactiveIsStateDisabled(index: number) {
185+
return this._isStateDisabled(index, this.reactiveDisableStateOption);
186+
}
187+
188+
templateIsStateDisabled(index: number) {
189+
return this._isStateDisabled(index, this.templateDisableStateOption);
190+
}
191+
192+
private _isStateDisabled(stateIndex: number, disableStateOption: DisableStateOption) {
193+
if (disableStateOption === 'all') {
194+
return true;
195+
}
196+
if (disableStateOption === 'first-middle-last') {
197+
return (
198+
stateIndex === 0 ||
199+
stateIndex === this.states.length - 1 ||
200+
stateIndex === Math.floor(this.states.length / 2)
201+
);
202+
}
203+
return false;
204+
}
175205
}

src/dev-app/select/select-demo.html

+11-3
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@
1212
<mat-label>Drink</mat-label>
1313
<mat-select [(ngModel)]="currentDrink" [required]="drinksRequired"
1414
[disabled]="drinksDisabled" #drinkControl="ngModel">
15-
<mat-option [disabled]="drinksOptionsDisabled">None</mat-option>
16-
<mat-option *ngFor="let drink of drinks" [value]="drink.value" [disabled]="drinksOptionsDisabled">
15+
<mat-option [disabled]="drinksOptionsDisabled === 'all'">None</mat-option>
16+
<mat-option *ngFor="let drink of drinks; let index = index" [value]="drink.value"
17+
[disabled]="isDrinkOptionDisabled(index)">
1718
{{ drink.viewValue }}
1819
</mat-option>
1920
</mat-select>
@@ -48,11 +49,18 @@
4849
</option>
4950
</select>
5051
</p>
52+
<p>
53+
<label for="drinks-disabled-options">Disabled options:</label>
54+
<select [(ngModel)]="drinksOptionsDisabled" id="drinks-disabled-options">
55+
<option value="none">None</option>
56+
<option value="first-middle-last">Disable First, Middle, and Last Options</option>
57+
<option value="all">Disable All Options</option>
58+
</select>
59+
</p>
5160

5261
<button mat-button (click)="currentDrink='water-2'">SET VALUE</button>
5362
<button mat-button (click)="drinksRequired=!drinksRequired">TOGGLE REQUIRED</button>
5463
<button mat-button (click)="drinksDisabled=!drinksDisabled">TOGGLE DISABLED</button>
55-
<button mat-button (click)="drinksOptionsDisabled=!drinksOptionsDisabled">TOGGLE DISABLED OPTIONS</button>
5664
<button mat-button (click)="drinkControl.reset()">RESET</button>
5765
</mat-card-content>
5866
</mat-card>

src/dev-app/select/select-demo.ts

+17-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export class MyErrorStateMatcher implements ErrorStateMatcher {
2828
}
2929
}
3030

31+
type DisableDrinkOption = 'none' | 'first-middle-last' | 'all';
32+
3133
@Component({
3234
selector: 'select-demo',
3335
templateUrl: 'select-demo.html',
@@ -50,7 +52,7 @@ export class SelectDemo {
5052
drinkObjectRequired = false;
5153
pokemonRequired = false;
5254
drinksDisabled = false;
53-
drinksOptionsDisabled = false;
55+
drinksOptionsDisabled: DisableDrinkOption = 'none';
5456
pokemonDisabled = false;
5557
pokemonOptionsDisabled = false;
5658
showSelect = false;
@@ -204,4 +206,18 @@ export class SelectDemo {
204206
toggleSelected() {
205207
this.currentAppearanceValue = this.currentAppearanceValue ? null : this.digimon[0].value;
206208
}
209+
210+
isDrinkOptionDisabled(index: number) {
211+
if (this.drinksOptionsDisabled === 'all') {
212+
return true;
213+
}
214+
if (this.drinksOptionsDisabled === 'first-middle-last') {
215+
return (
216+
index === 0 ||
217+
index === this.drinks.length - 1 ||
218+
index === Math.floor(this.drinks.length / 2)
219+
);
220+
}
221+
return false;
222+
}
207223
}

src/material/autocomplete/autocomplete-trigger.ts

+18-5
Original file line numberDiff line numberDiff line change
@@ -748,16 +748,29 @@ export abstract class _MatAutocompleteTriggerBase
748748
}
749749

750750
/**
751-
* Resets the active item to -1 so arrow events will activate the
752-
* correct options, or to 0 if the consumer opted into it.
751+
* Reset the active item to -1. This is so that pressing arrow keys will activate the correct
752+
* option.
753+
*
754+
* If the consumer opted-in to automatically activatating the first option, activate the first
755+
* *enabled* option.
753756
*/
754757
private _resetActiveItem(): void {
755758
const autocomplete = this.autocomplete;
756759

757760
if (autocomplete.autoActiveFirstOption) {
758-
// Note that we go through `setFirstItemActive`, rather than `setActiveItem(0)`, because
759-
// the former will find the next enabled option, if the first one is disabled.
760-
autocomplete._keyManager.setFirstItemActive();
761+
// Find the index of the first *enabled* option. Avoid calling `_keyManager.setActiveItem`
762+
// because it activates the first option that passes the skip predicate, rather than the
763+
// first *enabled* option.
764+
let firstEnabledOptionIndex = -1;
765+
766+
for (let index = 0; index < autocomplete.options.length; index++) {
767+
const option = autocomplete.options.get(index)!;
768+
if (!option.disabled) {
769+
firstEnabledOptionIndex = index;
770+
break;
771+
}
772+
}
773+
autocomplete._keyManager.setActiveItem(firstEnabledOptionIndex);
761774
} else {
762775
autocomplete._keyManager.setActiveItem(-1);
763776
}

src/material/autocomplete/autocomplete.spec.ts

+17
Original file line numberDiff line numberDiff line change
@@ -2307,6 +2307,23 @@ describe('MDC-based MatAutocomplete', () => {
23072307
}),
23082308
);
23092309

2310+
it('should not activate any option if all options are disabled', fakeAsync(() => {
2311+
const testComponent = fixture.componentInstance;
2312+
testComponent.trigger.autocomplete.autoActiveFirstOption = true;
2313+
for (const state of testComponent.states) {
2314+
state.disabled = true;
2315+
}
2316+
testComponent.trigger.openPanel();
2317+
fixture.detectChanges();
2318+
zone.simulateZoneExit();
2319+
fixture.detectChanges();
2320+
2321+
const selectedOptions = overlayContainerElement.querySelectorAll(
2322+
'mat-option.mat-mdc-option-active',
2323+
);
2324+
expect(selectedOptions.length).withContext('expected no options to be active').toBe(0);
2325+
}));
2326+
23102327
it('should remove aria-activedescendant when panel is closed with autoActiveFirstOption', fakeAsync(() => {
23112328
const input: HTMLElement = fixture.nativeElement.querySelector('input');
23122329

src/material/autocomplete/autocomplete.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,9 @@ export abstract class _MatAutocompleteBase
252252
}
253253

254254
ngAfterContentInit() {
255-
this._keyManager = new ActiveDescendantKeyManager<_MatOptionBase>(this.options).withWrap();
255+
this._keyManager = new ActiveDescendantKeyManager<_MatOptionBase>(this.options)
256+
.withWrap()
257+
.skipPredicate(this._skipPredicate);
256258
this._activeOptionChanges = this._keyManager.change.subscribe(index => {
257259
if (this.isOpen) {
258260
this.optionActivated.emit({source: this, option: this.options.toArray()[index] || null});
@@ -318,6 +320,10 @@ export abstract class _MatAutocompleteBase
318320
classList['mat-warn'] = this._color === 'warn';
319321
classList['mat-accent'] = this._color === 'accent';
320322
}
323+
324+
protected _skipPredicate(option: _MatOptionBase) {
325+
return option.disabled;
326+
}
321327
}
322328

323329
@Component({
@@ -363,4 +369,22 @@ export class MatAutocomplete extends _MatAutocompleteBase {
363369
}
364370
}
365371
}
372+
373+
// `skipPredicate` determines if key manager should avoid putting a given option in the tab
374+
// order. Allow disabled list items to receive focus via keyboard to align with WAI ARIA
375+
// recommendation.
376+
//
377+
// Normally WAI ARIA's instructions are to exclude disabled items from the tab order, but it
378+
// makes a few exceptions for compound widgets.
379+
//
380+
// From [Developing a Keyboard Interface](
381+
// https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/):
382+
// "For the following composite widget elements, keep them focusable when disabled: Options in a
383+
// Listbox..."
384+
//
385+
// The user can focus disabled options using the keyboard, but the user cannot click disabled
386+
// options.
387+
protected override _skipPredicate(_option: _MatOptionBase) {
388+
return false;
389+
}
366390
}

src/material/core/option/_option-theme.scss

+3-2
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,10 @@
1717
// we have explicitly set the default color.
1818
@include mdc-theme.prop(color, text-primary-on-background);
1919

20+
// Increase specificity to override styles from list theme.
2021
&:hover:not(.mdc-list-item--disabled),
21-
&:focus:not(.mdc-list-item--disabled),
22-
&.mat-mdc-option-active,
22+
&:focus.mdc-list-item,
23+
&.mat-mdc-option-active.mdc-list-item,
2324

2425
// In multiple mode there is a checkbox to show that the option is selected.
2526
&.mdc-list-item--selected:not(.mat-mdc-option-multiple):not(.mdc-list-item--disabled) {

src/material/core/option/option.scss

+16-1
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,23 @@
3232
&.mdc-list-item--disabled {
3333
// This is the same as `mdc-list-mixins.list-disabled-opacity` which
3434
// we can't use directly, because it comes with some selectors.
35-
opacity: mdc-list-variables.$content-disabled-opacity;
3635
cursor: default;
36+
37+
// Give the visual content of this list item a lower opacity. This creates the "gray" appearance
38+
// for disabled state. Set the opacity on the pseudo checkbox and projected content. Set
39+
// opacity only on the visual content rather than the entire list-item so we don't affect the
40+
// focus ring from `.mat-mdc-focus-indicator`.
41+
//
42+
// MatOption uses a child `<div>` element for its focus state to align with how ListItem does
43+
// its focus state.
44+
.mat-mdc-option-pseudo-checkbox, .mdc-list-item__primary-text, > mat-icon {
45+
opacity: mdc-list-variables.$content-disabled-opacity;
46+
}
47+
48+
// Prevent clicking on disabled options with mouse. Support focusing on disabled option using
49+
// keyboard, but not with mouse.
50+
pointer-events: none;
51+
3752
}
3853

3954
// Note that we bump the padding here, rather than padding inside the

0 commit comments

Comments
 (0)