/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.dev/license */ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ComponentRef, ElementRef, EmbeddedViewRef, inject, NgZone, OnDestroy, ViewChild, ViewEncapsulation, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; import {matSnackBarAnimations} from './snack-bar-animations'; import { BasePortalOutlet, CdkPortalOutlet, ComponentPortal, DomPortal, TemplatePortal, } from '@angular/cdk/portal'; import {Observable, Subject} from 'rxjs'; import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y'; import {Platform} from '@angular/cdk/platform'; import {AnimationEvent} from '@angular/animations'; import {MatSnackBarConfig} from './snack-bar-config'; /** * Internal component that wraps user-provided snack bar content. * @docs-private */ @Component({ selector: 'mat-snack-bar-container', templateUrl: 'snack-bar-container.html', styleUrl: 'snack-bar-container.css', // In Ivy embedded views will be change detected from their declaration place, rather than // where they were stamped out. This means that we can't have the snack bar container be OnPush, // because it might cause snack bars that were opened from a template not to be out of date. // tslint:disable-next-line:validate-decorators changeDetection: ChangeDetectionStrategy.Default, encapsulation: ViewEncapsulation.None, animations: [matSnackBarAnimations.snackBarState], imports: [CdkPortalOutlet], host: { 'class': 'mdc-snackbar mat-mdc-snack-bar-container', '[@state]': '_animationState', '(@state.done)': 'onAnimationEnd($event)', }, }) export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { private _ngZone = inject(NgZone); private _elementRef = inject>(ElementRef); private _changeDetectorRef = inject(ChangeDetectorRef); private _platform = inject(Platform); snackBarConfig = inject(MatSnackBarConfig); private _document = inject(DOCUMENT); private _trackedModals = new Set(); /** The number of milliseconds to wait before announcing the snack bar's content. */ private readonly _announceDelay: number = 150; /** The timeout for announcing the snack bar's content. */ private _announceTimeoutId: ReturnType; /** Whether the component has been destroyed. */ private _destroyed = false; /** The portal outlet inside of this container into which the snack bar content will be loaded. */ @ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet; /** Subject for notifying that the snack bar has announced to screen readers. */ readonly _onAnnounce: Subject = new Subject(); /** Subject for notifying that the snack bar has exited from view. */ readonly _onExit: Subject = new Subject(); /** Subject for notifying that the snack bar has finished entering the view. */ readonly _onEnter: Subject = new Subject(); /** The state of the snack bar animations. */ _animationState = 'void'; /** aria-live value for the live region. */ _live: AriaLivePoliteness; /** * Element that will have the `mdc-snackbar__label` class applied if the attached component * or template does not have it. This ensures that the appropriate structure, typography, and * color is applied to the attached view. */ @ViewChild('label', {static: true}) _label: ElementRef; /** * Role of the live region. This is only for Firefox as there is a known issue where Firefox + * JAWS does not read out aria-live message. */ _role?: 'status' | 'alert'; /** Unique ID of the aria-live element. */ readonly _liveElementId = inject(_IdGenerator).getId('mat-snack-bar-container-live-'); constructor(...args: unknown[]); constructor() { super(); const config = this.snackBarConfig; // Use aria-live rather than a live role like 'alert' or 'status' // because NVDA and JAWS have show inconsistent behavior with live roles. if (config.politeness === 'assertive' && !config.announcementMessage) { this._live = 'assertive'; } else if (config.politeness === 'off') { this._live = 'off'; } else { this._live = 'polite'; } // Only set role for Firefox. Set role based on aria-live because setting role="alert" implies // aria-live="assertive" which may cause issues if aria-live is set to "polite" above. if (this._platform.FIREFOX) { if (this._live === 'polite') { this._role = 'status'; } if (this._live === 'assertive') { this._role = 'alert'; } } } /** Attach a component portal as content to this snack bar container. */ attachComponentPortal(portal: ComponentPortal): ComponentRef { this._assertNotAttached(); const result = this._portalOutlet.attachComponentPortal(portal); this._afterPortalAttached(); return result; } /** Attach a template portal as content to this snack bar container. */ attachTemplatePortal(portal: TemplatePortal): EmbeddedViewRef { this._assertNotAttached(); const result = this._portalOutlet.attachTemplatePortal(portal); this._afterPortalAttached(); return result; } /** * Attaches a DOM portal to the snack bar container. * @deprecated To be turned into a method. * @breaking-change 10.0.0 */ override attachDomPortal = (portal: DomPortal) => { this._assertNotAttached(); const result = this._portalOutlet.attachDomPortal(portal); this._afterPortalAttached(); return result; }; /** Handle end of animations, updating the state of the snackbar. */ onAnimationEnd(event: AnimationEvent) { const {fromState, toState} = event; if ((toState === 'void' && fromState !== 'void') || toState === 'hidden') { this._completeExit(); } if (toState === 'visible') { // Note: we shouldn't use `this` inside the zone callback, // because it can cause a memory leak. const onEnter = this._onEnter; this._ngZone.run(() => { onEnter.next(); onEnter.complete(); }); } } /** Begin animation of snack bar entrance into view. */ enter(): void { if (!this._destroyed) { this._animationState = 'visible'; // _animationState lives in host bindings and `detectChanges` does not refresh host bindings // so we have to call `markForCheck` to ensure the host view is refreshed eventually. this._changeDetectorRef.markForCheck(); this._changeDetectorRef.detectChanges(); this._screenReaderAnnounce(); } } /** Begin animation of the snack bar exiting from view. */ exit(): Observable { // It's common for snack bars to be opened by random outside calls like HTTP requests or // errors. Run inside the NgZone to ensure that it functions correctly. this._ngZone.run(() => { // Note: this one transitions to `hidden`, rather than `void`, in order to handle the case // where multiple snack bars are opened in quick succession (e.g. two consecutive calls to // `MatSnackBar.open`). this._animationState = 'hidden'; this._changeDetectorRef.markForCheck(); // Mark this element with an 'exit' attribute to indicate that the snackbar has // been dismissed and will soon be removed from the DOM. This is used by the snackbar // test harness. this._elementRef.nativeElement.setAttribute('mat-exit', ''); // If the snack bar hasn't been announced by the time it exits it wouldn't have been open // long enough to visually read it either, so clear the timeout for announcing. clearTimeout(this._announceTimeoutId); }); return this._onExit; } /** Makes sure the exit callbacks have been invoked when the element is destroyed. */ ngOnDestroy() { this._destroyed = true; this._clearFromModals(); this._completeExit(); } /** * Removes the element in a microtask. Helps prevent errors where we end up * removing an element which is in the middle of an animation. */ private _completeExit() { queueMicrotask(() => { this._onExit.next(); this._onExit.complete(); }); } /** * Called after the portal contents have been attached. Can be * used to modify the DOM once it's guaranteed to be in place. */ private _afterPortalAttached() { const element: HTMLElement = this._elementRef.nativeElement; const panelClasses = this.snackBarConfig.panelClass; if (panelClasses) { if (Array.isArray(panelClasses)) { // Note that we can't use a spread here, because IE doesn't support multiple arguments. panelClasses.forEach(cssClass => element.classList.add(cssClass)); } else { element.classList.add(panelClasses); } } this._exposeToModals(); // Check to see if the attached component or template uses the MDC template structure, // specifically the MDC label. If not, the container should apply the MDC label class to this // component's label container, which will apply MDC's label styles to the attached view. const label = this._label.nativeElement; const labelClass = 'mdc-snackbar__label'; label.classList.toggle(labelClass, !label.querySelector(`.${labelClass}`)); } /** * 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(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 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. */ private _assertNotAttached() { if (this._portalOutlet.hasAttached() && (typeof ngDevMode === 'undefined' || ngDevMode)) { throw Error('Attempting to attach snack bar content after content is already attached'); } } /** * Starts a timeout to move the snack bar content to the live region so screen readers will * announce it. */ private _screenReaderAnnounce() { if (!this._announceTimeoutId) { this._ngZone.runOutsideAngular(() => { this._announceTimeoutId = setTimeout(() => { const inertElement = this._elementRef.nativeElement.querySelector('[aria-hidden]'); const liveElement = this._elementRef.nativeElement.querySelector('[aria-live]'); if (inertElement && liveElement) { // If an element in the snack bar content is focused before being moved // track it and restore focus after moving to the live region. let focusedElement: HTMLElement | null = null; if ( this._platform.isBrowser && document.activeElement instanceof HTMLElement && inertElement.contains(document.activeElement) ) { focusedElement = document.activeElement; } inertElement.removeAttribute('aria-hidden'); liveElement.appendChild(inertElement); focusedElement?.focus(); this._onAnnounce.next(); this._onAnnounce.complete(); } }, this._announceDelay); }); } } }