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 @@
-
+ + +
+
+
+
+ + +
diff --git a/src/dev-app/autocomplete/autocomplete-demo.ts b/src/dev-app/autocomplete/autocomplete-demo.ts index 18493a066dac..3cfad904ea49 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.ts +++ b/src/dev-app/autocomplete/autocomplete-demo.ts @@ -21,6 +21,7 @@ import {ThemePalette} from '@angular/material/core'; export interface State { code: string; name: string; + index: number; } export interface StateGroup { @@ -28,6 +29,8 @@ export interface StateGroup { states: State[]; } +type DisableStateOption = 'none' | 'first-middle-last' | 'all'; + @Component({ selector: 'autocomplete-demo', templateUrl: 'autocomplete-demo.html', @@ -55,7 +58,6 @@ export class AutocompleteDemo { tdDisabled = false; hideSingleSelectionIndicators = false; - reactiveStatesTheme: ThemePalette = 'primary'; templateStatesTheme: ThemePalette = 'primary'; @@ -68,6 +70,12 @@ export class AutocompleteDemo { reactiveHideSingleSelectionIndicator = false; templateHideSingleSelectionIndicator = false; + reactiveAutoActiveFirstOption = false; + templateAutoActiveFirstOption = false; + + reactiveDisableStateOption: DisableStateOption = 'none'; + templateDisableStateOption: DisableStateOption = 'none'; + @ViewChild(NgModel) modelDir: NgModel; groupedStates: StateGroup[]; @@ -123,7 +131,7 @@ export class AutocompleteDemo { {code: 'WV', name: 'West Virginia'}, {code: 'WI', name: 'Wisconsin'}, {code: 'WY', name: 'Wyoming'}, - ]; + ].map((state, index) => ({...state, index})); constructor() { this.tdStates = this.states; @@ -142,7 +150,7 @@ export class AutocompleteDemo { groups.push(group); } - group.states.push({code: state.code, name: state.name}); + group.states.push({...state}); return groups; }, @@ -172,4 +180,26 @@ export class AutocompleteDemo { const filterValue = val.toLowerCase(); return states.filter(state => state.name.toLowerCase().startsWith(filterValue)); } + + reactiveIsStateDisabled(index: number) { + return this._isStateDisabled(index, this.reactiveDisableStateOption); + } + + templateIsStateDisabled(index: number) { + return this._isStateDisabled(index, this.templateDisableStateOption); + } + + private _isStateDisabled(stateIndex: number, disableStateOption: DisableStateOption) { + if (disableStateOption === 'all') { + return true; + } + if (disableStateOption === 'first-middle-last') { + return ( + stateIndex === 0 || + stateIndex === this.states.length - 1 || + stateIndex === Math.floor(this.states.length / 2) + ); + } + return false; + } } diff --git a/src/dev-app/select/select-demo.html b/src/dev-app/select/select-demo.html index d5639d040139..5eee7a9ee2ce 100644 --- a/src/dev-app/select/select-demo.html +++ b/src/dev-app/select/select-demo.html @@ -12,8 +12,9 @@+ + +
- 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 `