diff --git a/src/dev-app/autocomplete/autocomplete-demo.html b/src/dev-app/autocomplete/autocomplete-demo.html index 546de5dd9c39..080723351160 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.html +++ b/src/dev-app/autocomplete/autocomplete-demo.html @@ -11,25 +11,40 @@ - + [hideSingleSelectionIndicator]="reactiveHideSingleSelectionIndicator" + [autoActiveFirstOption]="reactiveAutoActiveFirstOption"> + {{ state.name }} ({{ state.code }}) - +

- - +

+

+ + +

+

Hide Single-Selection Indicator - +

+

+ + Automatically activate first option + +

@@ -44,14 +59,16 @@ - + [hideSingleSelectionIndicator]="templateHideSingleSelectionIndicator" + [autoActiveFirstOption]="templateAutoActiveFirstOption"> + {{ state.name }} - +

- diff --git a/src/dev-app/select/select-demo.ts b/src/dev-app/select/select-demo.ts index 0f8fb788672e..61f2da697ebd 100644 --- a/src/dev-app/select/select-demo.ts +++ b/src/dev-app/select/select-demo.ts @@ -28,6 +28,8 @@ export class MyErrorStateMatcher implements ErrorStateMatcher { } } +type DisableDrinkOption = 'none' | 'first-middle-last' | 'all'; + @Component({ selector: 'select-demo', templateUrl: 'select-demo.html', @@ -50,7 +52,7 @@ export class SelectDemo { drinkObjectRequired = false; pokemonRequired = false; drinksDisabled = false; - drinksOptionsDisabled = false; + drinksOptionsDisabled: DisableDrinkOption = 'none'; pokemonDisabled = false; pokemonOptionsDisabled = false; showSelect = false; @@ -204,4 +206,18 @@ export class SelectDemo { toggleSelected() { this.currentAppearanceValue = this.currentAppearanceValue ? null : this.digimon[0].value; } + + isDrinkOptionDisabled(index: number) { + if (this.drinksOptionsDisabled === 'all') { + return true; + } + if (this.drinksOptionsDisabled === 'first-middle-last') { + return ( + index === 0 || + index === this.drinks.length - 1 || + index === Math.floor(this.drinks.length / 2) + ); + } + return false; + } } diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index c264ae6e2216..d3a39199bf2c 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -748,16 +748,29 @@ export abstract class _MatAutocompleteTriggerBase } /** - * Resets the active item to -1 so arrow events will activate the - * correct options, or to 0 if the consumer opted into it. + * Reset the active item to -1. This is so that pressing arrow keys will activate the correct + * option. + * + * If the consumer opted-in to automatically activatating the first option, activate the first + * *enabled* option. */ private _resetActiveItem(): void { const autocomplete = this.autocomplete; if (autocomplete.autoActiveFirstOption) { - // Note that we go through `setFirstItemActive`, rather than `setActiveItem(0)`, because - // the former will find the next enabled option, if the first one is disabled. - autocomplete._keyManager.setFirstItemActive(); + // Find the index of the first *enabled* option. Avoid calling `_keyManager.setActiveItem` + // because it activates the first option that passes the skip predicate, rather than the + // first *enabled* option. + let firstEnabledOptionIndex = -1; + + for (let index = 0; index < autocomplete.options.length; index++) { + const option = autocomplete.options.get(index)!; + if (!option.disabled) { + firstEnabledOptionIndex = index; + break; + } + } + autocomplete._keyManager.setActiveItem(firstEnabledOptionIndex); } else { autocomplete._keyManager.setActiveItem(-1); } diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index 86a97786e92f..df5264503e01 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -2307,6 +2307,23 @@ describe('MDC-based MatAutocomplete', () => { }), ); + it('should not activate any option if all options are disabled', fakeAsync(() => { + const testComponent = fixture.componentInstance; + testComponent.trigger.autocomplete.autoActiveFirstOption = true; + for (const state of testComponent.states) { + state.disabled = true; + } + testComponent.trigger.openPanel(); + fixture.detectChanges(); + zone.simulateZoneExit(); + fixture.detectChanges(); + + const selectedOptions = overlayContainerElement.querySelectorAll( + 'mat-option.mat-mdc-option-active', + ); + expect(selectedOptions.length).withContext('expected no options to be active').toBe(0); + })); + it('should remove aria-activedescendant when panel is closed with autoActiveFirstOption', fakeAsync(() => { const input: HTMLElement = fixture.nativeElement.querySelector('input'); diff --git a/src/material/autocomplete/autocomplete.ts b/src/material/autocomplete/autocomplete.ts index a0e8f5b3bf63..50417a57417b 100644 --- a/src/material/autocomplete/autocomplete.ts +++ b/src/material/autocomplete/autocomplete.ts @@ -252,7 +252,9 @@ export abstract class _MatAutocompleteBase } ngAfterContentInit() { - this._keyManager = new ActiveDescendantKeyManager<_MatOptionBase>(this.options).withWrap(); + this._keyManager = new ActiveDescendantKeyManager<_MatOptionBase>(this.options) + .withWrap() + .skipPredicate(this._skipPredicate); this._activeOptionChanges = this._keyManager.change.subscribe(index => { if (this.isOpen) { this.optionActivated.emit({source: this, option: this.options.toArray()[index] || null}); @@ -318,6 +320,10 @@ export abstract class _MatAutocompleteBase classList['mat-warn'] = this._color === 'warn'; classList['mat-accent'] = this._color === 'accent'; } + + protected _skipPredicate(option: _MatOptionBase) { + return option.disabled; + } } @Component({ @@ -363,4 +369,22 @@ export class MatAutocomplete extends _MatAutocompleteBase { } } } + + // `skipPredicate` determines if key manager should avoid putting a given option in the tab + // order. Allow disabled list items to receive focus via keyboard 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..." + // + // The user can focus disabled options using the keyboard, but the user cannot click disabled + // options. + protected override _skipPredicate(_option: _MatOptionBase) { + return false; + } } diff --git a/src/material/core/option/_option-theme.scss b/src/material/core/option/_option-theme.scss index 05bf830946e4..07f17c457054 100644 --- a/src/material/core/option/_option-theme.scss +++ b/src/material/core/option/_option-theme.scss @@ -17,9 +17,10 @@ // we have explicitly set the default color. @include mdc-theme.prop(color, text-primary-on-background); + // Increase specificity to override styles from list theme. &:hover:not(.mdc-list-item--disabled), - &:focus:not(.mdc-list-item--disabled), - &.mat-mdc-option-active, + &:focus.mdc-list-item, + &.mat-mdc-option-active.mdc-list-item, // In multiple mode there is a checkbox to show that the option is selected. &.mdc-list-item--selected:not(.mat-mdc-option-multiple):not(.mdc-list-item--disabled) { diff --git a/src/material/core/option/option.scss b/src/material/core/option/option.scss index be9560e5a677..30228302f541 100644 --- a/src/material/core/option/option.scss +++ b/src/material/core/option/option.scss @@ -32,8 +32,23 @@ &.mdc-list-item--disabled { // This is the same as `mdc-list-mixins.list-disabled-opacity` which // we can't use directly, because it comes with some selectors. - opacity: mdc-list-variables.$content-disabled-opacity; cursor: default; + + // Give the visual content of this list item a lower opacity. This creates the "gray" appearance + // for disabled state. Set the opacity on the pseudo checkbox and projected content. Set + // opacity only on the visual content rather than the entire list-item so we don't affect the + // focus ring from `.mat-mdc-focus-indicator`. + // + // MatOption uses a child `

` element for its focus state to align with how ListItem does + // its focus state. + .mat-mdc-option-pseudo-checkbox, .mdc-list-item__primary-text, > mat-icon { + opacity: mdc-list-variables.$content-disabled-opacity; + } + + // Prevent clicking on disabled options with mouse. Support focusing on disabled option using + // keyboard, but not with mouse. + pointer-events: none; + } // Note that we bump the padding here, rather than padding inside the diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts index 7990116a5542..9eb724ebe315 100644 --- a/src/material/select/select.spec.ts +++ b/src/material/select/select.spec.ts @@ -1057,12 +1057,12 @@ describe('MDC-based MatSelect', () => { fixture.detectChanges(); }); - expect(host.getAttribute('aria-activedescendant')).toBe(options[4].id); + expect(host.getAttribute('aria-activedescendant')).toBe(options[3].id); dispatchKeyboardEvent(host, 'keydown', UP_ARROW); fixture.detectChanges(); - expect(host.getAttribute('aria-activedescendant')).toBe(options[3].id); + expect(host.getAttribute('aria-activedescendant')).toBe(options[2].id); })); it('should not change the aria-activedescendant using the horizontal arrow keys', fakeAsync(() => { @@ -2453,14 +2453,12 @@ describe('MDC-based MatSelect', () => { host = groupFixture.debugElement.query(By.css('mat-select'))!.nativeElement; panel = overlayContainerElement.querySelector('.mat-mdc-select-panel')! as HTMLElement; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 8; i++) { dispatchKeyboardEvent(host, 'keydown', DOWN_ARROW); } - // Note that we press down 5 times, but it will skip - // 3 options because the second group is disabled. // + <(option index + group labels) * height> - = - // 8 + (9 + 3) * 48 - 275 = 309 + // 8 + (8 + 3) * 48 - 275 = 309 expect(panel.scrollTop).withContext('Expected scroll to be at the 9th option.').toBe(309); })); @@ -4465,6 +4463,7 @@ describe('MDC-based MatSelect', () => { const fixture = TestBed.createComponent(SelectInNgContainer); expect(() => fixture.detectChanges()).not.toThrow(); })); + describe('page up/down with disabled options', () => { let fixture: ComponentFixture; let host: HTMLElement; @@ -4484,30 +4483,30 @@ describe('MDC-based MatSelect', () => { host = fixture.debugElement.query(By.css('mat-select'))!.nativeElement; })); - it('should scroll to the second one pressing PAGE_UP, because the first one is disabled', fakeAsync(() => { + it('should be able to scroll to disabled option when pressing PAGE_UP', fakeAsync(() => { expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1); dispatchKeyboardEvent(host, 'keydown', PAGE_UP); fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0); dispatchKeyboardEvent(host, 'keydown', PAGE_UP); fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(1); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(0); })); - it('should scroll by PAGE_DOWN to the one before the last, because last one is disabled', fakeAsync(() => { + it('should be able to scroll to disabled option when pressing PAGE_DOWN', fakeAsync(() => { dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN); fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(6); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7); dispatchKeyboardEvent(host, 'keydown', PAGE_DOWN); fixture.detectChanges(); - expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(6); + expect(fixture.componentInstance.select._keyManager.activeItemIndex).toBe(7); })); }); }); diff --git a/src/material/select/select.ts b/src/material/select/select.ts index c93268f9e543..6c518afaf31e 100644 --- a/src/material/select/select.ts +++ b/src/material/select/select.ts @@ -912,6 +912,10 @@ export abstract class _MatSelectBase return false; } + protected _skipPredicate(item: MatOption): boolean { + return item.disabled; + } + /** Sets up a key manager to listen to keyboard events on the overlay panel. */ private _initKeyManager() { this._keyManager = new ActiveDescendantKeyManager(this.options) @@ -920,7 +924,8 @@ export abstract class _MatSelectBase .withHorizontalOrientation(this._isRtl() ? 'rtl' : 'ltr') .withHomeAndEnd() .withPageUpDown() - .withAllowedModifierKeys(['shiftKey']); + .withAllowedModifierKeys(['shiftKey']) + .skipPredicate(this._skipPredicate); this._keyManager.tabOut.subscribe(() => { if (this.panelOpen) { @@ -1047,12 +1052,24 @@ export abstract class _MatSelectBase /** * Highlights the selected item. If no option is selected, it will highlight - * the first item instead. + * the first *enabled* option. */ private _highlightCorrectOption(): void { if (this._keyManager) { if (this.empty) { - this._keyManager.setFirstItemActive(); + // Find the index of the first *enabled* option. Avoid calling `_keyManager.setActiveItem` + // because it activates the first option that passes the skip predicate, rather than the + // first *enabled* option. + let firstEnabledOptionIndex = -1; + for (let index = 0; index < this.options.length; index++) { + const option = this.options.get(index)!; + if (!option.disabled) { + firstEnabledOptionIndex = index; + break; + } + } + + this._keyManager.setActiveItem(firstEnabledOptionIndex); } else { this._keyManager.setActiveItem(this._selectionModel.selected[0]); } @@ -1316,4 +1333,30 @@ export class MatSelect extends _MatSelectBase implements OnInit } } } + + // `skipPredicate` determines if key manager should avoid putting a given option in the tab + // order. Allow disabled list items to receive focus via keyboard 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..." + // + // The user can focus disabled options using the keyboard, but the user cannot click disabled + // options. + protected override _skipPredicate = (option: MatOption) => { + if (this.panelOpen) { + // Support keyboard focusing disabled options in an ARIA listbox. + return false; + } + + // When the panel is closed, skip over disabled options. Support options via the UP/DOWN arrow + // keys on a closed select. ARIA listbox interaction pattern is less relevant when the panel is + // closed. + return option.disabled; + }; } diff --git a/tools/public_api_guard/material/autocomplete.md b/tools/public_api_guard/material/autocomplete.md index 615c21e501ba..3281519a7cb5 100644 --- a/tools/public_api_guard/material/autocomplete.md +++ b/tools/public_api_guard/material/autocomplete.md @@ -75,6 +75,8 @@ export class MatAutocomplete extends _MatAutocompleteBase { set hideSingleSelectionIndicator(value: BooleanInput); optionGroups: QueryList; options: QueryList; + // (undocumented) + protected _skipPredicate(_option: _MatOptionBase): boolean; _syncParentProperties(): void; // (undocumented) protected _visibleClass: string; @@ -133,6 +135,8 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp _setScrollTop(scrollTop: number): void; _setVisibility(): void; showPanel: boolean; + // (undocumented) + protected _skipPredicate(option: _MatOptionBase): boolean; template: TemplateRef; protected abstract _visibleClass: string; // (undocumented) diff --git a/tools/public_api_guard/material/select.md b/tools/public_api_guard/material/select.md index 3e1523a0e664..55ff53feceac 100644 --- a/tools/public_api_guard/material/select.md +++ b/tools/public_api_guard/material/select.md @@ -104,6 +104,8 @@ export class MatSelect extends _MatSelectBase implements OnInit protected _scrollOptionIntoView(index: number): void; // (undocumented) get shouldLabelFloat(): boolean; + // (undocumented) + protected _skipPredicate: (option: MatOption) => boolean; _syncParentProperties(): void; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration; @@ -204,6 +206,8 @@ export abstract class _MatSelectBase extends _MatSelectMixinBase implements A setDescribedByIds(ids: string[]): void; setDisabledState(isDisabled: boolean): void; get shouldLabelFloat(): boolean; + // (undocumented) + protected _skipPredicate(item: MatOption): boolean; sortComparator: (a: MatOption, b: MatOption, options: MatOption[]) => number; toggle(): void; trigger: ElementRef;