diff --git a/src/material/list/list-base.ts b/src/material/list/list-base.ts index 43ee79149dd0..bfcc230607e6 100644 --- a/src/material/list/list-base.ts +++ b/src/material/list/list-base.ts @@ -56,7 +56,10 @@ export abstract class MatListBase { } private _disableRipple: boolean = false; - /** Whether all list items are disabled. */ + /** + * Whether the entire list is disabled. When disabled, the list itself and each of its list items + * are disabled. + */ @Input() get disabled(): boolean { return this._disabled; diff --git a/src/material/list/selection-list.spec.ts b/src/material/list/selection-list.spec.ts index 9e063a85dde6..36258112ff18 100644 --- a/src/material/list/selection-list.spec.ts +++ b/src/material/list/selection-list.spec.ts @@ -804,6 +804,29 @@ describe('MDC-based MatSelectionList without forms', () => { .withContext('Expected ripples of list option to be enabled') .toBe(false); }); + + // when the entire list is disabled, its listitems should always have tabindex="-1" + it('should not put listitems in the tab order', () => { + fixture.componentInstance.disabled = false; + let testListItem = listOption[2].injector.get(MatListOption); + testListItem.focus(); + fixture.detectChanges(); + + expect( + listOption.filter(option => option.nativeElement.getAttribute('tabindex') === '0').length, + ) + .withContext('Expected at least one list option to be in the tab order') + .toBeGreaterThanOrEqual(1); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect( + listOption.filter(option => option.nativeElement.getAttribute('tabindex') !== '-1').length, + ) + .withContext('Expected all list options to be excluded from the tab order') + .toBe(0); + }); }); describe('with checkbox position after', () => { @@ -1373,12 +1396,20 @@ describe('MDC-based MatSelectionList with forms', () => { }); it('should be able to disable options from the control', () => { + selectionList.focus(); expect(selectionList.disabled) .withContext('Expected the selection list to be enabled.') .toBe(false); expect(listOptions.every(option => !option.disabled)) .withContext('Expected every list option to be enabled.') .toBe(true); + expect( + listOptions.some( + option => option._elementRef.nativeElement.getAttribute('tabindex') === '0', + ), + ) + .withContext('Expected one list item to be in the tab order') + .toBe(true); fixture.componentInstance.formControl.disable(); fixture.detectChanges(); @@ -1389,6 +1420,13 @@ describe('MDC-based MatSelectionList with forms', () => { expect(listOptions.every(option => option.disabled)) .withContext('Expected every list option to be disabled.') .toBe(true); + expect( + listOptions.every( + option => option._elementRef.nativeElement.getAttribute('tabindex') === '-1', + ), + ) + .withContext('Expected every list option to be removed from the tab order') + .toBe(true); }); it('should be able to update the disabled property after form control disabling', () => { diff --git a/src/material/list/selection-list.ts b/src/material/list/selection-list.ts index 468cdbcb4e73..afd22a2826c4 100644 --- a/src/material/list/selection-list.ts +++ b/src/material/list/selection-list.ts @@ -230,6 +230,25 @@ export class MatSelectionList this.disabled = isDisabled; } + /** + * Whether the *entire* selection list is disabled. When true, each list item is also disabled + * and each list item is removed from the tab order (has tabindex="-1"). + */ + @Input() + override get disabled(): boolean { + return this._selectionListDisabled; + } + override set disabled(value: BooleanInput) { + // Update the disabled state of this list. Write to `this._selectionListDisabled` instead of + // `super.disabled`. That is to avoid closure compiler compatibility issues with assigning to + // a super property. + this._selectionListDisabled = coerceBooleanProperty(value); + if (this._selectionListDisabled) { + this._keyManager?.setActiveItem(-1); + } + } + private _selectionListDisabled = false; + /** Implemented as part of ControlValueAccessor. */ registerOnChange(fn: (value: any) => void): void { this._onChange = fn; @@ -365,13 +384,24 @@ export class MatSelectionList } }; - /** Sets up the logic for maintaining the roving tabindex. */ + /** + * Sets up the logic for maintaining the roving tabindex. + * + * `skipPredicate` determines if key manager should avoid putting a given list item in the tab + * index. Allow disabled list items to receive focus to align with WAI ARIA recommendation. + * Normally WAI ARIA's instructions are to exclude disabled items from the tab order, but it + * makes a few exceptions for compound widgets. + * + * From [Developing a Keyboard Interface]( + * https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/): + * "For the following composite widget elements, keep them focusable when disabled: Options in a + * Listbox..." + */ private _setupRovingTabindex() { this._keyManager = new FocusKeyManager(this._items) .withHomeAndEnd() .withTypeAhead() .withWrap() - // Allow navigation to disabled items. .skipPredicate(() => false); // Set the initial focus. diff --git a/tools/public_api_guard/material/list.md b/tools/public_api_guard/material/list.md index c035d6f99ca8..da5ae144108e 100644 --- a/tools/public_api_guard/material/list.md +++ b/tools/public_api_guard/material/list.md @@ -216,6 +216,8 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont color: ThemePalette; compareWith: (o1: any, o2: any) => boolean; deselectAll(): MatListOption[]; + get disabled(): boolean; + set disabled(value: BooleanInput); // (undocumented) _element: ElementRef; _emitChangeEvent(options: MatListOption[]): void; @@ -243,7 +245,7 @@ export class MatSelectionList extends MatListBase implements SelectionList, Cont _value: string[] | null; writeValue(values: string[]): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; }