Skip to content

Commit 33a9543

Browse files
authored
fix(multiple): fix VoiceOver confused by Select/Autocomplete's ARIA semantics (#26861)
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 Fix #19798
1 parent e4552da commit 33a9543

File tree

18 files changed

+405
-53
lines changed

18 files changed

+405
-53
lines changed

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

+3
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/cdk/a11y/public-api.ts

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88
export * from './aria-describer/aria-describer';
9+
export * from './aria-describer/aria-reference';
910
export * from './key-manager/activedescendant-key-manager';
1011
export * from './key-manager/focus-key-manager';
1112
export * from './key-manager/list-key-manager';

src/dev-app/autocomplete/BUILD.bazel

+1
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

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

+63-1
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

+18-6
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

+19-4
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/BUILD.bazel

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ ng_module(
1414
":autocomplete_scss",
1515
] + glob(["**/*.html"]),
1616
deps = [
17+
"//src/cdk/a11y",
1718
"//src/cdk/coercion",
1819
"//src/cdk/overlay",
1920
"//src/cdk/scrolling",

src/material/autocomplete/autocomplete-trigger.ts

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

9+
import {addAriaReferencedId, removeAriaReferencedId} from '@angular/cdk/a11y';
910
import {
1011
AfterViewInit,
1112
ChangeDetectorRef,
@@ -244,6 +245,7 @@ export abstract class _MatAutocompleteTriggerBase
244245
this._componentDestroyed = true;
245246
this._destroyPanel();
246247
this._closeKeyEventStream.complete();
248+
this._clearFromModal();
247249
}
248250

249251
/** Whether or not the autocomplete panel is open. */
@@ -670,6 +672,8 @@ export abstract class _MatAutocompleteTriggerBase
670672
this.autocomplete._isOpen = this._overlayAttached = true;
671673
this.autocomplete._setColor(this._formField?.color);
672674

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

863929
@Directive({
@@ -869,7 +935,7 @@ export abstract class _MatAutocompleteTriggerBase
869935
'[attr.aria-autocomplete]': 'autocompleteDisabled ? null : "list"',
870936
'[attr.aria-activedescendant]': '(panelOpen && activeOption) ? activeOption.id : null',
871937
'[attr.aria-expanded]': 'autocompleteDisabled ? null : panelOpen.toString()',
872-
'[attr.aria-owns]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id',
938+
'[attr.aria-controls]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id',
873939
'[attr.aria-haspopup]': 'autocompleteDisabled ? null : "listbox"',
874940
// Note: we use `focusin`, as opposed to `focus`, in order to open the panel
875941
// a little earlier. This avoids issues where IE delays the focusing of the input.

0 commit comments

Comments
 (0)