Skip to content

Commit

Permalink
fixup! fix(material/snack-bar): switch away from animations module
Browse files Browse the repository at this point in the history
  • Loading branch information
crisbeto committed Jan 27, 2025
1 parent 392ae2c commit bf8c558
Show file tree
Hide file tree
Showing 4 changed files with 108 additions and 78 deletions.
5 changes: 5 additions & 0 deletions src/material/snack-bar/snack-bar-container.scss
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ $_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;
}
Expand Down
164 changes: 96 additions & 68 deletions src/material/snack-bar/snack-bar-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,16 @@
*/

import {
afterNextRender,
ANIMATION_MODULE_TYPE,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ComponentRef,
DoCheck,
ElementRef,
EmbeddedViewRef,
inject,
Injector,
NgZone,
OnDestroy,
ViewChild,
Expand All @@ -29,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 {MatSnackBarConfig} from './snack-bar-config';

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

/**
* Internal component that wraps user-provided snack bar content.
* @docs-private
Expand All @@ -54,15 +58,16 @@ import {MatSnackBarConfig} from './snack-bar-config';
'[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)',
'(animationcancel)': 'onAnimationEnd($event)',
'(animationend)': 'onAnimationEnd($event.animationName)',
'(animationcancel)': 'onAnimationEnd($event.animationName)',
},
})
export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, OnDestroy {
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 _injector = inject(Injector);
protected _animationsDisabled =
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
snackBarConfig = inject(MatSnackBarConfig);
Expand All @@ -71,7 +76,6 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
private _trackedModals = new Set<Element>();
private _enterFallback: ReturnType<typeof setTimeout> | undefined;
private _exitFallback: ReturnType<typeof setTimeout> | undefined;
private _pendingNoopAnimation: boolean;

/** The number of milliseconds to wait before announcing the snack bar's content. */
private readonly _announceDelay: number = 150;
Expand All @@ -82,6 +86,9 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
/** Whether the component has been destroyed. */
private _destroyed = false;

/** Whether the process of exiting is currently running. */
private _isExiting = false;

/** The portal outlet inside of this container into which the snack bar content will be loaded. */
@ViewChild(CdkPortalOutlet, {static: true}) _portalOutlet: CdkPortalOutlet;

Expand Down Expand Up @@ -173,11 +180,15 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
};

/** Handle end of animations, updating the state of the snackbar. */
onAnimationEnd(event: AnimationEvent) {
if (event.animationName === '_mat-snack-bar-exit') {
onAnimationEnd(animationName: string) {
if (animationName === EXIT_ANIMATION) {
this._completeExit();
} else if (event.animationName === '_mat-snack-bar-enter') {
this._completeEnter();
} else if (animationName === ENTER_ANIMATION) {
clearTimeout(this._enterFallback);
this._ngZone.run(() => {
this._onEnter.next();
this._onEnter.complete();
});
}
}

Expand All @@ -192,16 +203,43 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
this._screenReaderAnnounce();

if (this._animationsDisabled) {
this._pendingNoopAnimation = true;
afterNextRender(
() => {
this._ngZone.run(() => {
queueMicrotask(() => this.onAnimationEnd(ENTER_ANIMATION));
});
},
{
injector: this._injector,
},
);
} else {
clearTimeout(this._enterFallback);
this._enterFallback = setTimeout(() => this._completeEnter(), 200);
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 important not to re-enter here, because `afterNextRender` needs a non-destroyed injector
// which might happen between the time `exit` starts and the component is actually destroyed.
// This appears to happen in some internal tests when TestBed is being torn down.
if (this._isExiting) {
return this._onExit;
}

this._isExiting = true;

// 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 @@ -221,55 +259,38 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
clearTimeout(this._announceTimeoutId);

if (this._animationsDisabled) {
this._pendingNoopAnimation = true;
afterNextRender(
() => {
this._ngZone.run(() => {
queueMicrotask(() => this.onAnimationEnd(EXIT_ANIMATION));
});
},
{
injector: this._injector,
},
);
} else {
clearTimeout(this._exitFallback);
this._exitFallback = setTimeout(() => this._completeExit(), 200);
this._exitFallback = setTimeout(() => this.onAnimationEnd(EXIT_ANIMATION), 200);
}
});

return this._onExit;
}

ngDoCheck(): void {
// Aims to mimic the timing of when the snack back was using the animations
// module since many internal tests depend on the old timing.
if (this._pendingNoopAnimation) {
this._pendingNoopAnimation = false;
queueMicrotask(() => {
if (this._animationState === 'visible') {
this._completeEnter();
} else {
this._completeExit();
}
});
}
}

/** Makes sure the exit callbacks have been invoked when the element is destroyed. */
ngOnDestroy() {
this._destroyed = true;
this._clearFromModals();
this._completeExit();
}

private _completeEnter() {
clearTimeout(this._enterFallback);
this._ngZone.run(() => {
this._onEnter.next();
this._onEnter.complete();
});
}

/**
* 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();
this._isExiting = true;
});
}

Expand Down Expand Up @@ -360,33 +381,40 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
* 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);
});
}
}
10 changes: 5 additions & 5 deletions src/material/snack-bar/snack-bar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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!));
}
}

/**
Expand Down
7 changes: 2 additions & 5 deletions tools/public_api_guard/material/snack-bar.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import { ComponentPortal } from '@angular/cdk/portal';
import { ComponentRef } from '@angular/core';
import { ComponentType } from '@angular/cdk/overlay';
import { Direction } from '@angular/cdk/bidi';
import { DoCheck } from '@angular/core';
import { DomPortal } from '@angular/cdk/portal';
import { ElementRef } from '@angular/core';
import { EmbeddedViewRef } from '@angular/core';
Expand Down Expand Up @@ -94,7 +93,7 @@ export class MatSnackBarConfig<D = any> {
}

// @public
export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, OnDestroy {
export class MatSnackBarContainer extends BasePortalOutlet implements OnDestroy {
constructor(...args: unknown[]);
// (undocumented)
protected _animationsDisabled: boolean;
Expand All @@ -108,10 +107,8 @@ export class MatSnackBarContainer extends BasePortalOutlet implements DoCheck, O
_label: ElementRef;
_live: AriaLivePoliteness;
readonly _liveElementId: string;
// (undocumented)
ngDoCheck(): void;
ngOnDestroy(): void;
onAnimationEnd(event: AnimationEvent): void;
onAnimationEnd(animationName: string): void;
readonly _onAnnounce: Subject<void>;
readonly _onEnter: Subject<void>;
readonly _onExit: Subject<void>;
Expand Down

0 comments on commit bf8c558

Please sign in to comment.