Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

fix(material/snack-bar): switch away from animations module #30381

Merged
merged 5 commits into from
Jan 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading