From c42549ee29e2691c1c7e43ba63cd1a6d1b0323f2 Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Fri, 9 Mar 2018 10:26:07 -0500 Subject: [PATCH] feat(datepicker): add animation to calendar popup (#8999) Adds an animation when opening and closing the datepicker's calendar. BREAKING CHANGES: * `MatDatePicker` now requires an animations module to be loaded --- src/lib/datepicker/datepicker-animations.ts | 36 +++++++ src/lib/datepicker/datepicker-content.html | 1 + src/lib/datepicker/datepicker-content.scss | 5 + src/lib/datepicker/datepicker.spec.ts | 110 ++++++++++++-------- src/lib/datepicker/datepicker.ts | 58 ++++++++++- src/lib/datepicker/public-api.ts | 2 +- 6 files changed, 165 insertions(+), 47 deletions(-) create mode 100644 src/lib/datepicker/datepicker-animations.ts diff --git a/src/lib/datepicker/datepicker-animations.ts b/src/lib/datepicker/datepicker-animations.ts new file mode 100644 index 00000000000..751ce4ee2e6 --- /dev/null +++ b/src/lib/datepicker/datepicker-animations.ts @@ -0,0 +1,36 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + animate, + state, + style, + transition, + trigger, + AnimationTriggerMetadata, +} from '@angular/animations'; + +/** Animations used by the Material datepicker. */ +export const matDatepickerAnimations: { + readonly transformPanel: AnimationTriggerMetadata; + readonly fadeInCalendar: AnimationTriggerMetadata; +} = { + /** Transforms the height of the datepicker's calendar. */ + transformPanel: trigger('transformPanel', [ + state('void', style({opacity: 0, transform: 'scale(1, 0)'})), + state('enter', style({opacity: 1, transform: 'scale(1, 1)'})), + transition('void => enter', animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)')), + transition('* => void', animate('100ms linear', style({opacity: 0}))) + ]), + + /** Fades in the content of the calendar. */ + fadeInCalendar: trigger('fadeInCalendar', [ + state('void', style({opacity: 0})), + state('enter', style({opacity: 1})), + transition('void => *', animate('400ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)')) + ]) +}; diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index 77269cecf75..3ade43b7933 100644 --- a/src/lib/datepicker/datepicker-content.html +++ b/src/lib/datepicker/datepicker-content.html @@ -7,6 +7,7 @@ [maxDate]="datepicker._maxDate" [dateFilter]="datepicker._dateFilter" [selected]="datepicker._selected" + [@fadeInCalendar]="'enter'" (selectedChange)="datepicker._select($event)" (yearSelected)="datepicker._selectYear($event)" (monthSelected)="datepicker._selectMonth($event)" diff --git a/src/lib/datepicker/datepicker-content.scss b/src/lib/datepicker/datepicker-content.scss index cba88c32e08..c18025b6727 100644 --- a/src/lib/datepicker/datepicker-content.scss +++ b/src/lib/datepicker/datepicker-content.scss @@ -29,6 +29,7 @@ $mat-datepicker-touch-max-height: 788px; display: block; border-radius: 2px; + transform-origin: top center; .mat-calendar { width: $mat-datepicker-non-touch-calendar-width; @@ -36,6 +37,10 @@ $mat-datepicker-touch-max-height: 788px; } } +.mat-datepicker-content-above { + transform-origin: bottom center; +} + .mat-datepicker-content-touch { @include mat-elevation(0); diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 4130799ee87..a9036b15c7a 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -98,40 +98,19 @@ describe('MatDatepicker', () => { .not.toBeNull(); }); - it('should pass the datepicker theme color to the overlay', fakeAsync(() => { - testComponent.datepicker.color = 'primary'; - testComponent.datepicker.open(); - fixture.detectChanges(); - - let contentEl = document.querySelector('.mat-datepicker-content')!; - - expect(contentEl.classList).toContain('mat-primary'); - - testComponent.datepicker.close(); - fixture.detectChanges(); - flush(); - - testComponent.datepicker.color = 'warn'; - testComponent.datepicker.open(); - - contentEl = document.querySelector('.mat-datepicker-content')!; - fixture.detectChanges(); - - expect(contentEl.classList).toContain('mat-warn'); - expect(contentEl.classList).not.toContain('mat-primary'); - })); - - it('should open datepicker if opened input is set to true', () => { + it('should open datepicker if opened input is set to true', fakeAsync(() => { testComponent.opened = true; fixture.detectChanges(); + flush(); expect(document.querySelector('.mat-datepicker-content')).not.toBeNull(); testComponent.opened = false; fixture.detectChanges(); + flush(); expect(document.querySelector('.mat-datepicker-content')).toBeNull(); - }); + })); it('open in disabled mode should not open the calendar', () => { testComponent.disabled = true; @@ -165,7 +144,7 @@ describe('MatDatepicker', () => { fixture.detectChanges(); flush(); - let popup = document.querySelector('.cdk-overlay-pane')!; + const popup = document.querySelector('.cdk-overlay-pane')!; expect(popup).not.toBeNull(); expect(parseInt(getComputedStyle(popup).height as string)).not.toBe(0); @@ -211,6 +190,7 @@ describe('MatDatepicker', () => { testComponent.datepicker.open(); fixture.detectChanges(); + flush(); expect(document.querySelector('mat-dialog-container')).not.toBeNull(); expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); @@ -224,28 +204,30 @@ describe('MatDatepicker', () => { expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2)); })); - it('setting selected via enter press should update input and close calendar', () => { - testComponent.touch = true; - fixture.detectChanges(); + it('setting selected via enter press should update input and close calendar', + fakeAsync(() => { + testComponent.touch = true; + fixture.detectChanges(); - testComponent.datepicker.open(); - fixture.detectChanges(); + testComponent.datepicker.open(); + fixture.detectChanges(); + flush(); - expect(document.querySelector('mat-dialog-container')).not.toBeNull(); - expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); + expect(document.querySelector('mat-dialog-container')).not.toBeNull(); + expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1)); - let calendarBodyEl = document.querySelector('.mat-calendar-body') as HTMLElement; + let calendarBodyEl = document.querySelector('.mat-calendar-body') as HTMLElement; - dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); - fixture.detectChanges(); - dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); - fixture.detectChanges(); + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + flush(); + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + flush(); - fixture.whenStable().then(() => { expect(document.querySelector('mat-dialog-container')).toBeNull(); expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2)); - }); - }); + })); it('clicking the currently selected date should close the calendar ' + 'without firing selectedChanged', fakeAsync(() => { @@ -1342,6 +1324,52 @@ describe('MatDatepicker', () => { expect(testComponent.datepickerInput.value).toBe(selected); })); }); + + describe('popup animations', () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatDatepickerModule, MatNativeDateModule, NoopAnimationsModule], + declarations: [StandardDatepicker], + }).compileComponents(); + + fixture = TestBed.createComponent(StandardDatepicker); + fixture.detectChanges(); + })); + + it('should not set the `mat-datepicker-content-above` class when opening downwards', + fakeAsync(() => { + fixture.componentInstance.datepicker.open(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const content = + document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement; + + expect(content.classList).not.toContain('mat-datepicker-content-above'); + })); + + it('should set the `mat-datepicker-content-above` class when opening upwards', fakeAsync(() => { + const input = fixture.debugElement.nativeElement.querySelector('input'); + + // Push the input to the bottom of the page to force the calendar to open upwards + input.style.position = 'fixed'; + input.style.bottom = '0'; + + fixture.componentInstance.datepicker.open(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const content = + document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement; + + expect(content.classList).toContain('mat-datepicker-content-above'); + })); + + }); }); diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index 7ef1291d9aa..c06e7d513fd 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -16,6 +16,7 @@ import { PositionStrategy, RepositionScrollStrategy, ScrollStrategy, + ConnectedPositionStrategy, } from '@angular/cdk/overlay'; import {ComponentPortal} from '@angular/cdk/portal'; import {take} from 'rxjs/operators/take'; @@ -37,6 +38,8 @@ import { ViewChild, ViewContainerRef, ViewEncapsulation, + ChangeDetectorRef, + OnInit, } from '@angular/core'; import {CanColor, DateAdapter, mixinColor, ThemePalette} from '@angular/material/core'; import {MatDialog, MatDialogRef} from '@angular/material/dialog'; @@ -47,7 +50,7 @@ import {merge} from 'rxjs/observable/merge'; import {createMissingDateImplError} from './datepicker-errors'; import {MatDatepickerInput} from './datepicker-input'; import {MatCalendar} from './calendar'; - +import {matDatepickerAnimations} from './datepicker-animations'; /** Used to generate a unique ID for each datepicker instance. */ let datepickerUid = 0; @@ -90,23 +93,61 @@ export const _MatDatepickerContentMixinBase = mixinColor(MatDatepickerContentBas styleUrls: ['datepicker-content.css'], host: { 'class': 'mat-datepicker-content', + '[@transformPanel]': '"enter"', '[class.mat-datepicker-content-touch]': 'datepicker.touchUi', + '[class.mat-datepicker-content-above]': '_isAbove', }, + animations: [ + matDatepickerAnimations.transformPanel, + matDatepickerAnimations.fadeInCalendar, + ], exportAs: 'matDatepickerContent', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, inputs: ['color'], }) export class MatDatepickerContent extends _MatDatepickerContentMixinBase - implements AfterContentInit, CanColor { - datepicker: MatDatepicker; + implements AfterContentInit, CanColor, OnInit, OnDestroy { + /** Subscription to changes in the overlay's position. */ + private _positionChange: Subscription|null; + + /** Reference to the internal calendar component. */ @ViewChild(MatCalendar) _calendar: MatCalendar; - constructor(elementRef: ElementRef, private _ngZone: NgZone) { + /** Reference to the datepicker that created the overlay. */ + datepicker: MatDatepicker; + + /** Whether the datepicker is above or below the input. */ + _isAbove: boolean; + + constructor( + elementRef: ElementRef, + private _changeDetectorRef: ChangeDetectorRef, + private _ngZone: NgZone) { super(elementRef); } + ngOnInit() { + if (!this.datepicker._popupRef || this._positionChange) { + return; + } + + const positionStrategy = + this.datepicker._popupRef.getConfig().positionStrategy! as ConnectedPositionStrategy; + + this._positionChange = positionStrategy.onPositionChange.subscribe(change => { + const isAbove = change.connectionPair.overlayY === 'bottom'; + + if (isAbove !== this._isAbove) { + this._ngZone.run(() => { + this._isAbove = isAbove; + this._changeDetectorRef.markForCheck(); + }); + } + }); + } + ngAfterContentInit() { this._focusActiveCell(); } @@ -119,6 +160,13 @@ export class MatDatepickerContent extends _MatDatepickerContentMixinBase }); }); } + + ngOnDestroy() { + if (this._positionChange) { + this._positionChange.unsubscribe(); + this._positionChange = null; + } + } } @@ -246,7 +294,7 @@ export class MatDatepicker implements OnDestroy, CanColor { } /** A reference to the overlay when the calendar is opened as a popup. */ - private _popupRef: OverlayRef; + _popupRef: OverlayRef; /** A reference to the dialog when the calendar is opened as a dialog. */ private _dialogRef: MatDialogRef> | null; diff --git a/src/lib/datepicker/public-api.ts b/src/lib/datepicker/public-api.ts index eb79de80b7c..45e7e3a8e3e 100644 --- a/src/lib/datepicker/public-api.ts +++ b/src/lib/datepicker/public-api.ts @@ -10,9 +10,9 @@ export * from './datepicker-module'; export * from './calendar'; export * from './calendar-body'; export * from './datepicker'; +export * from './datepicker-animations'; export * from './datepicker-input'; export * from './datepicker-intl'; export * from './datepicker-toggle'; export * from './month-view'; export * from './year-view'; -