From eb8e998c6967a29a09531771f908c7fc3fcffaae Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Mon, 27 Jan 2025 18:43:23 +0100 Subject: [PATCH] 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 --- .../snack-bar/snack-bar-animations.ts | 2 + .../snack-bar/snack-bar-container.scss | 39 +++++ src/material/snack-bar/snack-bar-container.ts | 144 ++++++++++++------ src/material/snack-bar/snack-bar.spec.ts | 66 +------- src/material/snack-bar/snack-bar.ts | 10 +- tools/public_api_guard/material/snack-bar.md | 7 +- 6 files changed, 146 insertions(+), 122 deletions(-) diff --git a/src/material/snack-bar/snack-bar-animations.ts b/src/material/snack-bar/snack-bar-animations.ts index 4b9486075cbe..e836f75f78c9 100644 --- a/src/material/snack-bar/snack-bar-animations.ts +++ b/src/material/snack-bar/snack-bar-animations.ts @@ -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; diff --git a/src/material/snack-bar/snack-bar-container.scss b/src/material/snack-bar/snack-bar-container.scss index 87aecb67f3f2..3ba0a79709a2 100644 --- a/src/material/snack-bar/snack-bar-container.scss +++ b/src/material/snack-bar/snack-bar-container.scss @@ -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; @@ -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; diff --git a/src/material/snack-bar/snack-bar-container.ts b/src/material/snack-bar/snack-bar-container.ts index 9180b4aaefe9..846d4366927e 100644 --- a/src/material/snack-bar/snack-bar-container.ts +++ b/src/material/snack-bar/snack-bar-container.ts @@ -7,6 +7,9 @@ */ import { + afterRender, + AfterRenderRef, + ANIMATION_MODULE_TYPE, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -20,7 +23,6 @@ import { ViewEncapsulation, } from '@angular/core'; import {DOCUMENT} from '@angular/common'; -import {matSnackBarAnimations} from './snack-bar-animations'; import { BasePortalOutlet, CdkPortalOutlet, @@ -28,11 +30,14 @@ import { 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. @@ -48,12 +53,14 @@ 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 { @@ -61,10 +68,16 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy private _elementRef = inject>(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(); + private _enterFallback: ReturnType | undefined; + private _exitFallback: ReturnType | undefined; + private _renders = new Subject(); /** The number of milliseconds to wait before announcing the snack bar's content. */ private readonly _announceDelay: number = 150; @@ -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. */ @@ -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(); }); } } @@ -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 { + 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(() => { @@ -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; @@ -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(); @@ -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); + }); } } diff --git a/src/material/snack-bar/snack-bar.spec.ts b/src/material/snack-bar/snack-bar.spec.ts index 17a6b72b5915..242722ddae20 100644 --- a/src/material/snack-bar/snack-bar.spec.ts +++ b/src/material/snack-bar/snack-bar.spec.ts @@ -17,7 +17,6 @@ import { MAT_SNACK_BAR_DATA, MatSnackBar, MatSnackBarConfig, - MatSnackBarContainer, MatSnackBarModule, MatSnackBarRef, SimpleSnackBar, @@ -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); @@ -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); @@ -626,7 +564,7 @@ describe('MatSnackBar', () => { viewContainerFixture.detectChanges(); } - flush(); + flush(50); expect(overlayContainerElement.querySelectorAll('mat-snack-bar-container').length).toBe(1); })); diff --git a/src/material/snack-bar/snack-bar.ts b/src/material/snack-bar/snack-bar.ts index 8947692b8779..80031dbe5469 100644 --- a/src/material/snack-bar/snack-bar.ts +++ b/src/material/snack-bar/snack-bar.ts @@ -242,6 +242,11 @@ export class MatSnackBar implements OnDestroy { } }); + // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. + if (config.duration && config.duration > 0) { + snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); + } + if (this._openedSnackBarRef) { // If a snack bar is already in view, dismiss it and enter the // new snack bar after exit animation is complete. @@ -253,11 +258,6 @@ export class MatSnackBar implements OnDestroy { // If no snack bar is in view, enter the new snack bar. snackBarRef.containerInstance.enter(); } - - // If a dismiss timeout is provided, set up dismiss based on after the snackbar is opened. - if (config.duration && config.duration > 0) { - snackBarRef.afterOpened().subscribe(() => snackBarRef._dismissAfter(config.duration!)); - } } /** diff --git a/tools/public_api_guard/material/snack-bar.md b/tools/public_api_guard/material/snack-bar.md index b42922741409..5dc4e6819384 100644 --- a/tools/public_api_guard/material/snack-bar.md +++ b/tools/public_api_guard/material/snack-bar.md @@ -4,7 +4,6 @@ ```ts -import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { AriaLivePoliteness } from '@angular/cdk/a11y'; import { BasePortalOutlet } from '@angular/cdk/portal'; @@ -75,7 +74,7 @@ export class MatSnackBarActions { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public +// @public @deprecated export const matSnackBarAnimations: { readonly snackBarState: AnimationTriggerMetadata; }; @@ -96,6 +95,8 @@ export class MatSnackBarConfig { // @public export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy { constructor(...args: unknown[]); + // (undocumented) + protected _animationsDisabled: boolean; _animationState: string; attachComponentPortal(portal: ComponentPortal): ComponentRef; // @deprecated @@ -107,7 +108,7 @@ export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy _live: AriaLivePoliteness; readonly _liveElementId: string; ngOnDestroy(): void; - onAnimationEnd(event: AnimationEvent_2): void; + onAnimationEnd(animationName: string): void; readonly _onAnnounce: Subject; readonly _onEnter: Subject; readonly _onExit: Subject;