diff --git a/src/material/select/select.html b/src/material/select/select.html
index 3a1c41419414..5ee5974aa442 100644
--- a/src/material/select/select.html
+++ b/src/material/select/select.html
@@ -1,14 +1,4 @@
-
{
ReactiveFormsModule,
FormsModule,
NoopAnimationsModule,
+ OverlayModule,
],
declarations: declarations,
providers: [
@@ -117,6 +119,7 @@ describe('MDC-based MatSelect', () => {
beforeEach(waitForAsync(() => {
configureMatSelectTestingModule([
BasicSelect,
+ SelectInsideAModal,
MultiSelect,
SelectWithGroups,
SelectWithGroupsAndNgContainer,
@@ -155,19 +158,6 @@ describe('MDC-based MatSelect', () => {
expect(ariaControls).toBe(document.querySelector('.mat-mdc-select-panel')!.id);
}));
- it('should point the aria-owns attribute to the listbox on the trigger', fakeAsync(() => {
- const trigger = select.querySelector('.mat-mdc-select-trigger')!;
- expect(trigger.hasAttribute('aria-owns')).toBe(false);
-
- fixture.componentInstance.select.open();
- fixture.detectChanges();
- flush();
-
- const ariaOwns = trigger.getAttribute('aria-owns');
- expect(ariaOwns).toBeTruthy();
- expect(ariaOwns).toBe(document.querySelector('.mat-mdc-select-panel')!.id);
- }));
-
it('should set aria-expanded based on the select open state', fakeAsync(() => {
expect(select.getAttribute('aria-expanded')).toBe('false');
@@ -1172,6 +1162,27 @@ describe('MDC-based MatSelect', () => {
}));
});
+ describe('for select inside a modal', () => {
+ let fixture: ComponentFixture
;
+
+ beforeEach(fakeAsync(() => {
+ fixture = TestBed.createComponent(SelectInsideAModal);
+ fixture.detectChanges();
+ }));
+
+ it('should add the id of the select panel to the aria-owns of the modal', fakeAsync(() => {
+ fixture.componentInstance.select.open();
+ fixture.detectChanges();
+
+ const panelId = `${fixture.componentInstance.select.id}-panel`;
+ const modalElement = fixture.componentInstance.modal.nativeElement;
+
+ expect(modalElement.getAttribute('aria-owns')?.split(' '))
+ .withContext('expecting modal to own the select panel')
+ .toContain(panelId);
+ }));
+ });
+
describe('for options', () => {
let fixture: ComponentFixture;
let trigger: HTMLElement;
@@ -5450,3 +5461,34 @@ class BasicSelectWithFirstAndLastOptionDisabled {
@ViewChild(MatSelect, {static: true}) select: MatSelect;
@ViewChildren(MatOption) options: QueryList;
}
+
+@Component({
+ selector: 'select-inside-a-modal',
+ template: `
+
+
+
+
+ Select a food
+
+ {{ food.viewValue }}
+
+
+
+
+
+ `,
+})
+class SelectInsideAModal {
+ foods = [
+ {value: 'steak-0', viewValue: 'Steak'},
+ {value: 'pizza-1', viewValue: 'Pizza'},
+ {value: 'tacos-2', viewValue: 'Tacos'},
+ ];
+
+ @ViewChild(MatSelect) select: MatSelect;
+ @ViewChildren(MatOption) options: QueryList;
+ @ViewChild('modal') modal: ElementRef;
+}
diff --git a/src/material/select/select.ts b/src/material/select/select.ts
index 6c518afaf31e..ddb608ca6401 100644
--- a/src/material/select/select.ts
+++ b/src/material/select/select.ts
@@ -6,7 +6,12 @@
* found in the LICENSE file at https://angular.io/license
*/
-import {ActiveDescendantKeyManager, LiveAnnouncer} from '@angular/cdk/a11y';
+import {
+ ActiveDescendantKeyManager,
+ LiveAnnouncer,
+ addAriaReferencedId,
+ removeAriaReferencedId,
+} from '@angular/cdk/a11y';
import {Directionality} from '@angular/cdk/bidi';
import {
BooleanInput,
@@ -588,6 +593,7 @@ export abstract class _MatSelectBase
this._destroy.next();
this._destroy.complete();
this.stateChanges.complete();
+ this._clearFromModals();
}
/** Toggles the overlay panel open or closed. */
@@ -598,6 +604,8 @@ export abstract class _MatSelectBase
/** Opens the overlay panel. */
open(): void {
if (this._canOpen()) {
+ this._applyModalPanelOwnership();
+
this._panelOpen = true;
this._keyManager.withHorizontalOrientation(null);
this._highlightCorrectOption();
@@ -605,6 +613,64 @@ export abstract class _MatSelectBase
}
}
+ /**
+ * Track what modals we have modified the `aria-owns` attribute of. When the combobox trigger is
+ * inside an aria-modal, we apply aria-owns to the parent modal with the `id` of the options
+ * panel. Track modals we have changed so we can undo the changes on destroy.
+ */
+ private _trackedModals = new Set();
+
+ /**
+ * If the autocomplete trigger is inside of an `aria-modal` element, connect
+ * that modal to the options panel with `aria-owns`.
+ *
+ * For some browser + screen reader combinations, when navigation is inside
+ * of an `aria-modal` element, the screen reader treats everything outside
+ * of that modal as hidden or invisible.
+ *
+ * This causes a problem when the combobox trigger is _inside_ of a modal, because the
+ * options panel is rendered _outside_ of that modal, preventing screen reader navigation
+ * from reaching the panel.
+ *
+ * We can work around this issue by applying `aria-owns` to the modal with the `id` of
+ * the options panel. This effectively communicates to assistive technology that the
+ * options panel is part of the same interaction as the modal.
+ *
+ * At time of this writing, this issue is present in VoiceOver.
+ * See https://github.com/angular/components/issues/20694
+ */
+ private _applyModalPanelOwnership() {
+ // TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with
+ // the `LiveAnnouncer` and any other usages.
+ //
+ // Note that the selector here is limited to CDK overlays at the moment in order to reduce the
+ // section of the DOM we need to look through. This should cover all the cases we support, but
+ // the selector can be expanded if it turns out to be too narrow.
+ const modal = this._elementRef.nativeElement.closest(
+ 'body > .cdk-overlay-container [aria-modal="true"]',
+ );
+
+ if (!modal) {
+ // Most commonly, the autocomplete trigger is not inside a modal.
+ return;
+ }
+
+ const panelId = `${this.id}-panel`;
+
+ addAriaReferencedId(modal, 'aria-owns', panelId);
+ this._trackedModals.add(modal);
+ }
+
+ /** Clears the references to the listbox overlay element from any modals it was added to. */
+ private _clearFromModals() {
+ for (const modal of this._trackedModals) {
+ const panelId = `${this.id}-panel`;
+
+ removeAriaReferencedId(modal, 'aria-owns', panelId);
+ this._trackedModals.delete(modal);
+ }
+ }
+
/** Closes the overlay panel and focuses the host element. */
close(): void {
if (this._panelOpen) {
diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts
index 1a792f7e79d0..4b01141ecd14 100644
--- a/src/material/snack-bar/snack-bar-container.ts
+++ b/src/material/snack-bar/snack-bar-container.ts
@@ -241,7 +241,9 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
* pointing the `aria-owns` of all modals to the live element.
*/
private _exposeToModals() {
- // TODO(crisbeto): consider de-duplicating this with the `LiveAnnouncer`.
+ // TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with the
+ // `LiveAnnouncer` and any other usages.
+ //
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
// section of the DOM we need to look through. This should cover all the cases we support, but
// the selector can be expanded if it turns out to be too narrow.
diff --git a/tools/public_api_guard/cdk/a11y.md b/tools/public_api_guard/cdk/a11y.md
index 3ab1d6f8427f..a2b136168ad7 100644
--- a/tools/public_api_guard/cdk/a11y.md
+++ b/tools/public_api_guard/cdk/a11y.md
@@ -40,6 +40,9 @@ export class ActiveDescendantKeyManager extends ListKeyManager {
+ protected _getPanelSelector(): Promise;
static hostSelector: string;
// (undocumented)
protected _optionClass: typeof MatOptionHarness;
@@ -45,6 +46,7 @@ export abstract class _MatAutocompleteHarnessBase;
getOptionGroups(filters?: Omit): Promise;
getOptions(filters?: Omit): Promise