From 8493b5d6a28d26ee4adac0b03235ab424d4ff275 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Sun, 19 Nov 2017 13:45:03 +0100 Subject: [PATCH] feat(datepicker): add animation to calendar popup Adds an animation when opening and closing the datepicker's calendar. --- src/lib/datepicker/datepicker-content.html | 1 + src/lib/datepicker/datepicker.spec.ts | 53 +++++++++++++++++-- src/lib/datepicker/datepicker.ts | 61 ++++++++++++++++++++-- 3 files changed, 109 insertions(+), 6 deletions(-) diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index 60c0cc747809..9b8f29bcf4d6 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)" (_userSelection)="datepicker.close()"> diff --git a/src/lib/datepicker/datepicker.spec.ts b/src/lib/datepicker/datepicker.spec.ts index 81576b717270..407f2246b4c4 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -7,7 +7,7 @@ import { dispatchMouseEvent, } from '@angular/cdk/testing'; import {Component, ViewChild} from '@angular/core'; -import {async, ComponentFixture, inject, TestBed} from '@angular/core/testing'; +import {async, ComponentFixture, inject, TestBed, fakeAsync, flush} from '@angular/core/testing'; import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import { DEC, @@ -156,7 +156,7 @@ describe('MatDatepicker', () => { }); }); - it('should close the popup when pressing ESCAPE', () => { + it('should close the popup when pressing ESCAPE', fakeAsync(() => { testComponent.datepicker.open(); fixture.detectChanges(); @@ -168,6 +168,7 @@ describe('MatDatepicker', () => { dispatchEvent(content, keyboardEvent); fixture.detectChanges(); + flush(); content = document.querySelector('.cdk-overlay-pane mat-datepicker-content')!; @@ -175,7 +176,7 @@ describe('MatDatepicker', () => { expect(stopPropagationSpy).toHaveBeenCalled(); expect(keyboardEvent.defaultPrevented) .toBe(true, 'Expected default ESCAPE action to be prevented.'); - }); + })); it('close should close dialog', () => { testComponent.touch = true; @@ -1090,6 +1091,52 @@ describe('MatDatepicker', () => { }); }); }); + + describe('animations', () => { + let fixture: ComponentFixture; + let testComponent: StandardDatepicker; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [MatDatepickerModule, MatNativeDateModule, NoopAnimationsModule], + declarations: [StandardDatepicker], + }).compileComponents(); + + fixture = TestBed.createComponent(StandardDatepicker); + fixture.detectChanges(); + testComponent = fixture.componentInstance; + })); + + it('should set the correct transform origin when opening upwards', fakeAsync(() => { + fixture.componentInstance.datepicker.open(); + fixture.detectChanges(); + flush(); + fixture.detectChanges(); + + const content = + document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement; + + expect(content.style.transformOrigin).toContain('top'); + })); + + it('should set the correct transform origin when opening downwards', fakeAsync(() => { + const input = fixture.debugElement.nativeElement.querySelector('input'); + + 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.style.transformOrigin).toContain('bottom'); + })); + + }); }); diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index ae0bd02aab63..cdeaecbad034 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 {first} from 'rxjs/operators/first'; @@ -35,6 +36,8 @@ import { ViewChild, ViewContainerRef, ViewEncapsulation, + ChangeDetectorRef, + OnInit, } from '@angular/core'; import {DateAdapter} from '@angular/material/core'; import {MatDialog, MatDialogRef} from '@angular/material/dialog'; @@ -44,6 +47,7 @@ import {Subscription} from 'rxjs/Subscription'; import {MatCalendar} from './calendar'; import {createMissingDateImplError} from './datepicker-errors'; import {MatDatepickerInput} from './datepicker-input'; +import{trigger, state, style, animate, transition} from '@angular/animations'; /** Used to generate a unique ID for each datepicker instance. */ @@ -81,23 +85,74 @@ export const MAT_DATEPICKER_SCROLL_STRATEGY_PROVIDER = { styleUrls: ['datepicker-content.css'], host: { 'class': 'mat-datepicker-content', + '[@tranformPanel]': '"enter"', '[class.mat-datepicker-content-touch]': 'datepicker.touchUi', + // Note: binding to `transform-origin` here doesn't work on IE and Edge for some reason. + '[style.transformOrigin]': '_transformOrigin', '(keydown)': '_handleKeydown($event)', }, + animations: [ + trigger('tranformPanel', [ + 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}))) + ]), + 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)')) + ]) + ], exportAs: 'matDatepickerContent', encapsulation: ViewEncapsulation.None, preserveWhitespaces: false, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatDatepickerContent implements AfterContentInit { - datepicker: MatDatepicker; +export class MatDatepickerContent implements AfterContentInit, OnInit, OnDestroy { + /** Subscription to changes in the overlay's position. */ + private _positionChange: Subscription|null; + /** Reference to the internal calendar component. */ @ViewChild(MatCalendar) _calendar: MatCalendar; + /** Reference to the datepicker that created the overlay. */ + datepicker: MatDatepicker; + + /** Origin of the opening animation. */ + _transformOrigin: string; + + constructor(private _changeDetectorRef: ChangeDetectorRef) {} + + ngOnInit() { + if (!this.datepicker._popupRef || this._positionChange) { + return; + } + + const positionStrategy = + this.datepicker._popupRef.getConfig().positionStrategy! as ConnectedPositionStrategy; + + this._positionChange = positionStrategy.onPositionChange.subscribe(change => { + const newOrigin = `${change.connectionPair.overlayY} center`; + + if (newOrigin !== this._transformOrigin) { + this._transformOrigin = newOrigin; + this._changeDetectorRef.markForCheck(); + } + }); + } + ngAfterContentInit() { this._calendar._focusActiveCell(); } + ngOnDestroy() { + if (this._positionChange) { + this._positionChange.unsubscribe(); + this._positionChange = null; + } + } + /** * Handles keydown event on datepicker content. * @param event The event. @@ -211,7 +266,7 @@ export class MatDatepicker implements OnDestroy { } /** 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;