Skip to content

Commit

Permalink
fix(material/snack-bar): switch away from animations module (#30381)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
crisbeto authored Jan 27, 2025
1 parent e1cca90 commit eb8e998
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 122 deletions.
2 changes: 2 additions & 0 deletions src/material/snack-bar/snack-bar-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
/**
* Animations used by the Material snack bar.
* @docs-private
* @deprecated No longer used, will be removed.
* @breaking-change 21.0.0
*/
export const matSnackBarAnimations: {
readonly snackBarState: AnimationTriggerMetadata;
Expand Down
39 changes: 39 additions & 0 deletions src/material/snack-bar/snack-bar-container.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,28 @@

$_side-padding: 8px;

@keyframes _mat-snack-bar-enter {
from {
transform: scale(0.8);
opacity: 0;
}

to {
transform: scale(1);
opacity: 1;
}
}

@keyframes _mat-snack-bar-exit {
from {
opacity: 1;
}

to {
opacity: 0;
}
}

.mat-mdc-snack-bar-container {
display: flex;
align-items: center;
Expand All @@ -20,6 +42,23 @@ $_side-padding: 8px;
}
}

.mat-snack-bar-container-animations-enabled {
opacity: 0;

// Fallback in case the animation fails.
&.mat-snack-bar-fallback-visible {
opacity: 1;
}

&.mat-snack-bar-container-enter {
animation: _mat-snack-bar-enter 150ms cubic-bezier(0, 0, 0.2, 1) forwards;
}

&.mat-snack-bar-container-exit {
animation: _mat-snack-bar-exit 75ms cubic-bezier(0.4, 0, 1, 1) forwards;
}
}

.mat-mdc-snackbar-surface {
@include elevation.elevation(6);
display: flex;
Expand Down
144 changes: 94 additions & 50 deletions src/material/snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
*/

import {
afterRender,
AfterRenderRef,
ANIMATION_MODULE_TYPE,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Expand All @@ -20,19 +23,21 @@ import {
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 {Observable, Subject, of} 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';
import {take} from 'rxjs/operators';

const ENTER_ANIMATION = '_mat-snack-bar-enter';
const EXIT_ANIMATION = '_mat-snack-bar-exit';

/**
* Internal component that wraps user-provided snack bar content.
Expand All @@ -48,23 +53,31 @@ import {MatSnackBarConfig} from './snack-bar-config';
// 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)',
'[class.mat-snack-bar-container-enter]': '_animationState === "visible"',
'[class.mat-snack-bar-container-exit]': '_animationState === "hidden"',
'[class.mat-snack-bar-container-animations-enabled]': '!_animationsDisabled',
'(animationend)': 'onAnimationEnd($event.animationName)',
'(animationcancel)': 'onAnimationEnd($event.animationName)',
},
})
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);
private _rendersRef: AfterRenderRef;
protected _animationsDisabled =
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
snackBarConfig = inject(MatSnackBarConfig);

private _document = inject(DOCUMENT);
private _trackedModals = new Set<Element>();
private _enterFallback: ReturnType<typeof setTimeout> | undefined;
private _exitFallback: ReturnType<typeof setTimeout> | undefined;
private _renders = new Subject<void>();

/** The number of milliseconds to wait before announcing the snack bar's content. */
private readonly _announceDelay: number = 150;
Expand Down Expand Up @@ -135,6 +148,11 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
this._role = 'alert';
}
}

// Note: ideally we'd just do an `afterNextRender` in the places where we need to delay
// something, however in some cases (TestBed teardown) the injector can be destroyed at an
// unexpected time, causing the `afterRender` to fail.
this._rendersRef = afterRender(() => this._renders.next(), {manualCleanup: true});
}

/** Attach a component portal as content to this snack bar container. */
Expand Down Expand Up @@ -166,21 +184,14 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
};

/** Handle end of animations, updating the state of the snackbar. */
onAnimationEnd(event: AnimationEvent) {
const {fromState, toState} = event;

if ((toState === 'void' && fromState !== 'void') || toState === 'hidden') {
onAnimationEnd(animationName: string) {
if (animationName === EXIT_ANIMATION) {
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;

} else if (animationName === ENTER_ANIMATION) {
clearTimeout(this._enterFallback);
this._ngZone.run(() => {
onEnter.next();
onEnter.complete();
this._onEnter.next();
this._onEnter.complete();
});
}
}
Expand All @@ -194,11 +205,29 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
this._changeDetectorRef.markForCheck();
this._changeDetectorRef.detectChanges();
this._screenReaderAnnounce();

if (this._animationsDisabled) {
this._renders.pipe(take(1)).subscribe(() => {
this._ngZone.run(() => queueMicrotask(() => this.onAnimationEnd(ENTER_ANIMATION)));
});
} else {
clearTimeout(this._enterFallback);
this._enterFallback = setTimeout(() => {
// The snack bar will stay invisible if it fails to animate. Add a fallback class so it
// becomes visible. This can happen in some apps that do `* {animation: none !important}`.
this._elementRef.nativeElement.classList.add('mat-snack-bar-fallback-visible');
this.onAnimationEnd(ENTER_ANIMATION);
}, 200);
}
}
}

/** Begin animation of the snack bar exiting from view. */
exit(): Observable<void> {
if (this._destroyed) {
return of(undefined);
}

// 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(() => {
Expand All @@ -216,6 +245,15 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
// 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);

if (this._animationsDisabled) {
this._renders.pipe(take(1)).subscribe(() => {
this._ngZone.run(() => queueMicrotask(() => this.onAnimationEnd(EXIT_ANIMATION)));
});
} else {
clearTimeout(this._exitFallback);
this._exitFallback = setTimeout(() => this.onAnimationEnd(EXIT_ANIMATION), 200);
}
});

return this._onExit;
Expand All @@ -226,13 +264,12 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
this._destroyed = true;
this._clearFromModals();
this._completeExit();
this._renders.complete();
this._rendersRef.destroy();
}

/**
* 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() {
clearTimeout(this._exitFallback);
queueMicrotask(() => {
this._onExit.next();
this._onExit.complete();
Expand Down Expand Up @@ -326,33 +363,40 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy
* 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);
});
if (this._announceTimeoutId) {
return;
}

this._ngZone.runOutsideAngular(() => {
this._announceTimeoutId = setTimeout(() => {
if (this._destroyed) {
return;
}

const element = this._elementRef.nativeElement;
const inertElement = element.querySelector('[aria-hidden]');
const liveElement = element.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);
});
}
}
66 changes: 2 additions & 64 deletions src/material/snack-bar/snack-bar.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
MAT_SNACK_BAR_DATA,
MatSnackBar,
MatSnackBarConfig,
MatSnackBarContainer,
MatSnackBarModule,
MatSnackBarRef,
SimpleSnackBar,
Expand Down Expand Up @@ -360,67 +359,6 @@ describe('MatSnackBar', () => {
.toBe(0);
}));

it('should set the animation state to visible on entry', () => {
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
const snackBarRef = snackBar.open(simpleMessage, undefined, config);

viewContainerFixture.detectChanges();
const container = snackBarRef.containerInstance as MatSnackBarContainer;
expect(container._animationState)
.withContext(`Expected the animation state would be 'visible'.`)
.toBe('visible');
snackBarRef.dismiss();

viewContainerFixture.detectChanges();
expect(container._animationState)
.withContext(`Expected the animation state would be 'hidden'.`)
.toBe('hidden');
});

it('should set the animation state to complete on exit', () => {
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
snackBarRef.dismiss();

viewContainerFixture.detectChanges();
const container = snackBarRef.containerInstance as MatSnackBarContainer;
expect(container._animationState)
.withContext(`Expected the animation state would be 'hidden'.`)
.toBe('hidden');
});

it(`should set the old snack bar animation state to complete and the new snack bar animation
state to visible on entry of new snack bar`, fakeAsync(() => {
const config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
const snackBarRef = snackBar.open(simpleMessage, undefined, config);
const dismissCompleteSpy = jasmine.createSpy('dismiss complete spy');

viewContainerFixture.detectChanges();

const containerElement = document.querySelector('mat-snack-bar-container')!;
expect(containerElement.classList).toContain('ng-animating');
const container1 = snackBarRef.containerInstance as MatSnackBarContainer;
expect(container1._animationState)
.withContext(`Expected the animation state would be 'visible'.`)
.toBe('visible');

const config2 = {viewContainerRef: testViewContainerRef};
const snackBarRef2 = snackBar.open(simpleMessage, undefined, config2);

viewContainerFixture.detectChanges();
snackBarRef.afterDismissed().subscribe({complete: dismissCompleteSpy});
flush();

expect(dismissCompleteSpy).toHaveBeenCalled();
const container2 = snackBarRef2.containerInstance as MatSnackBarContainer;
expect(container1._animationState)
.withContext(`Expected the animation state would be 'hidden'.`)
.toBe('hidden');
expect(container2._animationState)
.withContext(`Expected the animation state would be 'visible'.`)
.toBe('visible');
}));

it('should open a new snackbar after dismissing a previous snackbar', fakeAsync(() => {
let config: MatSnackBarConfig = {viewContainerRef: testViewContainerRef};
let snackBarRef = snackBar.open(simpleMessage, 'Dismiss', config);
Expand Down Expand Up @@ -610,9 +548,9 @@ describe('MatSnackBar', () => {
it('should cap the timeout to the maximum accepted delay in setTimeout', fakeAsync(() => {
const config = new MatSnackBarConfig();
config.duration = Infinity;
spyOn(window, 'setTimeout').and.callThrough();
snackBar.open('content', 'test', config);
viewContainerFixture.detectChanges();
spyOn(window, 'setTimeout').and.callThrough();
tick(100);

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

flush();
flush(50);
expect(overlayContainerElement.querySelectorAll('mat-snack-bar-container').length).toBe(1);
}));

Expand Down
Loading

0 comments on commit eb8e998

Please sign in to comment.