Skip to content

Commit eb8e998

Browse files
authoredJan 27, 2025··
fix(material/snack-bar): switch away from animations module (#30381)
* fix(material/snack-bar): switch away from animations module Reworks the snack bar so it animates using CSS instead of the animations module. * fixup! fix(material/snack-bar): switch away from animations module * fixup! fix(material/snack-bar): switch away from animations module * fixup! fix(material/snack-bar): switch away from animations module * fixup! fix(material/snack-bar): switch away from animations module
1 parent e1cca90 commit eb8e998

File tree

6 files changed

+146
-122
lines changed

6 files changed

+146
-122
lines changed
 

Diff for: ‎src/material/snack-bar/snack-bar-animations.ts

+2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import {
1717
/**
1818
* Animations used by the Material snack bar.
1919
* @docs-private
20+
* @deprecated No longer used, will be removed.
21+
* @breaking-change 21.0.0
2022
*/
2123
export const matSnackBarAnimations: {
2224
readonly snackBarState: AnimationTriggerMetadata;

Diff for: ‎src/material/snack-bar/snack-bar-container.scss

+39
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,28 @@
77

88
$_side-padding: 8px;
99

10+
@keyframes _mat-snack-bar-enter {
11+
from {
12+
transform: scale(0.8);
13+
opacity: 0;
14+
}
15+
16+
to {
17+
transform: scale(1);
18+
opacity: 1;
19+
}
20+
}
21+
22+
@keyframes _mat-snack-bar-exit {
23+
from {
24+
opacity: 1;
25+
}
26+
27+
to {
28+
opacity: 0;
29+
}
30+
}
31+
1032
.mat-mdc-snack-bar-container {
1133
display: flex;
1234
align-items: center;
@@ -20,6 +42,23 @@ $_side-padding: 8px;
2042
}
2143
}
2244

45+
.mat-snack-bar-container-animations-enabled {
46+
opacity: 0;
47+
48+
// Fallback in case the animation fails.
49+
&.mat-snack-bar-fallback-visible {
50+
opacity: 1;
51+
}
52+
53+
&.mat-snack-bar-container-enter {
54+
animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1) forwards;
55+
}
56+
57+
&.mat-snack-bar-container-exit {
58+
animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1) forwards;
59+
}
60+
}
61+
2362
.mat-mdc-snackbar-surface {
2463
@include elevation.elevation(6);
2564
display: flex;

Diff for: ‎src/material/snack-bar/snack-bar-container.ts

+94-50
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
*/
88

99
import {
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';
2225
import {DOCUMENT} from '@angular/common';
23-
import {matSnackBarAnimations} from './snack-bar-animations';
2426
import {
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';
3234
import {_IdGenerator, AriaLivePoliteness} from '@angular/cdk/a11y';
3335
import {Platform} from '@angular/cdk/platform';
34-
import {AnimationEvent} from '@angular/animations';
3536
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';
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
})
5966
export 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
}

Diff for: ‎src/material/snack-bar/snack-bar.spec.ts

+2-64
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import {
1717
MAT_SNACK_BAR_DATA,
1818
MatSnackBar,
1919
MatSnackBarConfig,
20-
MatSnackBarContainer,
2120
MatSnackBarModule,
2221
MatSnackBarRef,
2322
SimpleSnackBar,
@@ -360,67 +359,6 @@ describe('MatSnackBar', () => {
360359
.toBe(0);
361360
}));
362361

363-
it('should set the animation state to visible on entry', () => {
364-
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
365-
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
366-
367-
viewContainerFixture.detectChanges();
368-
const container = snackBarRef.containerInstance as MatSnackBarContainer;
369-
expect(container._animationState)
370-
.withContext(`Expected the animation state would be 'visible'.`)
371-
.toBe('visible');
372-
snackBarRef.dismiss();
373-
374-
viewContainerFixture.detectChanges();
375-
expect(container._animationState)
376-
.withContext(`Expected the animation state would be 'hidden'.`)
377-
.toBe('hidden');
378-
});
379-
380-
it('should set the animation state to complete on exit', () => {
381-
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
382-
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
383-
snackBarRef.dismiss();
384-
385-
viewContainerFixture.detectChanges();
386-
const container = snackBarRef.containerInstance as MatSnackBarContainer;
387-
expect(container._animationState)
388-
.withContext(`Expected the animation state would be 'hidden'.`)
389-
.toBe('hidden');
390-
});
391-
392-
it(`should set the old snack bar animation state to complete and the new snack bar animation
393-
state to visible on entry of new snack bar`, fakeAsync(() => {
394-
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
395-
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
396-
const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy');
397-
398-
viewContainerFixture.detectChanges();
399-
400-
const containerElement = document.querySelector('mat-snack-bar-container')!;
401-
expect(containerElement.classList).toContain('ng-animating');
402-
const container1 = snackBarRef.containerInstance as MatSnackBarContainer;
403-
expect(container1._animationState)
404-
.withContext(`Expected the animation state would be 'visible'.`)
405-
.toBe('visible');
406-
407-
const config2 = {viewContainerRef: testViewContainerRef};
408-
const snackBarRef2 = snackBar.open(simpleMessage, undefined, config2);
409-
410-
viewContainerFixture.detectChanges();
411-
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
412-
flush();
413-
414-
expect(dismissCompleteSpy).toHaveBeenCalled();
415-
const container2 = snackBarRef2.containerInstance as MatSnackBarContainer;
416-
expect(container1._animationState)
417-
.withContext(`Expected the animation state would be 'hidden'.`)
418-
.toBe('hidden');
419-
expect(container2._animationState)
420-
.withContext(`Expected the animation state would be 'visible'.`)
421-
.toBe('visible');
422-
}));
423-
424362
it('should open a new snackbar after dismissing a previous snackbar', fakeAsync(() => {
425363
let config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
426364
let snackBarRef = snackBar.open(simpleMessage, 'Dismiss', config);
@@ -610,9 +548,9 @@ describe('MatSnackBar', () => {
610548
it('should cap the timeout to the maximum accepted delay in setTimeout', fakeAsync(() => {
611549
const config = new MatSnackBarConfig();
612550
config.duration = Infinity;
551+
spyOn(window, 'setTimeout').and.callThrough();
613552
snackBar.open('content', 'test', config);
614553
viewContainerFixture.detectChanges();
615-
spyOn(window, 'setTimeout').and.callThrough();
616554
tick(100);
617555

618556
expect(window.setTimeout).toHaveBeenCalledWith(jasmine.any(Function), Math.pow(2, 31) - 1);
@@ -626,7 +564,7 @@ describe('MatSnackBar', () => {
626564
viewContainerFixture.detectChanges();
627565
}
628566

629-
flush();
567+
flush(50);
630568
expect(overlayContainerElement.querySelectorAll('mat-snack-bar-container').length).toBe(1);
631569
}));
632570

Diff for: ‎src/material/snack-bar/snack-bar.ts

+5-5
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,11 @@ export class MatSnackBar implements OnDestroy {
242242
}
243243
});
244244

245+
// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
246+
if (config.duration && config.duration > 0) {
247+
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
248+
}
249+
245250
if (this._openedSnackBarRef) {
246251
// If a snack bar is already in view, dismiss it and enter the
247252
// new snack bar after exit animation is complete.
@@ -253,11 +258,6 @@ export class MatSnackBar implements OnDestroy {
253258
// If no snack bar is in view, enter the new snack bar.
254259
snackBarRef.containerInstance.enter();
255260
}
256-
257-
// If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened.
258-
if (config.duration && config.duration > 0) {
259-
snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!));
260-
}
261261
}
262262

263263
/**

Diff for: ‎tools/public_api_guard/material/snack-bar.md

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
55
```ts
66

7-
import { AnimationEvent as AnimationEvent_2 } from '@angular/animations';
87
import { AnimationTriggerMetadata } from '@angular/animations';
98
import { AriaLivePoliteness } from '@angular/cdk/a11y';
109
import { BasePortalOutlet } from '@angular/cdk/portal';
@@ -75,7 +74,7 @@ export class MatSnackBarActions {
7574
static ɵfac: i0.ɵɵFactoryDeclaration<MatSnackBarActions, never>;
7675
}
7776

78-
// @public
77+
// @public @deprecated
7978
export const matSnackBarAnimations: {
8079
readonly snackBarState: AnimationTriggerMetadata;
8180
};
@@ -96,6 +95,8 @@ export class MatSnackBarConfig<D = any> {
9695
// @public
9796
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
9897
constructor(...args: unknown[]);
98+
// (undocumented)
99+
protected _animationsDisabled: boolean;
99100
_animationState: string;
100101
attachComponentPortal<T>(portal: ComponentPortal<T>): ComponentRef<T>;
101102
// @deprecated
@@ -107,7 +108,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
107108
_live: AriaLivePoliteness;
108109
readonly _liveElementId: string;
109110
ngOnDestroy(): void;
110-
onAnimationEnd(event: AnimationEvent_2): void;
111+
onAnimationEnd(animationName: string): void;
111112
readonly _onAnnounce: Subject<void>;
112113
readonly _onEnter: Subject<void>;
113114
readonly _onExit: Subject<void>;

0 commit comments

Comments
 (0)
Please sign in to comment.