-
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;