diff --git a/src/material/autocomplete/autocomplete-trigger.ts b/src/material/autocomplete/autocomplete-trigger.ts index 4e0facbb508c..5e9ad873463f 100644 --- a/src/material/autocomplete/autocomplete-trigger.ts +++ b/src/material/autocomplete/autocomplete-trigger.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import {addAriaReferencedId, removeAriaReferencedId} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {DOWN_ARROW, ENTER, ESCAPE, TAB, UP_ARROW, hasModifierKey} from '@angular/cdk/keycodes'; import {BreakpointObserver, Breakpoints} from '@angular/cdk/layout'; @@ -269,7 +268,6 @@ export class MatAutocompleteTrigger this._componentDestroyed = true; this._destroyPanel(); this._closeKeyEventStream.complete(); - this._clearFromModal(); } /** Whether or not the autocomplete panel is open. */ @@ -327,11 +325,6 @@ export class MatAutocompleteTrigger // user clicks outside. this._changeDetectorRef.detectChanges(); } - - // Remove aria-owns attribute when the autocomplete is no longer visible. - if (this._trackedModal) { - removeAriaReferencedId(this._trackedModal, 'aria-owns', this.autocomplete.id); - } } /** @@ -756,11 +749,6 @@ export class MatAutocompleteTrigger private _openPanelInternal(valueOnAttach = this._element.nativeElement.value) { this._attachOverlay(valueOnAttach); this._floatLabel(); - // Add aria-owns attribute when the autocomplete becomes visible. - if (this._trackedModal) { - const panelId = this.autocomplete.id; - addAriaReferencedId(this._trackedModal, 'aria-owns', panelId); - } } private _attachOverlay(valueOnAttach: string): void { @@ -827,7 +815,6 @@ export class MatAutocompleteTrigger this.autocomplete._latestOpeningTrigger = this; this.autocomplete._setColor(this._formField?.color); this._updatePanelState(); - this._applyModalPanelOwnership(); // We need to do an extra `panelOpen` check in here, because the // autocomplete won't be shown if there are no options. @@ -1036,66 +1023,4 @@ export class MatAutocompleteTrigger } } } - - /** - * Track which modal 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 the modal we have changed so we can undo the changes on destroy. - */ - private _trackedModal: Element | null = null; - - /** - * 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; - - if (this._trackedModal) { - removeAriaReferencedId(this._trackedModal, 'aria-owns', panelId); - } - - addAriaReferencedId(modal, 'aria-owns', panelId); - this._trackedModal = modal; - } - - /** Clears the references to the listbox overlay element from the modal it was added to. */ - private _clearFromModal() { - if (this._trackedModal) { - const panelId = this.autocomplete.id; - - removeAriaReferencedId(this._trackedModal, 'aria-owns', panelId); - this._trackedModal = null; - } - } } diff --git a/src/material/autocomplete/autocomplete.spec.ts b/src/material/autocomplete/autocomplete.spec.ts index 4d86cb58c10e..b15d45573450 100644 --- a/src/material/autocomplete/autocomplete.spec.ts +++ b/src/material/autocomplete/autocomplete.spec.ts @@ -1,6 +1,6 @@ import {Direction} from '@angular/cdk/bidi'; import {DOWN_ARROW, ENTER, ESCAPE, SPACE, TAB, UP_ARROW} from '@angular/cdk/keycodes'; -import {OverlayModule, createCloseScrollStrategy} from '@angular/cdk/overlay'; +import {createCloseScrollStrategy} from '@angular/cdk/overlay'; import {_supportsShadowDom} from '@angular/cdk/platform'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { @@ -16,7 +16,6 @@ import { import { ChangeDetectionStrategy, Component, - ElementRef, Injector, OnDestroy, OnInit, @@ -3953,51 +3952,6 @@ describe('MatAutocomplete', () => { expect(document.querySelectorAll('.mat-pseudo-checkbox').length).toBe(0); }); }); - - describe('when used inside a modal', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - fixture = createComponent(AutocompleteInsideAModal); - fixture.detectChanges(); - }); - - it('should add the id of the autocomplete panel to the aria-owns of the modal', () => { - 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); - }); - - it('should remove the aria-owns attribute of the modal when the autocomplete panel closes', () => { - fixture.componentInstance.trigger.openPanel(); - fixture.componentInstance.trigger.closePanel(); - fixture.detectChanges(); - - const modalElement = fixture.componentInstance.modal.nativeElement; - - expect(modalElement.getAttribute('aria-owns')).toBeFalsy(); - }); - - it('should readd the aria-owns attribute of the modal when the autocomplete panel opens again', () => { - fixture.componentInstance.trigger.openPanel(); - fixture.componentInstance.trigger.closePanel(); - 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 = ` @@ -4547,49 +4501,6 @@ class AutocompleteWithActivatedEvent { @ViewChildren(MatOption) options!: QueryList; } -@Component({ - template: ` - - -
- - Food - - - - @for (food of foods; track food; let index = $index) { - {{food.viewValue}} - } - -
-
- `, - imports: [ - MatAutocomplete, - MatAutocompleteTrigger, - MatOption, - MatInputModule, - ReactiveFormsModule, - OverlayModule, - ], - changeDetection: ChangeDetectionStrategy.Eager, -}) -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; -} - @Component({ template: ` diff --git a/src/material/select/select.spec.ts b/src/material/select/select.spec.ts index 74650b35b7d5..20706994f1d4 100644 --- a/src/material/select/select.spec.ts +++ b/src/material/select/select.spec.ts @@ -15,7 +15,7 @@ import { TAB, UP_ARROW, } from '@angular/cdk/keycodes'; -import {OverlayModule, createCloseScrollStrategy} from '@angular/cdk/overlay'; +import {createCloseScrollStrategy} from '@angular/cdk/overlay'; import {ScrollDispatcher} from '@angular/cdk/scrolling'; import { createKeyboardEvent, @@ -30,7 +30,6 @@ import { ChangeDetectorRef, Component, DebugElement, - ElementRef, Injector, OnInit, QueryList, @@ -1097,27 +1096,6 @@ describe('MatSelect', () => { }); }); - describe('for select inside a modal', () => { - let fixture: ComponentFixture; - - beforeEach(() => { - fixture = TestBed.createComponent(SelectInsideAModal); - fixture.detectChanges(); - }); - - it('should add the id of the select panel to the aria-owns of the modal', () => { - 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; @@ -5482,35 +5460,3 @@ class BasicSelectWithFirstAndLastOptionDisabled { @ViewChild(MatSelect, {static: true}) select!: MatSelect; @ViewChildren(MatOption) options!: QueryList; } - -@Component({ - template: ` - - -
- - Select a food - - @for (food of foods; track food) { - {{ food.viewValue }} - } - - -
-
- `, - imports: [MatSelect, MatOption, MatFormFieldModule, FormsModule, OverlayModule], - changeDetection: ChangeDetectionStrategy.Eager, -}) -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 72389a431936..94efdfec1f28 100644 --- a/src/material/select/select.ts +++ b/src/material/select/select.ts @@ -6,13 +6,7 @@ * found in the LICENSE file at https://angular.dev/license */ -import { - _IdGenerator, - ActiveDescendantKeyManager, - addAriaReferencedId, - LiveAnnouncer, - removeAriaReferencedId, -} from '@angular/cdk/a11y'; +import {_IdGenerator, ActiveDescendantKeyManager, LiveAnnouncer} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {SelectionModel} from '@angular/cdk/collections'; import { @@ -720,7 +714,6 @@ export class MatSelect this._destroy.next(); this._destroy.complete(); this.stateChanges.complete(); - this._clearFromModal(); } /** Toggles the overlay panel open or closed. */ @@ -743,7 +736,6 @@ export class MatSelect this._cleanupDetach?.(); this._overlayWidth = this._getOverlayWidth(this._preferredOverlayOrigin); - this._applyModalPanelOwnership(); this._panelOpen = true; this._overlayDir.positionChange.pipe(take(1)).subscribe(() => { this._changeDetectorRef.detectChanges(); @@ -761,71 +753,6 @@ export class MatSelect Promise.resolve().then(() => this.openedChange.emit(true)); } - /** - * Track which modal 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 the modal we have changed so we can undo the changes on destroy. - */ - private _trackedModal: Element | null = null; - - /** - * 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`; - - if (this._trackedModal) { - removeAriaReferencedId(this._trackedModal, 'aria-owns', panelId); - } - - addAriaReferencedId(modal, 'aria-owns', panelId); - this._trackedModal = modal; - } - - /** Clears the reference to the listbox overlay element from the modal it was added to. */ - private _clearFromModal() { - if (!this._trackedModal) { - // Most commonly, the autocomplete trigger is not used inside a modal. - return; - } - - const panelId = `${this.id}-panel`; - - removeAriaReferencedId(this._trackedModal, 'aria-owns', panelId); - this._trackedModal = null; - } - /** Closes the overlay panel and focuses the host element. */ close(): void { if (this._panelOpen) {