Skip to content

Commit 55edfbd

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 2686bfe commit 55edfbd

File tree

4 files changed

+77
-4
lines changed

4 files changed

+77
-4
lines changed

src/material/list/list-base.ts

Lines changed: 3 additions & 1 deletion
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

Lines changed: 38 additions & 0 deletions
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

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,20 @@ export class MatSelectionListChange {
5757
) {}
5858
}
5959

60+
/**
61+
* Determines if key manager should avoid putting a given list item in the tab index. Allow
62+
* disabled list items to receive focus to align with WAI ARIA recommendation. Normally WAI ARIA's
63+
* instructions are to exclude disabled items from the tab order, but it makes a few exceptions for
64+
* compound widgets.
65+
*
66+
* From [Developing a Keyboard Interface](https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/):
67+
* "For the following composite widget elements, keep them focusable when disabled: Options in a
68+
* Listbox..."
69+
*/
70+
function _skipPredicate(_option: MatListOption): boolean {
71+
return false;
72+
}
73+
6074
@Component({
6175
selector: 'mat-selection-list',
6276
exportAs: 'matSelectionList',
@@ -230,6 +244,23 @@ export class MatSelectionList
230244
this.disabled = isDisabled;
231245
}
232246

247+
/**
248+
* Whether the *entire* selection list is disabled. When true, each list item is also disabled
249+
* and each list item is removed from the tab order (has tabindex="-1").
250+
*/
251+
@Input()
252+
override get disabled(): boolean {
253+
return super.disabled;
254+
}
255+
override set disabled(value: BooleanInput) {
256+
super.disabled = value;
257+
if (super.disabled) {
258+
// When the entire list is disabled, remove all list items from the tab order. Fix bug where
259+
// selection list stays in the tab order after being disabled.
260+
this._keyManager?.setActiveItem(-1);
261+
}
262+
}
263+
233264
/** Implemented as part of ControlValueAccessor. */
234265
registerOnChange(fn: (value: any) => void): void {
235266
this._onChange = fn;
@@ -371,8 +402,7 @@ export class MatSelectionList
371402
.withHomeAndEnd()
372403
.withTypeAhead()
373404
.withWrap()
374-
// Allow navigation to disabled items.
375-
.skipPredicate(() => false);
405+
.skipPredicate(_skipPredicate);
376406

377407
// Set the initial focus.
378408
this._resetActiveOption();

tools/public_api_guard/material/list.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,9 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont
216216
compareWith: (o1: any, o2: any) => boolean;
217217
deselectAll(): MatListOption[];
218218
// (undocumented)
219+
get disabled(): boolean;
220+
set disabled(value: boolean);
221+
// (undocumented)
219222
_element: ElementRef<HTMLElement>;
220223
_emitChangeEvent(options: MatListOption[]): void;
221224
focus(options?: FocusOptions): void;
@@ -242,7 +245,7 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont
242245
_value: string[] | null;
243246
writeValue(values: string[]): void;
244247
// (undocumented)
245-
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>;
246249
// (undocumented)
247250
static ɵfac: i0.ɵɵFactoryDeclaration<MatSelectionList, never>;
248251
}

0 commit comments

Comments
 (0)