Skip to content

Commit

Permalink
fix(material/snack-bar): live region not working when modal is open (#…
Browse files Browse the repository at this point in the history
…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.

(cherry picked from commit 5ea4ca5)
  • Loading branch information
crisbeto committed Mar 6, 2023
1 parent e7612c7 commit ec824f1
Show file tree
Hide file tree
Showing 4 changed files with 62 additions and 2 deletions.
2 changes: 1 addition & 1 deletion src/material/legacy-snack-bar/snack-bar-container.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
</div>

<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
<div [attr.aria-live]="_live" [attr.role]="_role"></div>
<div [attr.aria-live]="_live" [attr.role]="_role" [attr.id]="_liveElementId"></div>
2 changes: 1 addition & 1 deletion src/material/snack-bar/snack-bar-container.html
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
</div>

<!-- Will receive the snack bar content from the non-live div, move will happen a short delay after opening -->
<div [attr.aria-live]="_live" [attr.role]="_role"></div>
<div [attr.aria-live]="_live" [attr.role]="_role" [attr.id]="_liveElementId"></div>
</div>
</div>
59 changes: 59 additions & 0 deletions src/material/snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import {
Directive,
ElementRef,
EmbeddedViewRef,
inject,
NgZone,
OnDestroy,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {DOCUMENT} from '@angular/common';
import {matSnackBarAnimations} from './snack-bar-animations';
import {
BasePortalOutlet,
Expand All @@ -34,12 +36,17 @@ import {AnimationEvent} from '@angular/animations';
import {take} from 'rxjs/operators';
import {MatSnackBarConfig} from './snack-bar-config';

let uniqueId = 0;

/**
* Base class for snack bar containers.
* @docs-private
*/
@Directive()
export abstract class _MatSnackBarContainerBase extends BasePortalOutlet implements OnDestroy {
private _document = inject(DOCUMENT);
private _trackedModals = new Set<Element>();

/** The number of milliseconds to wait before announcing the snack bar's content. */
private readonly _announceDelay: number = 150;

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

/** Unique ID of the aria-live element. */
readonly _liveElementId = `mat-snack-bar-container-live-${uniqueId++}`;

constructor(
private _ngZone: NgZone,
protected _elementRef: ElementRef<HTMLElement>,
Expand Down Expand Up @@ -188,6 +198,7 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
/** Makes sure the exit callbacks have been invoked when the element is destroyed. */
ngOnDestroy() {
this._destroyed = true;
this._clearFromModals();
this._completeExit();
}

Expand Down Expand Up @@ -220,6 +231,54 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
element.classList.add(panelClasses);
}
}

this._exposeToModals();
}

/**
* Some browsers won't expose the accessibility node of the live element if there is an
* `aria-modal` and the live element is outside of it. This method works around the issue by
* pointing the `aria-owns` of all modals to the live element.
*/
private _exposeToModals() {
// TODO(crisbeto): consider de-duplicating this with the `LiveAnnouncer`.
// 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 id = this._liveElementId;
const modals = this._document.querySelectorAll(
'body > .cdk-overlay-container [aria-modal="true"]',
);

for (let i = 0; i < modals.length; i++) {
const modal = modals[i];
const ariaOwns = modal.getAttribute('aria-owns');
this._trackedModals.add(modal);

if (!ariaOwns) {
modal.setAttribute('aria-owns', id);
} else if (ariaOwns.indexOf(id) === -1) {
modal.setAttribute('aria-owns', ariaOwns + ' ' + id);
}
}
}

/** Clears the references to the live element from any modals it was added to. */
private _clearFromModals() {
this._trackedModals.forEach(modal => {
const ariaOwns = modal.getAttribute('aria-owns');

if (ariaOwns) {
const newValue = ariaOwns.replace(this._liveElementId, '').trim();

if (newValue.length > 0) {
modal.setAttribute('aria-owns', newValue);
} else {
modal.removeAttribute('aria-owns');
}
}
});
this._trackedModals.clear();
}

/** Asserts that no content is already attached to the container. */
Expand Down
1 change: 1 addition & 0 deletions tools/public_api_guard/material/snack-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ export abstract class _MatSnackBarContainerBase extends BasePortalOutlet impleme
enter(): void;
exit(): Observable<void>;
_live: AriaLivePoliteness;
readonly _liveElementId: string;
ngOnDestroy(): void;
onAnimationEnd(event: AnimationEvent_2): void;
readonly _onAnnounce: Subject<void>;
Expand Down

0 comments on commit ec824f1

Please sign in to comment.