77 */
88
99import {
10+ afterRender ,
11+ AfterRenderRef ,
12+ ANIMATION_MODULE_TYPE ,
1013 ChangeDetectionStrategy ,
1114 ChangeDetectorRef ,
1215 Component ,
@@ -20,19 +23,21 @@ import {
2023 ViewEncapsulation ,
2124} from '@angular/core' ;
2225import { DOCUMENT } from '@angular/common' ;
23- import { matSnackBarAnimations } from './snack-bar-animations' ;
2426import {
2527 BasePortalOutlet ,
2628 CdkPortalOutlet ,
2729 ComponentPortal ,
2830 DomPortal ,
2931 TemplatePortal ,
3032} from '@angular/cdk/portal' ;
31- import { Observable , Subject } from 'rxjs' ;
33+ import { Observable , Subject , of } from 'rxjs' ;
3234import { _IdGenerator , AriaLivePoliteness } from '@angular/cdk/a11y' ;
3335import { Platform } from '@angular/cdk/platform' ;
34- import { AnimationEvent } from '@angular/animations' ;
3536import { MatSnackBarConfig } from './snack-bar-config' ;
37+ import { take } from 'rxjs/operators' ;
38+
39+ const ENTER_ANIMATION = '_mat-snack-bar-enter' ;
40+ const EXIT_ANIMATION = '_mat-snack-bar-exit' ;
3641
3742/**
3843 * Internal component that wraps user-provided snack bar content.
@@ -48,23 +53,31 @@ import {MatSnackBarConfig} from './snack-bar-config';
4853 // tslint:disable-next-line:validate-decorators
4954 changeDetection : ChangeDetectionStrategy . Default ,
5055 encapsulation : ViewEncapsulation . None ,
51- animations : [ matSnackBarAnimations . snackBarState ] ,
5256 imports : [ CdkPortalOutlet ] ,
5357 host : {
5458 'class' : 'mdc-snackbar mat-mdc-snack-bar-container' ,
55- '[@state]' : '_animationState' ,
56- '(@state.done)' : 'onAnimationEnd($event)' ,
59+ '[class.mat-snack-bar-container-enter]' : '_animationState === "visible"' ,
60+ '[class.mat-snack-bar-container-exit]' : '_animationState === "hidden"' ,
61+ '[class.mat-snack-bar-container-animations-enabled]' : '!_animationsDisabled' ,
62+ '(animationend)' : 'onAnimationEnd($event.animationName)' ,
63+ '(animationcancel)' : 'onAnimationEnd($event.animationName)' ,
5764 } ,
5865} )
5966export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
6067 private _ngZone = inject ( NgZone ) ;
6168 private _elementRef = inject < ElementRef < HTMLElement > > ( ElementRef ) ;
6269 private _changeDetectorRef = inject ( ChangeDetectorRef ) ;
6370 private _platform = inject ( Platform ) ;
71+ private _rendersRef : AfterRenderRef ;
72+ protected _animationsDisabled =
73+ inject ( ANIMATION_MODULE_TYPE , { optional : true } ) === 'NoopAnimations' ;
6474 snackBarConfig = inject ( MatSnackBarConfig ) ;
6575
6676 private _document = inject ( DOCUMENT ) ;
6777 private _trackedModals = new Set < Element > ( ) ;
78+ private _enterFallback : ReturnType < typeof setTimeout > | undefined ;
79+ private _exitFallback : ReturnType < typeof setTimeout > | undefined ;
80+ private _renders = new Subject < void > ( ) ;
6881
6982 /** The number of milliseconds to wait before announcing the snack bar's content. */
7083 private readonly _announceDelay : number = 150 ;
@@ -135,6 +148,11 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
135148 this . _role = 'alert' ;
136149 }
137150 }
151+
152+ // Note: ideally we'd just do an `afterNextRender` in the places where we need to delay
153+ // something, however in some cases (TestBed teardown) the injector can be destroyed at an
154+ // unexpected time, causing the `afterRender` to fail.
155+ this . _rendersRef = afterRender ( ( ) => this . _renders . next ( ) , { manualCleanup : true } ) ;
138156 }
139157
140158 /** Attach a component portal as content to this snack bar container. */
@@ -166,21 +184,14 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
166184 } ;
167185
168186 /** Handle end of animations, updating the state of the snackbar. */
169- onAnimationEnd ( event : AnimationEvent ) {
170- const { fromState, toState} = event ;
171-
172- if ( ( toState === 'void' && fromState !== 'void' ) || toState === 'hidden' ) {
187+ onAnimationEnd ( animationName : string ) {
188+ if ( animationName === EXIT_ANIMATION ) {
173189 this . _completeExit ( ) ;
174- }
175-
176- if ( toState === 'visible' ) {
177- // Note: we shouldn't use `this` inside the zone callback,
178- // because it can cause a memory leak.
179- const onEnter = this . _onEnter ;
180-
190+ } else if ( animationName === ENTER_ANIMATION ) {
191+ clearTimeout ( this . _enterFallback ) ;
181192 this . _ngZone . run ( ( ) => {
182- onEnter . next ( ) ;
183- onEnter . complete ( ) ;
193+ this . _onEnter . next ( ) ;
194+ this . _onEnter . complete ( ) ;
184195 } ) ;
185196 }
186197 }
@@ -194,11 +205,29 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
194205 this . _changeDetectorRef . markForCheck ( ) ;
195206 this . _changeDetectorRef . detectChanges ( ) ;
196207 this . _screenReaderAnnounce ( ) ;
208+
209+ if ( this . _animationsDisabled ) {
210+ this . _renders . pipe ( take ( 1 ) ) . subscribe ( ( ) => {
211+ this . _ngZone . run ( ( ) => queueMicrotask ( ( ) => this . onAnimationEnd ( ENTER_ANIMATION ) ) ) ;
212+ } ) ;
213+ } else {
214+ clearTimeout ( this . _enterFallback ) ;
215+ this . _enterFallback = setTimeout ( ( ) => {
216+ // The snack bar will stay invisible if it fails to animate. Add a fallback class so it
217+ // becomes visible. This can happen in some apps that do `* {animation: none !important}`.
218+ this . _elementRef . nativeElement . classList . add ( 'mat-snack-bar-fallback-visible' ) ;
219+ this . onAnimationEnd ( ENTER_ANIMATION ) ;
220+ } , 200 ) ;
221+ }
197222 }
198223 }
199224
200225 /** Begin animation of the snack bar exiting from view. */
201226 exit ( ) : Observable < void > {
227+ if ( this . _destroyed ) {
228+ return of ( undefined ) ;
229+ }
230+
202231 // It's common for snack bars to be opened by random outside calls like HTTP requests or
203232 // errors. Run inside the NgZone to ensure that it functions correctly.
204233 this . _ngZone . run ( ( ) => {
@@ -216,6 +245,15 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
216245 // If the snack bar hasn't been announced by the time it exits it wouldn't have been open
217246 // long enough to visually read it either, so clear the timeout for announcing.
218247 clearTimeout ( this . _announceTimeoutId ) ;
248+
249+ if ( this . _animationsDisabled ) {
250+ this . _renders . pipe ( take ( 1 ) ) . subscribe ( ( ) => {
251+ this . _ngZone . run ( ( ) => queueMicrotask ( ( ) => this . onAnimationEnd ( EXIT_ANIMATION ) ) ) ;
252+ } ) ;
253+ } else {
254+ clearTimeout ( this . _exitFallback ) ;
255+ this . _exitFallback = setTimeout ( ( ) => this . onAnimationEnd ( EXIT_ANIMATION ) , 200 ) ;
256+ }
219257 } ) ;
220258
221259 return this . _onExit ;
@@ -226,13 +264,12 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
226264 this . _destroyed = true ;
227265 this . _clearFromModals ( ) ;
228266 this . _completeExit ( ) ;
267+ this . _renders . complete ( ) ;
268+ this . _rendersRef . destroy ( ) ;
229269 }
230270
231- /**
232- * Removes the element in a microtask. Helps prevent errors where we end up
233- * removing an element which is in the middle of an animation.
234- */
235271 private _completeExit ( ) {
272+ clearTimeout ( this . _exitFallback ) ;
236273 queueMicrotask ( ( ) => {
237274 this . _onExit . next ( ) ;
238275 this . _onExit . complete ( ) ;
@@ -326,33 +363,40 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
326363 * announce it.
327364 */
328365 private _screenReaderAnnounce ( ) {
329- if ( ! this . _announceTimeoutId ) {
330- this . _ngZone . runOutsideAngular ( ( ) => {
331- this . _announceTimeoutId = setTimeout ( ( ) => {
332- const inertElement = this . _elementRef . nativeElement . querySelector ( '[aria-hidden]' ) ;
333- const liveElement = this . _elementRef . nativeElement . querySelector ( '[aria-live]' ) ;
334-
335- if ( inertElement && liveElement ) {
336- // If an element in the snack bar content is focused before being moved
337- // track it and restore focus after moving to the live region.
338- let focusedElement : HTMLElement | null = null ;
339- if (
340- this . _platform . isBrowser &&
341- document . activeElement instanceof HTMLElement &&
342- inertElement . contains ( document . activeElement )
343- ) {
344- focusedElement = document . activeElement ;
345- }
346-
347- inertElement . removeAttribute ( 'aria-hidden' ) ;
348- liveElement . appendChild ( inertElement ) ;
349- focusedElement ?. focus ( ) ;
350-
351- this . _onAnnounce . next ( ) ;
352- this . _onAnnounce . complete ( ) ;
353- }
354- } , this . _announceDelay ) ;
355- } ) ;
366+ if ( this . _announceTimeoutId ) {
367+ return ;
356368 }
369+
370+ this . _ngZone . runOutsideAngular ( ( ) => {
371+ this . _announceTimeoutId = setTimeout ( ( ) => {
372+ if ( this . _destroyed ) {
373+ return ;
374+ }
375+
376+ const element = this . _elementRef . nativeElement ;
377+ const inertElement = element . querySelector ( '[aria-hidden]' ) ;
378+ const liveElement = element . querySelector ( '[aria-live]' ) ;
379+
380+ if ( inertElement && liveElement ) {
381+ // If an element in the snack bar content is focused before being moved
382+ // track it and restore focus after moving to the live region.
383+ let focusedElement : HTMLElement | null = null ;
384+ if (
385+ this . _platform . isBrowser &&
386+ document . activeElement instanceof HTMLElement &&
387+ inertElement . contains ( document . activeElement )
388+ ) {
389+ focusedElement = document . activeElement ;
390+ }
391+
392+ inertElement . removeAttribute ( 'aria-hidden' ) ;
393+ liveElement . appendChild ( inertElement ) ;
394+ focusedElement ?. focus ( ) ;
395+
396+ this . _onAnnounce . next ( ) ;
397+ this . _onAnnounce . complete ( ) ;
398+ }
399+ } , this . _announceDelay ) ;
400+ } ) ;
357401 }
358402}
0 commit comments