Skip to content

Commit 7033c3c

Browse files
committed
fix(material/select): fix VoiceOver confused by ARIA semantics
For Select component, fix issues where VoiceOver was confused by the ARIA semantics of the combobox. Fix multiple behaviors: - Fix VoiceOver focus ring stuck on the combobox while navigating options. - Fix VoiceOver would sometimes reading option as a TextNode and not communicating the selected state and position in set. - Fix VoiceOver "flickering" behavior where VoiceOver would display one announcement then quickly change to another annoucement. Also fix the same issues for Autocomplete component. Implement fix by doing two things. First, move the aria-owns reference to the overlay from the child of the combobox to the parent modal of the comobobx. Having an aria-owns reference inside the combobox element seemed to confuse VoiceOver. Second, apply `aria-hidden="true"` to the ripple element and pseudo checkboxes on mat-option. These DOM nodes are only used for visual purposes, so it is most appropriate to remove them from the accessibility tree. This seemed to make VoiceOver's behavior more consistent. Fix #23202, #19798
1 parent 3aaabbd commit 7033c3c

File tree

15 files changed

+249
-57
lines changed

15 files changed

+249
-57
lines changed

src/cdk/a11y/live-announcer/live-announcer.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,8 @@ export class LiveAnnouncer implements OnDestroy {
190190
* pointing the `aria-owns` of all modals to the live announcer element.
191191
*/
192192
private _exposeAnnouncerToModals(id: string) {
193-
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
194-
// section of the DOM we need to look through. This should cover all the cases we support, but
195-
// the selector can be expanded if it turns out to be too narrow.
193+
// TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with
194+
// the `SnakBarContainer` and any other usages.
196195
const modals = this._document.querySelectorAll(
197196
'body > .cdk-overlay-container [aria-modal="true"]',
198197
);

src/dev-app/autocomplete/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ng_module(
1414
"//src/material/button",
1515
"//src/material/card",
1616
"//src/material/checkbox",
17+
"//src/material/dialog",
1718
"//src/material/form-field",
1819
"//src/material/input",
1920
"@npm//@angular/forms",

src/dev-app/autocomplete/autocomplete-demo.html

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,11 +112,18 @@
112112
(ngModelChange)="filteredGroupedStates = filterStateGroups(currentGroupedState)">
113113
</mat-form-field>
114114
</mat-card>
115+
116+
<mat-card>
117+
<mat-card-subtitle>Dialog integration</mat-card-subtitle>
118+
<mat-card-content>
119+
<button mat-button (click)="openDialog()">Open modal dialog</button>
120+
</mat-card-content>
121+
</mat-card>
115122
</div>
116123

117124
<mat-autocomplete #groupedAuto="matAutocomplete">
118125
<mat-optgroup *ngFor="let group of filteredGroupedStates"
119126
[label]="'States starting with ' + group.letter">
120127
<mat-option *ngFor="let state of group.states" [value]="state.name">{{ state.name }}</mat-option>
121128
</mat-optgroup>
122-
</mat-autocomplete>
129+
</mat-autocomplete>

src/dev-app/autocomplete/autocomplete-demo.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {Component, ViewChild} from '@angular/core';
9+
import {Component, inject, ViewChild} from '@angular/core';
1010
import {FormControl, NgModel, FormsModule, ReactiveFormsModule} from '@angular/forms';
1111
import {CommonModule} from '@angular/common';
1212
import {MatAutocompleteModule} from '@angular/material/autocomplete';
@@ -17,6 +17,7 @@ import {MatInputModule} from '@angular/material/input';
1717
import {Observable} from 'rxjs';
1818
import {map, startWith} from 'rxjs/operators';
1919
import {ThemePalette} from '@angular/material/core';
20+
import {MatDialog, MatDialogModule, MatDialogRef} from '@angular/material/dialog';
2021

2122
export interface State {
2223
code: string;
@@ -43,6 +44,7 @@ type DisableStateOption = 'none' | 'first-middle-last' | 'all';
4344
MatButtonModule,
4445
MatCardModule,
4546
MatCheckboxModule,
47+
MatDialogModule,
4648
MatInputModule,
4749
ReactiveFormsModule,
4850
],
@@ -202,4 +204,64 @@ export class AutocompleteDemo {
202204
}
203205
return false;
204206
}
207+
208+
dialog = inject(MatDialog);
209+
dialogRef: MatDialogRef<any> | null;
210+
211+
openDialog() {
212+
this.dialogRef = this.dialog.open(AutocompleteDemoExampleDialog, {width: '400px'});
213+
}
214+
}
215+
216+
@Component({
217+
selector: 'autocomplete-demo-example-dialog',
218+
template: `
219+
<form (submit)="close()">
220+
<p>Choose a t-shirt size.</p>
221+
<mat-form-field>
222+
<mat-label>T-Shirt Size</mat-label>
223+
<input matInput [matAutocomplete]="tdAuto" [(ngModel)]="currentSize" name="size">
224+
<mat-autocomplete #tdAuto="matAutocomplete">
225+
<mat-option *ngFor="let size of sizes" [value]="size">
226+
{{size}}
227+
</mat-option>
228+
</mat-autocomplete>
229+
</mat-form-field>
230+
231+
<button type="submit" mat-button>Close</button>
232+
</form>
233+
`,
234+
styles: [
235+
`
236+
:host {
237+
display: block;
238+
padding: 20px;
239+
}
240+
241+
form {
242+
display: flex;
243+
flex-direction: column;
244+
align-items: flex-start;
245+
}
246+
`,
247+
],
248+
standalone: true,
249+
imports: [
250+
CommonModule,
251+
FormsModule,
252+
MatAutocompleteModule,
253+
MatButtonModule,
254+
MatDialogModule,
255+
MatInputModule,
256+
],
257+
})
258+
export class AutocompleteDemoExampleDialog {
259+
constructor(public dialogRef: MatDialogRef<AutocompleteDemoExampleDialog>) {}
260+
261+
currentSize = '';
262+
sizes = ['S', 'M', 'L'];
263+
264+
close() {
265+
this.dialogRef.close();
266+
}
205267
}

src/dev-app/dialog/dialog-demo.html

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -116,18 +116,30 @@ <h2>Other options</h2>
116116
<p>Last beforeClose result: {{lastBeforeCloseResult}}</p>
117117

118118
<ng-template let-data let-dialogRef="dialogRef">
119-
I'm a template dialog. I've been opened {{numTemplateOpens}} times!
120-
121-
<p>It's Jazz!</p>
119+
<p>Order printer ink refills.</p>
122120

123121
<mat-form-field>
124-
<mat-label>How much?</mat-label>
122+
<mat-label>How many?</mat-label>
125123
<input matInput #howMuch>
126124
</mat-form-field>
127125

126+
<mat-form-field>
127+
<mat-label>What color?</mat-label>
128+
<mat-select #whatColor>
129+
<mat-option></mat-option>
130+
<mat-option value="black">Black</mat-option>
131+
<mat-option value="cyan">Cyan</mat-option>
132+
<mat-option value="magenta">Magenta</mat-option>
133+
<mat-option value="yellow">Yellow</mat-option>
134+
</mat-select>
135+
</mat-form-field>
136+
128137
<p> {{ data.message }} </p>
129-
<button type="button" (click)="dialogRef.close(howMuch.value)" class="demo-dialog-button"
130-
cdkFocusInitial>
138+
139+
I'm a template dialog. I've been opened {{numTemplateOpens}} times!
140+
141+
<button type="button" class="demo-dialog-button" cdkFocusInitial
142+
(click)="dialogRef.close({ quantity: howMuch.value, color: whatColor.value })">
131143
Close dialog
132144
</button>
133145
<button (click)="dialogRef.updateSize('500px', '500px').updatePosition({top: '25px', left: '25px'});"

src/dev-app/dialog/dialog-demo.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,23 +125,38 @@ export class DialogDemo {
125125
selector: 'demo-jazz-dialog',
126126
template: `
127127
<div cdkDrag cdkDragRootElement=".cdk-overlay-pane">
128-
<p>It's Jazz!</p>
128+
<p>Order printer ink refills.</p>
129129
130130
<mat-form-field>
131-
<mat-label>How much?</mat-label>
131+
<mat-label>How many?</mat-label>
132132
<input matInput #howMuch>
133133
</mat-form-field>
134134
135+
<mat-form-field>
136+
<mat-label>What color?</mat-label>
137+
<mat-select #whatColor>
138+
<mat-option></mat-option>
139+
<mat-option value="black">Black</mat-option>
140+
<mat-option value="cyan">Cyan</mat-option>
141+
<mat-option value="magenta">Magenta</mat-option>
142+
<mat-option value="yellow">Yellow</mat-option>
143+
</mat-select>
144+
</mat-form-field>
145+
135146
<p cdkDragHandle> {{ data.message }} </p>
136-
<button type="button" (click)="dialogRef.close(howMuch.value)">Close dialog</button>
147+
<button type="button" class="demo-dialog-button"
148+
(click)="dialogRef.close({ quantity: howMuch.value, color: whatColor.value })">
149+
150+
Close dialog
151+
</button>
137152
<button (click)="togglePosition()">Change dimensions</button>
138153
<button (click)="temporarilyHide()">Hide for 2 seconds</button>
139154
</div>
140155
`,
141156
encapsulation: ViewEncapsulation.None,
142157
styles: [`.hidden-dialog { opacity: 0; }`],
143158
standalone: true,
144-
imports: [MatInputModule, DragDropModule],
159+
imports: [DragDropModule, MatInputModule, MatSelectModule],
145160
})
146161
export class JazzDialog {
147162
private _dimensionToggle = false;

src/material/autocomplete/autocomplete-trigger.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,7 @@ export abstract class _MatAutocompleteTriggerBase
244244
this._componentDestroyed = true;
245245
this._destroyPanel();
246246
this._closeKeyEventStream.complete();
247+
this._clearFromModals();
247248
}
248249

249250
/** Whether or not the autocomplete panel is open. */
@@ -670,6 +671,8 @@ export abstract class _MatAutocompleteTriggerBase
670671
this.autocomplete._isOpen = this._overlayAttached = true;
671672
this.autocomplete._setColor(this._formField?.color);
672673

674+
this._exposeToModals();
675+
673676
// We need to do an extra `panelOpen` check in here, because the
674677
// autocomplete won't be shown if there are no options.
675678
if (this.panelOpen && wasOpen !== this.panelOpen) {
@@ -858,6 +861,59 @@ export abstract class _MatAutocompleteTriggerBase
858861
// but the behvior isn't exactly the same and it ends up breaking some internal tests.
859862
overlayRef.outsidePointerEvents().subscribe();
860863
}
864+
865+
private _trackedModals = new Set<Element>();
866+
867+
/**
868+
* Some browsers won't expose the accessibility node of the listbox overlay if there is an
869+
* `aria-modal` and the live element is outside of it. This method works around the issue by
870+
* pointing the `aria-owns` of all modals to the live element.
871+
*
872+
* While aria-owns is not required for the ARIA 1.2 `role="combobox"` interaction pattern,
873+
* it fixes an issue with VoiceOver when the autocomplete appears inside of an `aria-model="true"`
874+
* element (e.g. a dialog). Without this `aria-owns`, the `aria-modal` on a dialog prevents
875+
* VoiceOver from "seeing" the autocomplete's listbox overlay for aria-activedescendant.
876+
* Using `aria-owns` re-parents the autocomplete overlay so that it works again.
877+
* See https://github.com/angular/components/issues/20694
878+
*/
879+
private _exposeToModals() {
880+
// TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with
881+
// the `LiveAnnouncer` and any other usages.
882+
const id = this.autocomplete.id;
883+
const modals = this._document.querySelectorAll(
884+
'body > .cdk-overlay-container [aria-modal="true"]',
885+
);
886+
887+
for (let i = 0; i < modals.length; i++) {
888+
const modal = modals[i];
889+
const ariaOwns = modal.getAttribute('aria-owns');
890+
this._trackedModals.add(modal);
891+
892+
if (!ariaOwns) {
893+
modal.setAttribute('aria-owns', id);
894+
} else if (ariaOwns.indexOf(id) === -1) {
895+
modal.setAttribute('aria-owns', ariaOwns + ' ' + id);
896+
}
897+
}
898+
}
899+
900+
/** Clears the references to the listbox overlay element from any modals it was added to. */
901+
private _clearFromModals() {
902+
this._trackedModals.forEach(modal => {
903+
const ariaOwns = modal.getAttribute('aria-owns');
904+
905+
if (ariaOwns) {
906+
const newValue = ariaOwns.replace(`${this.autocomplete.id}-panel`, '').trim();
907+
908+
if (newValue.length > 0) {
909+
modal.setAttribute('aria-owns', newValue);
910+
} else {
911+
modal.removeAttribute('aria-owns');
912+
}
913+
}
914+
});
915+
this._trackedModals.clear();
916+
}
861917
}
862918

863919
@Directive({
@@ -869,7 +925,7 @@ export abstract class _MatAutocompleteTriggerBase
869925
'[attr.aria-autocomplete]': 'autocompleteDisabled ? null : "list"',
870926
'[attr.aria-activedescendant]': '(panelOpen && activeOption) ? activeOption.id : null',
871927
'[attr.aria-expanded]': 'autocompleteDisabled ? null : panelOpen.toString()',
872-
'[attr.aria-owns]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id',
928+
'[attr.aria-controls]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id',
873929
'[attr.aria-haspopup]': 'autocompleteDisabled ? null : "listbox"',
874930
// Note: we use `focusin`, as opposed to `focus`, in order to open the panel
875931
// a little earlier. This avoids issues where IE delays the focusing of the input.

src/material/autocomplete/autocomplete.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1863,26 +1863,26 @@ describe('MDC-based MatAutocomplete', () => {
18631863
.toBe('false');
18641864
}));
18651865

1866-
it('should set aria-owns based on the attached autocomplete', () => {
1866+
it('should set aria-controls based on the attached autocomplete', () => {
18671867
fixture.componentInstance.trigger.openPanel();
18681868
fixture.detectChanges();
18691869

18701870
const panel = fixture.debugElement.query(
18711871
By.css('.mat-mdc-autocomplete-panel'),
18721872
)!.nativeElement;
18731873

1874-
expect(input.getAttribute('aria-owns'))
1875-
.withContext('Expected aria-owns to match attached autocomplete.')
1874+
expect(input.getAttribute('aria-controls'))
1875+
.withContext('Expected aria-controls to match attached autocomplete.')
18761876
.toBe(panel.getAttribute('id'));
18771877
});
18781878

1879-
it('should not set aria-owns while the autocomplete is closed', () => {
1880-
expect(input.getAttribute('aria-owns')).toBeFalsy();
1879+
it('should not set aria-controls while the autocomplete is closed', () => {
1880+
expect(input.getAttribute('aria-controls')).toBeFalsy();
18811881

18821882
fixture.componentInstance.trigger.openPanel();
18831883
fixture.detectChanges();
18841884

1885-
expect(input.getAttribute('aria-owns')).toBeTruthy();
1885+
expect(input.getAttribute('aria-controls')).toBeTruthy();
18861886
});
18871887

18881888
it('should restore focus to the input when clicking to select a value', fakeAsync(() => {

src/material/autocomplete/testing/autocomplete-harness.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ export abstract class _MatAutocompleteHarnessBase<
129129
}
130130

131131
/** Gets the selector that can be used to find the autocomplete trigger's panel. */
132-
private async _getPanelSelector(): Promise<string> {
132+
protected async _getPanelSelector(): Promise<string> {
133133
return `#${await (await this.host()).getAttribute('aria-owns')}`;
134134
}
135135
}
@@ -168,4 +168,9 @@ export class MatAutocompleteHarness extends _MatAutocompleteHarnessBase<
168168
return (await harness.isDisabled()) === disabled;
169169
});
170170
}
171+
172+
/** Gets the selector that can be used to find the autocomplete trigger's panel. */
173+
protected override async _getPanelSelector(): Promise<string> {
174+
return `#${await (await this.host()).getAttribute('aria-controls')}`;
175+
}
171176
}
Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,18 @@
1-
<mat-pseudo-checkbox *ngIf="multiple" class="mat-mdc-option-pseudo-checkbox"
2-
[state]="selected ? 'checked' : 'unchecked'" [disabled]="disabled"></mat-pseudo-checkbox>
1+
<mat-pseudo-checkbox *ngIf="multiple" class="mat-mdc-option-pseudo-checkbox" [disabled]="disabled"
2+
[state]="selected ? 'checked' : 'unchecked'" [attr.aria-hidden]="'true'"></mat-pseudo-checkbox>
33

44
<ng-content select="mat-icon"></ng-content>
55

66
<span class="mdc-list-item__primary-text" #text><ng-content></ng-content></span>
77

88
<!-- Render checkmark at the end for single-selection. -->
99
<mat-pseudo-checkbox *ngIf="!multiple && selected && !hideSingleSelectionIndicator"
10-
class="mat-mdc-option-pseudo-checkbox" state="checked" [disabled]="disabled"
11-
appearance="minimal"></mat-pseudo-checkbox>
10+
class="mat-mdc-option-pseudo-checkbox" [disabled]="disabled" state="checked"
11+
[attr.aria-hidden]="'true'" appearance="minimal"></mat-pseudo-checkbox>
1212

1313
<!-- See a11y notes inside optgroup.ts for context behind this element. -->
1414
<span class="cdk-visually-hidden" *ngIf="group && group._inert">({{ group.label }})</span>
1515

16-
<div class="mat-mdc-option-ripple mat-mdc-focus-indicator" mat-ripple
17-
[matRippleTrigger]="_getHostElement()"
18-
[matRippleDisabled]="disabled || disableRipple">
16+
<div class="mat-mdc-option-ripple mat-mdc-focus-indicator" [attr.aria-hidden]="'true'" mat-ripple
17+
[matRippleTrigger]="_getHostElement()" [matRippleDisabled]="disabled || disableRipple">
1918
</div>

0 commit comments

Comments
 (0)