diff --git a/src/cdk/a11y/live-announcer/live-announcer.ts b/src/cdk/a11y/live-announcer/live-announcer.ts index 9706aa96c0b1..08cf51f750aa 100644 --- a/src/cdk/a11y/live-announcer/live-announcer.ts +++ b/src/cdk/a11y/live-announcer/live-announcer.ts @@ -190,6 +190,9 @@ export class LiveAnnouncer implements OnDestroy { * pointing the `aria-owns` of all modals to the live announcer element. */ private _exposeAnnouncerToModals(id: string) { + // TODO(http://github.com/angular/components/issues/26853): consider de-duplicating this with + // the `SnakBarContainer` and 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/src/cdk/a11y/public-api.ts b/src/cdk/a11y/public-api.ts index 3d805b97d6dd..af4e24404387 100644 --- a/src/cdk/a11y/public-api.ts +++ b/src/cdk/a11y/public-api.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ export * from './aria-describer/aria-describer'; +export * from './aria-describer/aria-reference'; export * from './key-manager/activedescendant-key-manager'; export * from './key-manager/focus-key-manager'; export * from './key-manager/list-key-manager'; diff --git a/src/dev-app/autocomplete/BUILD.bazel b/src/dev-app/autocomplete/BUILD.bazel index efc5063c8511..27f1e500850e 100644 --- a/src/dev-app/autocomplete/BUILD.bazel +++ b/src/dev-app/autocomplete/BUILD.bazel @@ -14,6 +14,7 @@ ng_module( "//src/material/button", "//src/material/card", "//src/material/checkbox", + "//src/material/dialog", "//src/material/form-field", "//src/material/input", "@npm//@angular/forms", diff --git a/src/dev-app/autocomplete/autocomplete-demo.html b/src/dev-app/autocomplete/autocomplete-demo.html index 080723351160..2502c1584c65 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.html +++ b/src/dev-app/autocomplete/autocomplete-demo.html @@ -112,6 +112,13 @@ (ngModelChange)="filteredGroupedStates = filterStateGroups(currentGroupedState)"> + + + Autocomplete inside a Dialog + + + + diff --git a/src/dev-app/autocomplete/autocomplete-demo.ts b/src/dev-app/autocomplete/autocomplete-demo.ts index 3cfad904ea49..bb1f6ff02c68 100644 --- a/src/dev-app/autocomplete/autocomplete-demo.ts +++ b/src/dev-app/autocomplete/autocomplete-demo.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Component, ViewChild} from '@angular/core'; +import {Component, inject, ViewChild} from '@angular/core'; import {FormControl, NgModel, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {CommonModule} from '@angular/common'; import {MatAutocompleteModule} from '@angular/material/autocomplete'; @@ -17,6 +17,7 @@ import {MatInputModule} from '@angular/material/input'; import {Observable} from 'rxjs'; import {map, startWith} from 'rxjs/operators'; import {ThemePalette} from '@angular/material/core'; +import {MatDialog, MatDialogModule, MatDialogRef} from '@angular/material/dialog'; export interface State { code: string; @@ -43,6 +44,7 @@ type DisableStateOption = 'none' | 'first-middle-last' | 'all'; MatButtonModule, MatCardModule, MatCheckboxModule, + MatDialogModule, MatInputModule, ReactiveFormsModule, ], @@ -202,4 +204,64 @@ export class AutocompleteDemo { } return false; } + + dialog = inject(MatDialog); + dialogRef: MatDialogRef | null; + + openDialog() { + this.dialogRef = this.dialog.open(AutocompleteDemoExampleDialog, {width: '400px'}); + } +} + +@Component({ + selector: 'autocomplete-demo-example-dialog', + template: ` +
+

Choose a T-shirt size.

+ + T-Shirt Size + + + + {{size}} + + + + + +
+ `, + styles: [ + ` + :host { + display: block; + padding: 20px; + } + + form { + display: flex; + flex-direction: column; + align-items: flex-start; + } + `, + ], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatAutocompleteModule, + MatButtonModule, + MatDialogModule, + MatInputModule, + ], +}) +export class AutocompleteDemoExampleDialog { + constructor(public dialogRef: MatDialogRef) {} + + currentSize = ''; + sizes = ['S', 'M', 'L']; + + close() { + this.dialogRef.close(); + } } diff --git a/src/dev-app/dialog/dialog-demo.html b/src/dev-app/dialog/dialog-demo.html index 8aa566b7d247..5a00217091ca 100644 --- a/src/dev-app/dialog/dialog-demo.html +++ b/src/dev-app/dialog/dialog-demo.html @@ -116,18 +116,30 @@

Other options

Last beforeClose result: {{lastBeforeCloseResult}}

- I'm a template dialog. I've been opened {{numTemplateOpens}} times! - -

It's Jazz!

+

Order printer ink refills.

- How much? + How many? + + What color? + + + Black + Cyan + Magenta + Yellow + + +

{{ data.message }}

- + @@ -141,7 +156,7 @@ export class DialogDemo { encapsulation: ViewEncapsulation.None, styles: [`.hidden-dialog { opacity: 0; }`], standalone: true, - imports: [MatInputModule, DragDropModule], + imports: [DragDropModule, MatInputModule, MatSelectModule], }) export class JazzDialog { private _dimensionToggle = false; diff --git a/src/material/autocomplete/BUILD.bazel b/src/material/autocomplete/BUILD.bazel index ebd6c1090507..6c6607f3c27a 100644 --- a/src/material/autocomplete/BUILD.bazel +++ b/src/material/autocomplete/BUILD.bazel @@ -14,6 +14,7 @@ ng_module( ":autocomplete_scss", ] + glob(["**/*.html"]), deps = [ + "//src/cdk/a11y", "//src/cdk/coercion", "//src/cdk/overlay", "//src/cdk/scrolling", diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index d3a39199bf2c..8d8942ecbdf9 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -6,6 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ +import {addAriaReferencedId, removeAriaReferencedId} from '@angular/cdk/a11y'; import { AfterViewInit, ChangeDetectorRef, @@ -244,6 +245,7 @@ export abstract class _MatAutocompleteTriggerBase this._componentDestroyed = true; this._destroyPanel(); this._closeKeyEventStream.complete(); + this._clearFromModals(); } /** Whether or not the autocomplete panel is open. */ @@ -670,6 +672,8 @@ export abstract class _MatAutocompleteTriggerBase this.autocomplete._isOpen = this._overlayAttached = true; this.autocomplete._setColor(this._formField?.color); + this._applyModalPanelOwnership(); + // We need to do an extra `panelOpen` check in here, because the // autocomplete won't be shown if there are no options. if (this.panelOpen && wasOpen !== this.panelOpen) { @@ -858,6 +862,64 @@ export abstract class _MatAutocompleteTriggerBase // but the behvior isn't exactly the same and it ends up breaking some internal tests. overlayRef.outsidePointerEvents().subscribe(); } + + /** + * 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._element.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.autocomplete.id; + + 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.autocomplete.id; + + removeAriaReferencedId(modal, 'aria-owns', panelId); + this._trackedModals.delete(modal); + } + } } @Directive({ @@ -869,7 +931,7 @@ export abstract class _MatAutocompleteTriggerBase '[attr.aria-autocomplete]': 'autocompleteDisabled ? null : "list"', '[attr.aria-activedescendant]': '(panelOpen && activeOption) ? activeOption.id : null', '[attr.aria-expanded]': 'autocompleteDisabled ? null : panelOpen.toString()', - '[attr.aria-owns]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id', + '[attr.aria-controls]': '(autocompleteDisabled || !panelOpen) ? null : autocomplete?.id', '[attr.aria-haspopup]': 'autocompleteDisabled ? null : "listbox"', // Note: we use `focusin`, as opposed to `focus`, in order to open the panel // a little earlier. This avoids issues where IE delays the focusing of the input. diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index df5264503e01..f3b34b606873 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -1,6 +1,6 @@ import {Directionality} from '@angular/cdk/bidi'; import {DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes'; -import {Overlay, OverlayContainer} from '@angular/cdk/overlay'; +import {Overlay, OverlayContainer, OverlayModule} from '@angular/cdk/overlay'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { @@ -16,6 +16,7 @@ import { import { ChangeDetectionStrategy, Component, + ElementRef, NgZone, OnDestroy, OnInit, @@ -69,6 +70,7 @@ describe('MDC-based MatAutocomplete', () => { FormsModule, ReactiveFormsModule, NoopAnimationsModule, + OverlayModule, ], declarations: [component], providers: [{provide: NgZone, useFactory: () => (zone = new MockNgZone())}, ...providers], @@ -1863,7 +1865,7 @@ describe('MDC-based MatAutocomplete', () => { .toBe('false'); })); - it('should set aria-owns based on the attached autocomplete', () => { + it('should set aria-controls based on the attached autocomplete', () => { fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); @@ -1871,18 +1873,18 @@ describe('MDC-based MatAutocomplete', () => { By.css('.mat-mdc-autocomplete-panel'), )!.nativeElement; - expect(input.getAttribute('aria-owns')) - .withContext('Expected aria-owns to match attached autocomplete.') + expect(input.getAttribute('aria-controls')) + .withContext('Expected aria-controls to match attached autocomplete.') .toBe(panel.getAttribute('id')); }); - it('should not set aria-owns while the autocomplete is closed', () => { - expect(input.getAttribute('aria-owns')).toBeFalsy(); + it('should not set aria-controls while the autocomplete is closed', () => { + expect(input.getAttribute('aria-controls')).toBeFalsy(); fixture.componentInstance.trigger.openPanel(); fixture.detectChanges(); - expect(input.getAttribute('aria-owns')).toBeTruthy(); + expect(input.getAttribute('aria-controls')).toBeTruthy(); }); it('should restore focus to the input when clicking to select a value', fakeAsync(() => { @@ -3474,6 +3476,27 @@ describe('MDC-based MatAutocomplete', () => { expect(document.querySelectorAll('.mat-pseudo-checkbox').length).toBe(0); }); }); + + describe('when used inside a modal', () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + fixture = createComponent(AutocompleteInsideAModal); + fixture.detectChanges(); + })); + + it('should add the id of the autocomplete panel to the aria-owns of the modal', fakeAsync(() => { + fixture.componentInstance.trigger.openPanel(); + fixture.detectChanges(); + + const panelId = fixture.componentInstance.autocomplete.id; + const modalElement = fixture.componentInstance.modal.nativeElement; + + expect(modalElement.getAttribute('aria-owns')?.split(' ')) + .withContext('expecting modal to own the autocommplete panel') + .toContain(panelId); + })); + }); }); const SIMPLE_AUTOCOMPLETE_TEMPLATE = ` @@ -3897,3 +3920,38 @@ class AutocompleteWithActivatedEvent { @ViewChild(MatAutocomplete) autocomplete: MatAutocomplete; @ViewChildren(MatOption) options: QueryList; } + +@Component({ + selector: 'autocomplete-inside-a-modal', + template: ` + + +
+ + Food + + + + + {{food.viewValue}} + + +
+
+ `, +}) +class AutocompleteInsideAModal { + foods = [ + {value: 'steak-0', viewValue: 'Steak'}, + {value: 'pizza-1', viewValue: 'Pizza'}, + {value: 'tacos-2', viewValue: 'Tacos'}, + ]; + + formControl = new FormControl(); + + @ViewChild(MatAutocomplete) autocomplete: MatAutocomplete; + @ViewChild(MatAutocompleteTrigger) trigger: MatAutocompleteTrigger; + @ViewChildren(MatOption) options: QueryList; + @ViewChild('modal') modal: ElementRef; +} diff --git a/src/material/autocomplete/testing/autocomplete-harness.ts b/src/material/autocomplete/testing/autocomplete-harness.ts index 93ff6ccf9255..a777e7c34dbc 100644 --- a/src/material/autocomplete/testing/autocomplete-harness.ts +++ b/src/material/autocomplete/testing/autocomplete-harness.ts @@ -129,7 +129,7 @@ export abstract class _MatAutocompleteHarnessBase< } /** Gets the selector that can be used to find the autocomplete trigger's panel. */ - private async _getPanelSelector(): Promise { + protected async _getPanelSelector(): Promise { return `#${await (await this.host()).getAttribute('aria-owns')}`; } } @@ -168,4 +168,9 @@ export class MatAutocompleteHarness extends _MatAutocompleteHarnessBase< return (await harness.isDisabled()) === disabled; }); } + + /** Gets the selector that can be used to find the autocomplete trigger's panel. */ + protected override async _getPanelSelector(): Promise { + return `#${await (await this.host()).getAttribute('aria-controls')}`; + } } diff --git a/src/material/core/option/option.html b/src/material/core/option/option.html index aff571f282f3..19adfc3b2ac2 100644 --- a/src/material/core/option/option.html +++ b/src/material/core/option/option.html @@ -1,5 +1,5 @@ - + @@ -7,13 +7,12 @@ + class="mat-mdc-option-pseudo-checkbox" [disabled]="disabled" state="checked" + [attr.aria-hidden]="'true'" appearance="minimal"> ({{ group.label }}) -
+
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; + protected _getPanelSelector(): Promise; getValue(): Promise; isDisabled(): Promise; isFocused(): Promise;