Skip to content

Commit 196a245

Browse files
committed
fix(multiple): fix VoiceOver confused by Select/Autocomplete's ARIA semantics
For Select and Autcomplete components, 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. Fix the same issues for both Select and Autocomplete component. Implement fix by correcting the combobox element and also invidual options. 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 c4026fe commit 196a245

File tree

16 files changed

+269
-50
lines changed

16 files changed

+269
-50
lines changed

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,9 @@ 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+
// TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with
194+
// the `SnakBarContainer` and other usages.
195+
//
193196
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
194197
// section of the DOM we need to look through. This should cover all the cases we support, but
195198
// the selector can be expanded if it turns out to be too narrow.

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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@
112112
(ngModelChange)="filteredGroupedStates = filterStateGroups(currentGroupedState)">
113113
</mat-form-field>
114114
</mat-card>
115+
116+
<mat-card>
117+
<mat-card-subtitle>Autocomplete inside a Dialog</mat-card-subtitle>
118+
<mat-card-content>
119+
<button mat-button (click)="openDialog()">Open dialog</button>
120+
</mat-card-content>
121+
</mat-card>
115122
</div>
116123

117124
<mat-autocomplete #groupedAuto="matAutocomplete">

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<AutocompleteDemoExampleDialog> | 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+
<p>I'm a template dialog. I've been opened {{numTemplateOpens}} times!</p>
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: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ import {
5555
MAT_AUTOCOMPLETE_DEFAULT_OPTIONS,
5656
_MatAutocompleteBase,
5757
} from './autocomplete';
58+
import {
59+
addAriaReferencedId,
60+
removeAriaReferencedId,
61+
} from '@angular/cdk/a11y/aria-describer/aria-reference';
5862

5963
/**
6064
* Provider that allows the autocomplete to register as a ControlValueAccessor.
@@ -244,6 +248,7 @@ export abstract class _MatAutocompleteTriggerBase
244248
this._componentDestroyed = true;
245249
this._destroyPanel();
246250
this._closeKeyEventStream.complete();
251+
this._clearFromModals();
247252
}
248253

249254
/** Whether or not the autocomplete panel is open. */
@@ -670,6 +675,8 @@ export abstract class _MatAutocompleteTriggerBase
670675
this.autocomplete._isOpen = this._overlayAttached = true;
671676
this.autocomplete._setColor(this._formField?.color);
672677

678+
this._applyModalPanelOwnership();
679+
673680
// We need to do an extra `panelOpen` check in here, because the
674681
// autocomplete won't be shown if there are no options.
675682
if (this.panelOpen && wasOpen !== this.panelOpen) {
@@ -858,6 +865,64 @@ export abstract class _MatAutocompleteTriggerBase
858865
// but the behvior isn't exactly the same and it ends up breaking some internal tests.
859866
overlayRef.outsidePointerEvents().subscribe();
860867
}
868+
869+
/**
870+
* Track what modals we have modified the `aria-owns` attribute of. When the combobox trigger is
871+
* inside an aria-modal, we apply aria-owns to the parent modal with the `id` of the options
872+
* panel. Track modals we have changed so we can undo the changes on destroy.
873+
*/
874+
private _trackedModals = new Set<Element>();
875+
876+
/**
877+
* If the autocomplete trigger is inside of an `aria-modal` element, connect
878+
* that modal to the options panel with `aria-owns`.
879+
*
880+
* For some browser + screen reader combinations, when navigation is inside
881+
* of an `aria-modal` element, the screen reader treats everything outside
882+
* of that modal as hidden or invisible.
883+
*
884+
* This causes a problem when the combobox trigger is _inside_ of a modal, because the
885+
* options panel is rendered _outside_ of that modal, preventing screen reader navigation
886+
* from reaching the panel.
887+
*
888+
* We can work around this issue by applying `aria-owns` to the modal with the `id` of
889+
* the options panel. This effectively communicates to assistive technology that the
890+
* options panel is part of the same interaction as the modal.
891+
*
892+
* At time of this writing, this issue is present in VoiceOver.
893+
* See https://github.com/angular/components/issues/20694
894+
*/
895+
private _applyModalPanelOwnership() {
896+
// TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with
897+
// the `LiveAnnouncer` and any other usages.
898+
//
899+
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
900+
// section of the DOM we need to look through. This should cover all the cases we support, but
901+
// the selector can be expanded if it turns out to be too narrow.
902+
const modal = this._element.nativeElement.closest(
903+
'body > .cdk-overlay-container [aria-modal="true"]',
904+
);
905+
906+
if (!modal) {
907+
// Most commonly, the autocomplete trigger is not inside a modal.
908+
return;
909+
}
910+
911+
const panelId = this.autocomplete.id;
912+
913+
addAriaReferencedId(modal, 'aria-owns', panelId);
914+
this._trackedModals.add(modal);
915+
}
916+
917+
/** Clears the references to the listbox overlay element from any modals it was added to. */
918+
private _clearFromModals() {
919+
for (const modal of this._trackedModals) {
920+
const panelId = this.autocomplete.id;
921+
922+
removeAriaReferencedId(modal, 'aria-owns', panelId);
923+
this._trackedModals.delete(modal);
924+
}
925+
}
861926
}
862927

863928
@Directive({
@@ -869,7 +934,7 @@ export abstract class _MatAutocompleteTriggerBase
869934
'[attr.aria-autocomplete]': 'autocompleteDisabled ? null : "list"',
870935
'[attr.aria-activedescendant]': '(panelOpen && activeOption) ? activeOption.id : null',
871936
'[attr.aria-expanded]': 'autocompleteDisabled ? null : panelOpen.toString()',
872-
'[attr.aria-owns]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id',
937+
'[attr.aria-controls]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id',
873938
'[attr.aria-haspopup]': 'autocompleteDisabled ? null : "listbox"',
874939
// Note: we use `focusin`, as opposed to `focus`, in order to open the panel
875940
// 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/autocomplete.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,8 @@ export abstract class _MatAutocompleteBase
228228
_classList: {[key: string]: boolean} = {};
229229

230230
/** Unique ID to be used by autocomplete trigger's "aria-owns" property. */
231+
// This string needs to be Regex friendly, so that it can be used in the regex to attach this
232+
// panel to aria modals. See `_applyModalPanelOwnership` method in autocomplete-trigger.ts.
231233
id: string = `mat-autocomplete-${_uniqueAutocompleteIdCounter++}`;
232234

233235
/**

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
}

0 commit comments

Comments
 (0)