Skip to content

Commit 3866761

Browse files
authored
fix(material/autocomplete): options inside groups not read out with VoiceOver (#20819)
We're currently hitting a bug with `mat-autocomplete` that prevents options inside of `mat-optgroup` from being read out by VoiceOver on Safari. These changes implement an alternate accessibility pattern that only applies to options inside an autocomplete since the work correctly in `mat-select`.
1 parent 9f4415e commit 3866761

File tree

14 files changed

+203
-32
lines changed

14 files changed

+203
-32
lines changed

src/material-experimental/mdc-core/option/optgroup.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@
99
import {Component, ViewEncapsulation, ChangeDetectionStrategy} from '@angular/core';
1010
import {_MatOptgroupBase, MAT_OPTGROUP} from '@angular/material/core';
1111

12+
// Notes on the accessibility pattern used for `mat-optgroup`.
13+
// The option group has two different "modes": regular and inert. The regular mode uses the
14+
// recommended a11y pattern which has `role="group"` on the group element with `aria-labelledby`
15+
// pointing to the label. This works for `mat-select`, but it seems to hit a bug for autocomplete
16+
// under VoiceOver where the group doesn't get read out at all. The bug appears to be that if
17+
// there's __any__ a11y-related attribute on the group (e.g. `role` or `aria-labelledby`),
18+
// VoiceOver on Safari won't read it out.
19+
// We've introduced the `inert` mode as a workaround. Under this mode, all a11y attributes are
20+
// removed from the group, and we get the screen reader to read out the group label by mirroring it
21+
// inside an invisible element in the option. This is sub-optimal, because the screen reader will
22+
// repeat the group label on each navigation, whereas the default pattern only reads the group when
23+
// the user enters a new group. The following alternate approaches were considered:
24+
// 1. Reading out the group label using the `LiveAnnouncer` solves the problem, but we can't control
25+
// when the text will be read out so sometimes it comes in too late or never if the user
26+
// navigates quickly.
27+
// 2. `<mat-option aria-describedby="groupLabel"` - This works on Safari, but VoiceOver in Chrome
28+
// won't read out the description at all.
29+
// 3. `<mat-option aria-labelledby="optionLabel groupLabel"` - This works on Chrome, but Safari
30+
// doesn't read out the text at all. Furthermore, on
1231

1332
/**
1433
* Component that is used to group instances of `mat-option`.
@@ -23,9 +42,9 @@ import {_MatOptgroupBase, MAT_OPTGROUP} from '@angular/material/core';
2342
styleUrls: ['optgroup.css'],
2443
host: {
2544
'class': 'mat-mdc-optgroup',
26-
'role': 'group',
27-
'[attr.aria-disabled]': 'disabled.toString()',
28-
'[attr.aria-labelledby]': '_labelId',
45+
'[attr.role]': '_inert ? null : "group"',
46+
'[attr.aria-disabled]': '_inert ? null : disabled.toString()',
47+
'[attr.aria-labelledby]': '_inert ? null : _labelId',
2948
},
3049
providers: [
3150
{provide: MAT_OPTGROUP, useExisting: MatOptgroup}

src/material-experimental/mdc-core/option/option.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
<span class="mdc-list-item__text"><ng-content></ng-content></span>
55

6+
<!-- See a11y notes inside optgroup.ts for context behind this element. -->
7+
<span class="cdk-visually-hidden" *ngIf="group && group._inert">({{ group.label }})</span>
8+
69
<div class="mat-mdc-option-ripple" mat-ripple
710
[matRippleTrigger]="_getHostElement()"
811
[matRippleDisabled]="disabled || disableRipple">

src/material-experimental/mdc-core/option/option.spec.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
dispatchEvent,
99
} from '@angular/cdk/testing/private';
1010
import {SPACE, ENTER} from '@angular/cdk/keycodes';
11-
import {MatOption, MatOptionModule} from './index';
11+
import {MatOption, MatOptionModule, MAT_OPTION_PARENT_COMPONENT} from './index';
1212

1313
describe('MatOption component', () => {
1414

@@ -197,6 +197,38 @@ describe('MatOption component', () => {
197197
expect(optionNativeElement.classList.contains('mat-mdc-focus-indicator')).toBe(true);
198198
});
199199

200+
describe('inside inert group', () => {
201+
let fixture: ComponentFixture<InsideGroup>;
202+
203+
beforeEach(waitForAsync(() => {
204+
TestBed.resetTestingModule();
205+
TestBed.configureTestingModule({
206+
imports: [MatOptionModule],
207+
declarations: [InsideGroup],
208+
providers: [{
209+
provide: MAT_OPTION_PARENT_COMPONENT,
210+
useValue: {inertGroups: true}
211+
}]
212+
}).compileComponents();
213+
214+
fixture = TestBed.createComponent(InsideGroup);
215+
fixture.detectChanges();
216+
}));
217+
218+
it('should remove all accessibility-related attributes from the group', () => {
219+
const group: HTMLElement = fixture.nativeElement.querySelector('mat-optgroup');
220+
expect(group.hasAttribute('role')).toBe(false);
221+
expect(group.hasAttribute('aria-disabled')).toBe(false);
222+
expect(group.hasAttribute('aria-labelledby')).toBe(false);
223+
});
224+
225+
it('should mirror the group label inside the option', () => {
226+
const option: HTMLElement = fixture.nativeElement.querySelector('mat-option');
227+
expect(option.textContent?.trim()).toBe('Option(Group)');
228+
});
229+
});
230+
231+
200232
});
201233

202234
@Component({
@@ -206,3 +238,14 @@ class BasicOption {
206238
disabled: boolean;
207239
id: string;
208240
}
241+
242+
243+
@Component({
244+
template: `
245+
<mat-optgroup label="Group">
246+
<mat-option>Option</mat-option>
247+
</mat-optgroup>
248+
`
249+
})
250+
class InsideGroup {
251+
}

src/material-experimental/mdc-core/testing/option-harness.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export class MatOptionHarness extends ComponentHarness {
1414
/** Selector used to locate option instances. */
1515
static hostSelector = '.mat-mdc-option';
1616

17+
/** Element containing the option's text. */
18+
private _text = this.locatorFor('.mdc-list-item__text');
19+
1720
/**
1821
* Gets a `HarnessPredicate` that can be used to search for a `MatOptionsHarness` that meets
1922
* certain criteria.
@@ -37,7 +40,7 @@ export class MatOptionHarness extends ComponentHarness {
3740

3841
/** Gets the option's label text. */
3942
async getText(): Promise<string> {
40-
return (await this.host()).text();
43+
return (await this._text()).text();
4144
}
4245

4346
/** Gets whether the option is disabled. */

src/material/autocomplete/autocomplete.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import {ActiveDescendantKeyManager} from '@angular/cdk/a11y';
1010
import {BooleanInput, coerceBooleanProperty, coerceStringArray} from '@angular/cdk/coercion';
11+
import {Platform} from '@angular/cdk/platform';
1112
import {
1213
AfterContentInit,
1314
ChangeDetectionStrategy,
@@ -187,12 +188,24 @@ export abstract class _MatAutocompleteBase extends _MatAutocompleteMixinBase imp
187188
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
188189
id: string = `mat-autocomplete-${_uniqueAutocompleteIdCounter++}`;
189190

191+
/**
192+
* Tells any descendant `mat-optgroup` to use the inert a11y pattern.
193+
* @docs-private
194+
*/
195+
readonly inertGroups: boolean;
196+
190197
constructor(
191198
private _changeDetectorRef: ChangeDetectorRef,
192199
private _elementRef: ElementRef<HTMLElement>,
193-
@Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS) defaults: MatAutocompleteDefaultOptions) {
200+
@Inject(MAT_AUTOCOMPLETE_DEFAULT_OPTIONS) defaults: MatAutocompleteDefaultOptions,
201+
platform?: Platform) {
194202
super();
195203

204+
// TODO(crisbeto): the problem that the `inertGroups` option resolves is only present on
205+
// Safari using VoiceOver. We should occasionally check back to see whether the bug
206+
// wasn't resolved in VoiceOver, and if it has, we can remove this and the `inertGroups`
207+
// option altogether.
208+
this.inertGroups = platform?.SAFARI || false;
196209
this._autoActiveFirstOption = !!defaults.autoActiveFirstOption;
197210
}
198211

src/material/core/option/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,3 +24,4 @@ export class MatOptionModule {}
2424

2525
export * from './option';
2626
export * from './optgroup';
27+
export * from './option-parent';

src/material/core/option/optgroup.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,30 @@ import {
1313
InjectionToken,
1414
Input,
1515
ViewEncapsulation,
16-
Directive
16+
Directive, Inject, Optional
1717
} from '@angular/core';
1818
import {CanDisable, CanDisableCtor, mixinDisabled} from '../common-behaviors/disabled';
19+
import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent';
1920

21+
// Notes on the accessibility pattern used for `mat-optgroup`.
22+
// The option group has two different "modes": regular and inert. The regular mode uses the
23+
// recommended a11y pattern which has `role="group"` on the group element with `aria-labelledby`
24+
// pointing to the label. This works for `mat-select`, but it seems to hit a bug for autocomplete
25+
// under VoiceOver where the group doesn't get read out at all. The bug appears to be that if
26+
// there's __any__ a11y-related attribute on the group (e.g. `role` or `aria-labelledby`),
27+
// VoiceOver on Safari won't read it out.
28+
// We've introduced the `inert` mode as a workaround. Under this mode, all a11y attributes are
29+
// removed from the group, and we get the screen reader to read out the group label by mirroring it
30+
// inside an invisible element in the option. This is sub-optimal, because the screen reader will
31+
// repeat the group label on each navigation, whereas the default pattern only reads the group when
32+
// the user enters a new group. The following alternate approaches were considered:
33+
// 1. Reading out the group label using the `LiveAnnouncer` solves the problem, but we can't control
34+
// when the text will be read out so sometimes it comes in too late or never if the user
35+
// navigates quickly.
36+
// 2. `<mat-option aria-describedby="groupLabel"` - This works on Safari, but VoiceOver in Chrome
37+
// won't read out the description at all.
38+
// 3. `<mat-option aria-labelledby="optionLabel groupLabel"` - This works on Chrome, but Safari
39+
// doesn't read out the text at all. Furthermore, on
2040

2141
// Boilerplate for applying mixins to MatOptgroup.
2242
/** @docs-private */
@@ -35,6 +55,14 @@ export class _MatOptgroupBase extends _MatOptgroupMixinBase implements CanDisabl
3555
/** Unique id for the underlying label. */
3656
_labelId: string = `mat-optgroup-label-${_uniqueOptgroupIdCounter++}`;
3757

58+
/** Whether the group is in inert a11y mode. */
59+
_inert: boolean;
60+
61+
constructor(@Inject(MAT_OPTION_PARENT_COMPONENT) @Optional() parent?: MatOptionParentComponent) {
62+
super();
63+
this._inert = parent?.inertGroups ?? false;
64+
}
65+
3866
static ngAcceptInputType_disabled: BooleanInput;
3967
}
4068

@@ -58,10 +86,10 @@ export const MAT_OPTGROUP = new InjectionToken<MatOptgroup>('MatOptgroup');
5886
styleUrls: ['optgroup.css'],
5987
host: {
6088
'class': 'mat-optgroup',
61-
'role': 'group',
89+
'[attr.role]': '_inert ? null : "group"',
90+
'[attr.aria-disabled]': '_inert ? null : disabled.toString()',
91+
'[attr.aria-labelledby]': '_inert ? null : _labelId',
6292
'[class.mat-optgroup-disabled]': 'disabled',
63-
'[attr.aria-disabled]': 'disabled.toString()',
64-
'[attr.aria-labelledby]': '_labelId',
6593
},
6694
providers: [{provide: MAT_OPTGROUP, useExisting: MatOptgroup}],
6795
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {InjectionToken} from '@angular/core';
10+
11+
/**
12+
* Describes a parent component that manages a list of options.
13+
* Contains properties that the options can inherit.
14+
* @docs-private
15+
*/
16+
export interface MatOptionParentComponent {
17+
disableRipple?: boolean;
18+
multiple?: boolean;
19+
inertGroups?: boolean;
20+
}
21+
22+
/**
23+
* Injection token used to provide the parent component to options.
24+
*/
25+
export const MAT_OPTION_PARENT_COMPONENT =
26+
new InjectionToken<MatOptionParentComponent>('MAT_OPTION_PARENT_COMPONENT');
27+

src/material/core/option/option.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33

44
<span class="mat-option-text"><ng-content></ng-content></span>
55

6+
<!-- See a11y notes inside optgroup.ts for context behind this element. -->
7+
<span class="cdk-visually-hidden" *ngIf="group && group._inert">({{ group.label }})</span>
8+
69
<div class="mat-option-ripple" mat-ripple
710
[matRippleTrigger]="_getHostElement()"
811
[matRippleDisabled]="disabled || disableRipple">

src/material/core/option/option.spec.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
dispatchEvent,
99
} from '@angular/cdk/testing/private';
1010
import {SPACE, ENTER} from '@angular/cdk/keycodes';
11-
import {MatOption, MatOptionModule} from './index';
11+
import {MatOption, MatOptionModule, MAT_OPTION_PARENT_COMPONENT} from './index';
1212

1313
describe('MatOption component', () => {
1414

@@ -196,6 +196,37 @@ describe('MatOption component', () => {
196196
expect(optionNativeElement.classList.contains('mat-focus-indicator')).toBe(true);
197197
});
198198

199+
describe('inside inert group', () => {
200+
let fixture: ComponentFixture<InsideGroup>;
201+
202+
beforeEach(waitForAsync(() => {
203+
TestBed.resetTestingModule();
204+
TestBed.configureTestingModule({
205+
imports: [MatOptionModule],
206+
declarations: [InsideGroup],
207+
providers: [{
208+
provide: MAT_OPTION_PARENT_COMPONENT,
209+
useValue: {inertGroups: true}
210+
}]
211+
}).compileComponents();
212+
213+
fixture = TestBed.createComponent(InsideGroup);
214+
fixture.detectChanges();
215+
}));
216+
217+
it('should remove all accessibility-related attributes from the group', () => {
218+
const group: HTMLElement = fixture.nativeElement.querySelector('mat-optgroup');
219+
expect(group.hasAttribute('role')).toBe(false);
220+
expect(group.hasAttribute('aria-disabled')).toBe(false);
221+
expect(group.hasAttribute('aria-labelledby')).toBe(false);
222+
});
223+
224+
it('should mirror the group label inside the option', () => {
225+
const option: HTMLElement = fixture.nativeElement.querySelector('mat-option');
226+
expect(option.textContent?.trim()).toBe('Option(Group)');
227+
});
228+
});
229+
199230
});
200231

201232
@Component({
@@ -205,3 +236,13 @@ class BasicOption {
205236
disabled: boolean;
206237
id: string;
207238
}
239+
240+
@Component({
241+
template: `
242+
<mat-optgroup label="Group">
243+
<mat-option>Option</mat-option>
244+
</mat-optgroup>
245+
`
246+
})
247+
class InsideGroup {
248+
}

src/material/core/option/option.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ import {
1616
ElementRef,
1717
EventEmitter,
1818
Inject,
19-
InjectionToken,
2019
Input,
2120
OnDestroy,
2221
Optional,
@@ -28,6 +27,7 @@ import {
2827
import {FocusOptions, FocusableOption, FocusOrigin} from '@angular/cdk/a11y';
2928
import {Subject} from 'rxjs';
3029
import {MatOptgroup, _MatOptgroupBase, MAT_OPTGROUP} from './optgroup';
30+
import {MatOptionParentComponent, MAT_OPTION_PARENT_COMPONENT} from './option-parent';
3131

3232
/**
3333
* Option IDs need to be unique across components, so this counter exists outside of
@@ -44,23 +44,6 @@ export class MatOptionSelectionChange {
4444
public isUserInput = false) { }
4545
}
4646

47-
/**
48-
* Describes a parent component that manages a list of options.
49-
* Contains properties that the options can inherit.
50-
* @docs-private
51-
*/
52-
export interface MatOptionParentComponent {
53-
disableRipple?: boolean;
54-
multiple?: boolean;
55-
}
56-
57-
/**
58-
* Injection token used to provide the parent component to options.
59-
*/
60-
export const MAT_OPTION_PARENT_COMPONENT =
61-
new InjectionToken<MatOptionParentComponent>('MAT_OPTION_PARENT_COMPONENT');
62-
63-
6447
@Directive()
6548
export class _MatOptionBase implements FocusableOption, AfterViewChecked, OnDestroy {
6649
private _selected = false;

src/material/core/testing/option-harness.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ export class MatOptionHarness extends ComponentHarness {
1414
/** Selector used to locate option instances. */
1515
static hostSelector = '.mat-option';
1616

17+
/** Element containing the option's text. */
18+
private _text = this.locatorFor('.mat-option-text');
19+
1720
/**
1821
* Gets a `HarnessPredicate` that can be used to search for a `MatOptionsHarness` that meets
1922
* certain criteria.
@@ -37,7 +40,7 @@ export class MatOptionHarness extends ComponentHarness {
3740

3841
/** Gets the option's label text. */
3942
async getText(): Promise<string> {
40-
return (await this.host()).text();
43+
return (await this._text()).text();
4144
}
4245

4346
/** Gets whether the option is disabled. */

0 commit comments

Comments
 (0)