sass-references/angular-material/material/snack-bar/snack-bar-container.ts

359 lines
13 KiB
TypeScript
Raw Permalink Normal View History

2024-12-06 10:42:08 +08:00
/**
* @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<HTMLElement>>(ElementRef);
private _changeDetectorRef = inject(ChangeDetectorRef);
private _platform = inject(Platform);
snackBarConfig = inject(MatSnackBarConfig);
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;
/** The timeout for announcing the snack bar's content. */
private _announceTimeoutId: ReturnType<typeof setTimeout>;
/** 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<void> = new Subject();
/** Subject for notifying that the snack bar has exited from view. */
readonly _onExit: Subject<void> = new Subject();
/** Subject for notifying that the snack bar has finished entering the view. */
readonly _onEnter: Subject<void> = 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<T>(portal: ComponentPortal<T>): ComponentRef<T> {
this._assertNotAttached();
const result = this._portalOutlet.attachComponentPortal(portal);
this._afterPortalAttached();
return result;
}
/** Attach a template portal as content to this snack bar container. */
attachTemplatePortal<C>(portal: TemplatePortal<C>): EmbeddedViewRef<C> {
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<void> {
// 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);
});
}
}
}