From 066c740251b95f0b62158e98cfd72c7c294114fc Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Tue, 21 Jan 2025 15:10:40 +0100 Subject: [PATCH] fix(material/datepicker): switch away from animations module (#30360) Reworks the datepicker so it no longer depends on the animations module. --- .../datepicker/datepicker-animations.ts | 2 + src/material/datepicker/datepicker-base.ts | 86 ++++++++++++------- .../datepicker/datepicker-content.html | 1 - .../datepicker/datepicker-content.scss | 47 +++++++++- src/material/datepicker/datepicker.spec.ts | 5 -- tools/public_api_guard/material/datepicker.md | 14 ++- 6 files changed, 109 insertions(+), 46 deletions(-) diff --git a/src/material/datepicker/datepicker-animations.ts b/src/material/datepicker/datepicker-animations.ts index 081a8642b07a..f72e129aa646 100644 --- a/src/material/datepicker/datepicker-animations.ts +++ b/src/material/datepicker/datepicker-animations.ts @@ -18,6 +18,8 @@ import { /** * Animations used by the Material datepicker. * @docs-private + * @deprecated No longer used, will be removed. + * @breaking-change 21.0.0 */ export const matDatepickerAnimations: { readonly transformPanel: AnimationTriggerMetadata; diff --git a/src/material/datepicker/datepicker-base.ts b/src/material/datepicker/datepicker-base.ts index 6046c3eb8ace..c7504bf6db8f 100644 --- a/src/material/datepicker/datepicker-base.ts +++ b/src/material/datepicker/datepicker-base.ts @@ -6,7 +6,6 @@ * found in the LICENSE file at https://angular.dev/license */ -import {AnimationEvent} from '@angular/animations'; import {_IdGenerator, CdkTrapFocus} from '@angular/cdk/a11y'; import {Directionality} from '@angular/cdk/bidi'; import {coerceStringArray} from '@angular/cdk/coercion'; @@ -34,6 +33,7 @@ import {DOCUMENT} from '@angular/common'; import { afterNextRender, AfterViewInit, + ANIMATION_MODULE_TYPE, booleanAttribute, ChangeDetectionStrategy, ChangeDetectorRef, @@ -46,10 +46,11 @@ import { InjectionToken, Injector, Input, + NgZone, OnChanges, OnDestroy, - OnInit, Output, + Renderer2, SimpleChanges, ViewChild, ViewContainerRef, @@ -70,7 +71,6 @@ import { ExtractDateTypeFromSelection, MatDateSelectionModel, } from './date-selection-model'; -import {matDatepickerAnimations} from './datepicker-animations'; import {createMissingDateImplError} from './datepicker-errors'; import {DateFilterFn} from './datepicker-input-base'; import {MatDatepickerIntl} from './datepicker-intl'; @@ -120,31 +120,34 @@ export const MAT_DATEPICKER_SCROLL_STRATEGY_FACTORY_PROVIDER = { host: { 'class': 'mat-datepicker-content', '[class]': 'color ? "mat-" + color : ""', - '[@transformPanel]': '_animationState', - '(@transformPanel.start)': '_handleAnimationEvent($event)', - '(@transformPanel.done)': '_handleAnimationEvent($event)', '[class.mat-datepicker-content-touch]': 'datepicker.touchUi', + '[class.mat-datepicker-content-animations-enabled]': '!_animationsDisabled', }, - animations: [matDatepickerAnimations.transformPanel, matDatepickerAnimations.fadeInCalendar], exportAs: 'matDatepickerContent', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, imports: [CdkTrapFocus, MatCalendar, CdkPortalOutlet, MatButton], }) export class MatDatepickerContent> - implements OnInit, AfterViewInit, OnDestroy + implements AfterViewInit, OnDestroy { - protected _elementRef = inject(ElementRef); + protected _elementRef = inject>(ElementRef); + protected _animationsDisabled = + inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations'; private _changeDetectorRef = inject(ChangeDetectorRef); private _globalModel = inject>(MatDateSelectionModel); private _dateAdapter = inject>(DateAdapter)!; + private _ngZone = inject(NgZone); private _rangeSelectionStrategy = inject>( MAT_DATE_RANGE_SELECTION_STRATEGY, {optional: true}, ); - private _subscriptions = new Subscription(); + private _stateChanges: Subscription | undefined; private _model: MatDateSelectionModel; + private _eventCleanups: (() => void)[] | undefined; + private _animationFallback: ReturnType | undefined; + /** Reference to the internal calendar component. */ @ViewChild(MatCalendar) _calendar: MatCalendar; @@ -175,9 +178,6 @@ export class MatDatepickerContent> /** Whether the datepicker is above or below the input. */ _isAbove: boolean; - /** Current state of the animation. */ - _animationState: 'enter-dropdown' | 'enter-dialog' | 'void'; - /** Emits when an animation has finished. */ readonly _animationDone = new Subject(); @@ -200,26 +200,31 @@ export class MatDatepickerContent> constructor() { inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader); - const intl = inject(MatDatepickerIntl); + this._closeButtonText = inject(MatDatepickerIntl).closeCalendarLabel; - this._closeButtonText = intl.closeCalendarLabel; - } + if (!this._animationsDisabled) { + const element = this._elementRef.nativeElement; + const renderer = inject(Renderer2); - ngOnInit() { - this._animationState = this.datepicker.touchUi ? 'enter-dialog' : 'enter-dropdown'; + this._eventCleanups = this._ngZone.runOutsideAngular(() => [ + renderer.listen(element, 'animationstart', this._handleAnimationEvent), + renderer.listen(element, 'animationend', this._handleAnimationEvent), + renderer.listen(element, 'animationcancel', this._handleAnimationEvent), + ]); + } } ngAfterViewInit() { - this._subscriptions.add( - this.datepicker.stateChanges.subscribe(() => { - this._changeDetectorRef.markForCheck(); - }), - ); + this._stateChanges = this.datepicker.stateChanges.subscribe(() => { + this._changeDetectorRef.markForCheck(); + }); this._calendar.focusActiveCell(); } ngOnDestroy() { - this._subscriptions.unsubscribe(); + clearTimeout(this._animationFallback); + this._eventCleanups?.forEach(cleanup => cleanup()); + this._stateChanges?.unsubscribe(); this._animationDone.complete(); } @@ -258,17 +263,38 @@ export class MatDatepickerContent> } _startExitAnimation() { - this._animationState = 'void'; - this._changeDetectorRef.markForCheck(); + this._elementRef.nativeElement.classList.add('mat-datepicker-content-exit'); + + if (this._animationsDisabled) { + this._animationDone.next(); + } else { + // Some internal apps disable animations in tests using `* {animation: none !important}`. + // If that happens, the animation events won't fire and we'll never clean up the overlay. + // Add a fallback that will fire if the animation doesn't run in a certain amount of time. + clearTimeout(this._animationFallback); + this._animationFallback = setTimeout(() => { + if (!this._isAnimating) { + this._animationDone.next(); + } + }, 200); + } } - _handleAnimationEvent(event: AnimationEvent) { - this._isAnimating = event.phaseName === 'start'; + private _handleAnimationEvent = (event: AnimationEvent) => { + const element = this._elementRef.nativeElement; + + if (event.target !== element || !event.animationName.startsWith('_mat-datepicker-content')) { + return; + } + + clearTimeout(this._animationFallback); + this._isAnimating = event.type === 'animationstart'; + element.classList.toggle('mat-datepicker-content-animating', this._isAnimating); if (!this._isAnimating) { this._animationDone.next(); } - } + }; _getSelected() { return this._model.selection as unknown as D | DateRange | null; @@ -672,7 +698,6 @@ export abstract class MatDatepickerBase< if (this._componentRef) { const {instance, location} = this._componentRef; - instance._startExitAnimation(); instance._animationDone.pipe(take(1)).subscribe(() => { const activeElement = this._document.activeElement; @@ -690,6 +715,7 @@ export abstract class MatDatepickerBase< this._focusedElementBeforeOpen = null; this._destroyOverlay(); }); + instance._startExitAnimation(); } if (canRestoreFocus) { diff --git a/src/material/datepicker/datepicker-content.html b/src/material/datepicker/datepicker-content.html index e8ad512c2715..96fc2a3c2a09 100644 --- a/src/material/datepicker/datepicker-content.html +++ b/src/material/datepicker/datepicker-content.html @@ -19,7 +19,6 @@ [dateClass]="datepicker.dateClass" [comparisonStart]="comparisonStart" [comparisonEnd]="comparisonEnd" - [@fadeInCalendar]="'enter'" [startDateAccessibleName]="startDateAccessibleName" [endDateAccessibleName]="endDateAccessibleName" (yearSelected)="datepicker._selectYear($event)" diff --git a/src/material/datepicker/datepicker-content.scss b/src/material/datepicker/datepicker-content.scss index 3184ed325cf6..57ecd20348b7 100644 --- a/src/material/datepicker/datepicker-content.scss +++ b/src/material/datepicker/datepicker-content.scss @@ -24,6 +24,39 @@ $touch-min-height: 312px; $touch-max-width: 750px; $touch-max-height: 788px; +@keyframes _mat-datepicker-content-dropdown-enter { + from { + opacity: 0; + transform: scaleY(0.8); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes _mat-datepicker-content-dialog-enter { + from { + opacity: 0; + transform: scale(0.8); + } + + to { + opacity: 1; + transform: none; + } +} + +@keyframes _mat-datepicker-content-exit { + from { + opacity: 1; + } + + to { + opacity: 0; + } +} .mat-datepicker-content { display: block; @@ -37,6 +70,10 @@ $touch-max-height: 788px; @include token-utils.create-token-slot(border-radius, calendar-container-shape); } + &.mat-datepicker-content-animations-enabled { + animation: _mat-datepicker-content-dropdown-enter 120ms cubic-bezier(0, 0, 0.2, 1); + } + .mat-calendar { width: $non-touch-calendar-width; height: $non-touch-calendar-height; @@ -59,7 +96,7 @@ $touch-max-height: 788px; // Hide the button while the overlay is animating, because it's rendered // outside of it and it seems to cause scrollbars in some cases (see #21493). - .ng-animating & { + .mat-datepicker-content-animating & { display: none; } } @@ -89,6 +126,10 @@ $touch-max-height: 788px; // Prevents the content from jumping around on Windows while the animation is running. overflow: visible; + &.mat-datepicker-content-animations-enabled { + animation: _mat-datepicker-content-dialog-enter 150ms cubic-bezier(0, 0, 0.2, 1); + } + .mat-datepicker-content-container { min-height: $touch-min-height; max-height: $touch-max-height; @@ -102,6 +143,10 @@ $touch-max-height: 788px; } } +.mat-datepicker-content-exit.mat-datepicker-content-animations-enabled { + animation: _mat-datepicker-content-exit 100ms linear; +} + @media all and (orientation: landscape) { .mat-datepicker-content-touch .mat-datepicker-content-container { width: $touch-landscape-width; diff --git a/src/material/datepicker/datepicker.spec.ts b/src/material/datepicker/datepicker.spec.ts index 0078af06367f..b5927d3e0afe 100644 --- a/src/material/datepicker/datepicker.spec.ts +++ b/src/material/datepicker/datepicker.spec.ts @@ -481,17 +481,13 @@ describe('MatDatepicker', () => { for (let i = 0; i < 3; i++) { testComponent.datepicker.open(); fixture.detectChanges(); - tick(); testComponent.datepicker.close(); fixture.detectChanges(); - tick(); } testComponent.datepicker.open(); fixture.detectChanges(); - tick(); - flush(); const spy = jasmine.createSpy('close event spy'); const subscription = testComponent.datepicker.closedStream.subscribe(spy); @@ -499,7 +495,6 @@ describe('MatDatepicker', () => { backdrop.click(); fixture.detectChanges(); - flush(); expect(spy).toHaveBeenCalledTimes(1); expect(testComponent.datepicker.opened).toBe(false); diff --git a/tools/public_api_guard/material/datepicker.md b/tools/public_api_guard/material/datepicker.md index e36f6f9de145..d6017429bd93 100644 --- a/tools/public_api_guard/material/datepicker.md +++ b/tools/public_api_guard/material/datepicker.md @@ -8,7 +8,6 @@ import { AbstractControl } from '@angular/forms'; import { AfterContentInit } from '@angular/core'; import { AfterViewChecked } from '@angular/core'; import { AfterViewInit } from '@angular/core'; -import { AnimationEvent as AnimationEvent_2 } from '@angular/animations'; import { AnimationTriggerMetadata } from '@angular/animations'; import { ChangeDetectorRef } from '@angular/core'; import { ComponentType } from '@angular/cdk/portal'; @@ -338,7 +337,7 @@ export class MatDatepickerActions implements AfterViewInit, OnDestroy { static ɵfac: i0.ɵɵFactoryDeclaration; } -// @public +// @public @deprecated export const matDatepickerAnimations: { readonly transformPanel: AnimationTriggerMetadata; readonly fadeInCalendar: AnimationTriggerMetadata; @@ -367,11 +366,12 @@ export class MatDatepickerCancel { } // @public -export class MatDatepickerContent> implements OnInit, AfterViewInit, OnDestroy { +export class MatDatepickerContent> implements AfterViewInit, OnDestroy { constructor(...args: unknown[]); _actionsPortal: TemplatePortal | null; readonly _animationDone: Subject; - _animationState: 'enter-dropdown' | 'enter-dialog' | 'void'; + // (undocumented) + protected _animationsDisabled: boolean; _applyPendingSelection(): void; _assignActions(portal: TemplatePortal | null, forceRerender: boolean): void; _calendar: MatCalendar; @@ -383,13 +383,11 @@ export class MatDatepickerContent> implem datepicker: MatDatepickerBase; _dialogLabelId: string | null; // (undocumented) - protected _elementRef: ElementRef; + protected _elementRef: ElementRef; endDateAccessibleName: string | null; // (undocumented) _getSelected(): D | DateRange | null; // (undocumented) - _handleAnimationEvent(event: AnimationEvent_2): void; - // (undocumented) _handleUserDragDrop(event: MatCalendarUserEvent>): void; // (undocumented) _handleUserSelection(event: MatCalendarUserEvent): void; @@ -399,8 +397,6 @@ export class MatDatepickerContent> implem ngAfterViewInit(): void; // (undocumented) ngOnDestroy(): void; - // (undocumented) - ngOnInit(): void; startDateAccessibleName: string | null; // (undocumented) _startExitAnimation(): void;