Skip to content

Commit 5ea4ca5

Browse files
authored
fix(material/snack-bar): live region not working when modal is open (#26725)
Implements a workaround to the issue where some browsers don't expose live elements to the accessibility tree if there's an `aria-modal` on the page. Fixes #26708.
1 parent 168619b commit 5ea4ca5

File tree

4 files changed

+62
-2
lines changed

4 files changed

+62
-2
lines changed

src/material/legacy-snack-bar/snack-bar-container.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@
44
</div>
55

66
<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
7-
<div [attr.aria-live]="_live" [attr.role]="_role"></div>
7+
<div [attr.aria-live]="_live" [attr.role]="_role" [attr.id]="_liveElementId"></div>

src/material/snack-bar/snack-bar-container.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
</div>
1111

1212
<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
13-
<div [attr.aria-live]="_live" [attr.role]="_role"></div>
13+
<div [attr.aria-live]="_live" [attr.role]="_role" [attr.id]="_liveElementId"></div>
1414
</div>
1515
</div>

src/material/snack-bar/snack-bar-container.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ import {
1414
Directive,
1515
ElementRef,
1616
EmbeddedViewRef,
17+
inject,
1718
NgZone,
1819
OnDestroy,
1920
ViewChild,
2021
ViewEncapsulation,
2122
} from '@angular/core';
23+
import {DOCUMENT} from '@angular/common';
2224
import {matSnackBarAnimations} from './snack-bar-animations';
2325
import {
2426
BasePortalOutlet,
@@ -34,12 +36,17 @@ import {AnimationEvent} from '@angular/animations';
3436
import {take} from 'rxjs/operators';
3537
import {MatSnackBarConfig} from './snack-bar-config';
3638

39+
let uniqueId = 0;
40+
3741
/**
3842
* Base class for snack bar containers.
3943
* @docs-private
4044
*/
4145
@Directive()
4246
export abstract class _MatSnackBarContainerBase extends BasePortalOutlet implements OnDestroy {
47+
private _document = inject(DOCUMENT);
48+
private _trackedModals = new Set<Element>();
49+
4350
/** The number of milliseconds to wait before announcing the snack bar's content. */
4451
private readonly _announceDelay: number = 150;
4552

@@ -73,6 +80,9 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
7380
*/
7481
_role?: 'status' | 'alert';
7582

83+
/** Unique ID of the aria-live element. */
84+
readonly _liveElementId = `mat-snack-bar-container-live-${uniqueId++}`;
85+
7686
constructor(
7787
private _ngZone: NgZone,
7888
protected _elementRef: ElementRef<HTMLElement>,
@@ -188,6 +198,7 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
188198
/** Makes sure the exit callbacks have been invoked when the element is destroyed. */
189199
ngOnDestroy() {
190200
this._destroyed = true;
201+
this._clearFromModals();
191202
this._completeExit();
192203
}
193204

@@ -220,6 +231,54 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
220231
element.classList.add(panelClasses);
221232
}
222233
}
234+
235+
this._exposeToModals();
236+
}
237+
238+
/**
239+
* Some browsers won't expose the accessibility node of the live element if there is an
240+
* `aria-modal` and the live element is outside of it. This method works around the issue by
241+
* pointing the `aria-owns` of all modals to the live element.
242+
*/
243+
private _exposeToModals() {
244+
// TODO(crisbeto): consider de-duplicating this with the `LiveAnnouncer`.
245+
// Note that the selector here is limited to CDK overlays at the moment in order to reduce the
246+
// section of the DOM we need to look through. This should cover all the cases we support, but
247+
// the selector can be expanded if it turns out to be too narrow.
248+
const id = this._liveElementId;
249+
const modals = this._document.querySelectorAll(
250+
'body > .cdk-overlay-container [aria-modal="true"]',
251+
);
252+
253+
for (let i = 0; i < modals.length; i++) {
254+
const modal = modals[i];
255+
const ariaOwns = modal.getAttribute('aria-owns');
256+
this._trackedModals.add(modal);
257+
258+
if (!ariaOwns) {
259+
modal.setAttribute('aria-owns', id);
260+
} else if (ariaOwns.indexOf(id) === -1) {
261+
modal.setAttribute('aria-owns', ariaOwns + ' ' + id);
262+
}
263+
}
264+
}
265+
266+
/** Clears the references to the live element from any modals it was added to. */
267+
private _clearFromModals() {
268+
this._trackedModals.forEach(modal => {
269+
const ariaOwns = modal.getAttribute('aria-owns');
270+
271+
if (ariaOwns) {
272+
const newValue = ariaOwns.replace(this._liveElementId, '').trim();
273+
274+
if (newValue.length > 0) {
275+
modal.setAttribute('aria-owns', newValue);
276+
} else {
277+
modal.removeAttribute('aria-owns');
278+
}
279+
}
280+
});
281+
this._trackedModals.clear();
223282
}
224283

225284
/** Asserts that no content is already attached to the container. */

tools/public_api_guard/material/snack-bar.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
142142
enter(): void;
143143
exit(): Observable<void>;
144144
_live: AriaLivePoliteness;
145+
readonly _liveElementId: string;
145146
ngOnDestroy(): void;
146147
onAnimationEnd(event: AnimationEvent_2): void;
147148
readonly _onAnnounce: Subject<void>;

0 commit comments

Comments
 (0)