7
7
*/
8
8
9
9
import {
10
+ afterRender ,
11
+ AfterRenderRef ,
12
+ ANIMATION_MODULE_TYPE ,
10
13
ChangeDetectionStrategy ,
11
14
ChangeDetectorRef ,
12
15
Component ,
@@ -20,19 +23,21 @@ import {
20
23
ViewEncapsulation ,
21
24
} from '@angular/core' ;
22
25
import { DOCUMENT } from '@angular/common' ;
23
- import { matSnackBarAnimations } from './snack-bar-animations' ;
24
26
import {
25
27
BasePortalOutlet ,
26
28
CdkPortalOutlet ,
27
29
ComponentPortal ,
28
30
DomPortal ,
29
31
TemplatePortal ,
30
32
} from '@angular/cdk/portal' ;
31
- import { Observable , Subject } from 'rxjs' ;
33
+ import { Observable , Subject , of } from 'rxjs' ;
32
34
import { _IdGenerator , AriaLivePoliteness } from '@angular/cdk/a11y' ;
33
35
import { Platform } from '@angular/cdk/platform' ;
34
- import { AnimationEvent } from '@angular/animations' ;
35
36
import { 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' ;
36
41
37
42
/**
38
43
* Internal component that wraps user-provided snack bar content.
@@ -48,23 +53,31 @@ import {MatSnackBarConfig} from './snack-bar-config';
48
53
// tslint:disable-next-line:validate-decorators
49
54
changeDetection : ChangeDetectionStrategy . Default ,
50
55
encapsulation : ViewEncapsulation . None ,
51
- animations : [ matSnackBarAnimations . snackBarState ] ,
52
56
imports : [ CdkPortalOutlet ] ,
53
57
host : {
54
58
'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)' ,
57
64
} ,
58
65
} )
59
66
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
60
67
private _ngZone = inject ( NgZone ) ;
61
68
private _elementRef = inject < ElementRef < HTMLElement > > ( ElementRef ) ;
62
69
private _changeDetectorRef = inject ( ChangeDetectorRef ) ;
63
70
private _platform = inject ( Platform ) ;
71
+ private _rendersRef : AfterRenderRef ;
72
+ protected _animationsDisabled =
73
+ inject ( ANIMATION_MODULE_TYPE , { optional : true } ) === 'NoopAnimations' ;
64
74
snackBarConfig = inject ( MatSnackBarConfig ) ;
65
75
66
76
private _document = inject ( DOCUMENT ) ;
67
77
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 > ( ) ;
68
81
69
82
/** The number of milliseconds to wait before announcing the snack bar's content. */
70
83
private readonly _announceDelay : number = 150 ;
@@ -135,6 +148,11 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
135
148
this . _role = 'alert' ;
136
149
}
137
150
}
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 } ) ;
138
156
}
139
157
140
158
/** Attach a component portal as content to this snack bar container. */
@@ -166,21 +184,14 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
166
184
} ;
167
185
168
186
/** 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 ) {
173
189
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 ) ;
181
192
this . _ngZone . run ( ( ) => {
182
- onEnter . next ( ) ;
183
- onEnter . complete ( ) ;
193
+ this . _onEnter . next ( ) ;
194
+ this . _onEnter . complete ( ) ;
184
195
} ) ;
185
196
}
186
197
}
@@ -194,11 +205,29 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
194
205
this . _changeDetectorRef . markForCheck ( ) ;
195
206
this . _changeDetectorRef . detectChanges ( ) ;
196
207
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
+ }
197
222
}
198
223
}
199
224
200
225
/** Begin animation of the snack bar exiting from view. */
201
226
exit ( ) : Observable < void > {
227
+ if ( this . _destroyed ) {
228
+ return of ( undefined ) ;
229
+ }
230
+
202
231
// It's common for snack bars to be opened by random outside calls like HTTP requests or
203
232
// errors. Run inside the NgZone to ensure that it functions correctly.
204
233
this . _ngZone . run ( ( ) => {
@@ -216,6 +245,15 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
216
245
// If the snack bar hasn't been announced by the time it exits it wouldn't have been open
217
246
// long enough to visually read it either, so clear the timeout for announcing.
218
247
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
+ }
219
257
} ) ;
220
258
221
259
return this . _onExit ;
@@ -226,13 +264,12 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
226
264
this . _destroyed = true ;
227
265
this . _clearFromModals ( ) ;
228
266
this . _completeExit ( ) ;
267
+ this . _renders . complete ( ) ;
268
+ this . _rendersRef . destroy ( ) ;
229
269
}
230
270
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
- */
235
271
private _completeExit ( ) {
272
+ clearTimeout ( this . _exitFallback ) ;
236
273
queueMicrotask ( ( ) => {
237
274
this . _onExit . next ( ) ;
238
275
this . _onExit . complete ( ) ;
@@ -326,33 +363,40 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
326
363
* announce it.
327
364
*/
328
365
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 ;
356
368
}
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
+ } ) ;
357
401
}
358
402
}
0 commit comments