Skip to content

Commit b8baf4d

Browse files
committed
fix(material/list): fix selection-list staying in tab order when disabled
Remove list options from the tab order when the entire slection list is disabled. Fix accessibility issue where end user can tab to a disabled selection list. ``` <mat-selection-list disabled> <mat-list-option>A</mat-list-option> <!-- ^ should have tabindex="-1" since entire list is disabled --> </mat-selection-list> ``` Approach is to consider a disabled mat-selection-list with a mat-list-option having `tabindex="0"` an invalid state. When disabled Input on the seleciton list is set to true, set tabindex to -1 on every list option. fixes #25730
1 parent 72547a4 commit b8baf4d

File tree

4 files changed

+76
-4
lines changed

4 files changed

+76
-4
lines changed

src/material/list/list-base.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,9 @@ export abstract class MatListBase {
5656
}
5757
private _disableRipple: boolean = false;
5858

59-
/** Whether all list items are disabled. */
59+
/**
60+
* Whether the *entire* list is disabled. When true, each list item is also disabled.
61+
*/
6062
@Input()
6163
get disabled(): boolean {
6264
return this._disabled;

src/material/list/selection-list.spec.ts

+38
Original file line numberDiff line numberDiff line change
@@ -804,6 +804,29 @@ describe('MDC-based MatSelectionList without forms', () => {
804804
.withContext('Expected ripples of list option to be enabled')
805805
.toBe(false);
806806
});
807+
808+
// when the entire list is disabled, its listitems should always have tabindex="-1"
809+
it('should not put listitems in the tab order', () => {
810+
fixture.componentInstance.disabled = false;
811+
let testListItem = listOption[2].injector.get<MatListOption>(MatListOption);
812+
testListItem.focus();
813+
fixture.detectChanges();
814+
815+
expect(
816+
listOption.filter(option => option.nativeElement.getAttribute('tabindex') === '0').length,
817+
)
818+
.withContext('Expected at least one list option to be in the tab order')
819+
.toBeGreaterThanOrEqual(1);
820+
821+
fixture.componentInstance.disabled = true;
822+
fixture.detectChanges();
823+
824+
expect(
825+
listOption.filter(option => option.nativeElement.getAttribute('tabindex') !== '-1').length,
826+
)
827+
.withContext('Expected all list options to be excluded from the tab order')
828+
.toBe(0);
829+
});
807830
});
808831

809832
describe('with checkbox position after', () => {
@@ -1373,12 +1396,20 @@ describe('MDC-based MatSelectionList with forms', () => {
13731396
});
13741397

13751398
it('should be able to disable options from the control', () => {
1399+
selectionList.focus();
13761400
expect(selectionList.disabled)
13771401
.withContext('Expected the selection list to be enabled.')
13781402
.toBe(false);
13791403
expect(listOptions.every(option => !option.disabled))
13801404
.withContext('Expected every list option to be enabled.')
13811405
.toBe(true);
1406+
expect(
1407+
listOptions.some(
1408+
option => option._elementRef.nativeElement.getAttribute('tabindex') === '0',
1409+
),
1410+
)
1411+
.withContext('Expected one list item to be in the tab order')
1412+
.toBe(true);
13821413

13831414
fixture.componentInstance.formControl.disable();
13841415
fixture.detectChanges();
@@ -1389,6 +1420,13 @@ describe('MDC-based MatSelectionList with forms', () => {
13891420
expect(listOptions.every(option => option.disabled))
13901421
.withContext('Expected every list option to be disabled.')
13911422
.toBe(true);
1423+
expect(
1424+
listOptions.every(
1425+
option => option._elementRef.nativeElement.getAttribute('tabindex') === '-1',
1426+
),
1427+
)
1428+
.withContext('Expected every list option to be removed from the tab order')
1429+
.toBe(true);
13921430
});
13931431

13941432
it('should be able to update the disabled property after form control disabling', () => {

src/material/list/selection-list.ts

+32-2
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,25 @@ export class MatSelectionList
230230
this.disabled = isDisabled;
231231
}
232232

233+
/**
234+
* Whether the *entire* selection list is disabled. When true, each list item is also disabled
235+
* and each list item is removed from the tab order (has tabindex="-1").
236+
*/
237+
@Input()
238+
override get disabled(): boolean {
239+
// Access base class's `disabled` property accessor. This class's disabled property accessor
240+
// will implicitly return `undefined` if this class does not implement a getter.
241+
return super.disabled;
242+
}
243+
override set disabled(value: BooleanInput) {
244+
super.disabled = value;
245+
if (super.disabled) {
246+
// When the entire list is disabled, remove all list items from the tab order. Fix bug where
247+
// selection list stays in the tab order after being disabled.
248+
this._keyManager?.setActiveItem(-1);
249+
}
250+
}
251+
233252
/** Implemented as part of ControlValueAccessor. */
234253
registerOnChange(fn: (value: any) => void): void {
235254
this._onChange = fn;
@@ -365,13 +384,24 @@ export class MatSelectionList
365384
}
366385
};
367386

368-
/** Sets up the logic for maintaining the roving tabindex. */
387+
/**
388+
* Sets up the logic for maintaining the roving tabindex.
389+
*
390+
* `skipPredicate` determines if key manager should avoid putting a given list item in the tab
391+
* index. Allow disabled list items to receive focus to align with WAI ARIA recommendation.
392+
* Normally WAI ARIA's instructions are to exclude disabled items from the tab order, but it
393+
* makes a few exceptions for compound widgets.
394+
*
395+
* From [Developing a Keyboard Interface](
396+
* https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/):
397+
* "For the following composite widget elements, keep them focusable when disabled: Options in a
398+
* Listbox..."
399+
*/
369400
private _setupRovingTabindex() {
370401
this._keyManager = new FocusKeyManager(this._items)
371402
.withHomeAndEnd()
372403
.withTypeAhead()
373404
.withWrap()
374-
// Allow navigation to disabled items.
375405
.skipPredicate(() => false);
376406

377407
// Set the initial focus.

tools/public_api_guard/material/list.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,8 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont
216216
color: ThemePalette;
217217
compareWith: (o1: any, o2: any) => boolean;
218218
deselectAll(): MatListOption[];
219+
get disabled(): boolean;
220+
set disabled(value: BooleanInput);
219221
// (undocumented)
220222
_element: ElementRef<HTMLElement>;
221223
_emitChangeEvent(options: MatListOption[]): void;
@@ -243,7 +245,7 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont
243245
_value: string[] | null;
244246
writeValue(values: string[]): void;
245247
// (undocumented)
246-
static ɵcmp: i0.ɵɵComponentDeclaration<MatSelectionList, "mat-selection-list", ["matSelectionList"], { "color": "color"; "compareWith": "compareWith"; "multiple": "multiple"; }, { "selectionChange": "selectionChange"; }, ["_items"], ["*"], false, never>;
248+
static ɵcmp: i0.ɵɵComponentDeclaration<MatSelectionList, "mat-selection-list", ["matSelectionList"], { "color": "color"; "compareWith": "compareWith"; "multiple": "multiple"; "disabled": "disabled"; }, { "selectionChange": "selectionChange"; }, ["_items"], ["*"], false, never>;
247249
// (undocumented)
248250
static ɵfac: i0.ɵɵFactoryDeclaration<MatSelectionList, never>;
249251
}

0 commit comments

Comments
 (0)