Skip to content

Commit

Permalink
fix(material/datepicker): switch away from animations module (#30360)
Browse files Browse the repository at this point in the history
Reworks the datepicker so it no longer depends on the animations module.
  • Loading branch information
crisbeto authored Jan 21, 2025
1 parent 013fe04 commit 066c740
Show file tree
Hide file tree
Showing 6 changed files with 109 additions and 46 deletions.
2 changes: 2 additions & 0 deletions src/material/datepicker/datepicker-animations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 56 additions & 30 deletions src/material/datepicker/datepicker-base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -34,6 +33,7 @@ import {DOCUMENT} from '@angular/common';
import {
afterNextRender,
AfterViewInit,
ANIMATION_MODULE_TYPE,
booleanAttribute,
ChangeDetectionStrategy,
ChangeDetectorRef,
Expand All @@ -46,10 +46,11 @@ import {
InjectionToken,
Injector,
Input,
NgZone,
OnChanges,
OnDestroy,
OnInit,
Output,
Renderer2,
SimpleChanges,
ViewChild,
ViewContainerRef,
Expand All @@ -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';
Expand Down Expand Up @@ -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<S, D = ExtractDateTypeFromSelection<S>>
implements OnInit, AfterViewInit, OnDestroy
implements AfterViewInit, OnDestroy
{
protected _elementRef = inject(ElementRef);
protected _elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
protected _animationsDisabled =
inject(ANIMATION_MODULE_TYPE, {optional: true}) === 'NoopAnimations';
private _changeDetectorRef = inject(ChangeDetectorRef);
private _globalModel = inject<MatDateSelectionModel<S, D>>(MatDateSelectionModel);
private _dateAdapter = inject<DateAdapter<D>>(DateAdapter)!;
private _ngZone = inject(NgZone);
private _rangeSelectionStrategy = inject<MatDateRangeSelectionStrategy<D>>(
MAT_DATE_RANGE_SELECTION_STRATEGY,
{optional: true},
);

private _subscriptions = new Subscription();
private _stateChanges: Subscription | undefined;
private _model: MatDateSelectionModel<S, D>;
private _eventCleanups: (() => void)[] | undefined;
private _animationFallback: ReturnType<typeof setTimeout> | undefined;

/** Reference to the internal calendar component. */
@ViewChild(MatCalendar) _calendar: MatCalendar<D>;

Expand Down Expand Up @@ -175,9 +178,6 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>>
/** 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<void>();

Expand All @@ -200,26 +200,31 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>>

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();
}

Expand Down Expand Up @@ -258,17 +263,38 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>>
}

_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<D> | null;
Expand Down Expand Up @@ -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;

Expand All @@ -690,6 +715,7 @@ export abstract class MatDatepickerBase<
this._focusedElementBeforeOpen = null;
this._destroyOverlay();
});
instance._startExitAnimation();
}

if (canRestoreFocus) {
Expand Down
1 change: 0 additions & 1 deletion src/material/datepicker/datepicker-content.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
[dateClass]="datepicker.dateClass"
[comparisonStart]="comparisonStart"
[comparisonEnd]="comparisonEnd"
[@fadeInCalendar]="'enter'"
[startDateAccessibleName]="startDateAccessibleName"
[endDateAccessibleName]="endDateAccessibleName"
(yearSelected)="datepicker._selectYear($event)"
Expand Down
47 changes: 46 additions & 1 deletion src/material/datepicker/datepicker-content.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
}
}
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
5 changes: 0 additions & 5 deletions src/material/datepicker/datepicker.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,25 +481,20 @@ 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);
const backdrop = document.querySelector('.cdk-overlay-backdrop')! as HTMLElement;

backdrop.click();
fixture.detectChanges();
flush();

expect(spy).toHaveBeenCalledTimes(1);
expect(testComponent.datepicker.opened).toBe(false);
Expand Down
14 changes: 5 additions & 9 deletions tools/public_api_guard/material/datepicker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -338,7 +337,7 @@ export class MatDatepickerActions implements AfterViewInit, OnDestroy {
static ɵfac: i0.ɵɵFactoryDeclaration<MatDatepickerActions, never>;
}

// @public
// @public @deprecated
export const matDatepickerAnimations: {
readonly transformPanel: AnimationTriggerMetadata;
readonly fadeInCalendar: AnimationTriggerMetadata;
Expand Down Expand Up @@ -367,11 +366,12 @@ export class MatDatepickerCancel {
}

// @public
export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>> implements OnInit, AfterViewInit, OnDestroy {
export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>> implements AfterViewInit, OnDestroy {
constructor(...args: unknown[]);
_actionsPortal: TemplatePortal | null;
readonly _animationDone: Subject<void>;
_animationState: 'enter-dropdown' | 'enter-dialog' | 'void';
// (undocumented)
protected _animationsDisabled: boolean;
_applyPendingSelection(): void;
_assignActions(portal: TemplatePortal<any> | null, forceRerender: boolean): void;
_calendar: MatCalendar<D>;
Expand All @@ -383,13 +383,11 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>> implem
datepicker: MatDatepickerBase<any, S, D>;
_dialogLabelId: string | null;
// (undocumented)
protected _elementRef: ElementRef<any>;
protected _elementRef: ElementRef<HTMLElement>;
endDateAccessibleName: string | null;
// (undocumented)
_getSelected(): D | DateRange<D> | null;
// (undocumented)
_handleAnimationEvent(event: AnimationEvent_2): void;
// (undocumented)
_handleUserDragDrop(event: MatCalendarUserEvent<DateRange<D>>): void;
// (undocumented)
_handleUserSelection(event: MatCalendarUserEvent<D | null>): void;
Expand All @@ -399,8 +397,6 @@ export class MatDatepickerContent<S, D = ExtractDateTypeFromSelection<S>> implem
ngAfterViewInit(): void;
// (undocumented)
ngOnDestroy(): void;
// (undocumented)
ngOnInit(): void;
startDateAccessibleName: string | null;
// (undocumented)
_startExitAnimation(): void;
Expand Down

0 comments on commit 066c740

Please sign in to comment.