From c3e267ffb6ed4be61cb07d1e2087433b9df6f15e Mon Sep 17 00:00:00 2001 From: Kristiyan Kostadinov Date: Wed, 13 Dec 2017 23:03:09 +0100 Subject: [PATCH] feat(datepicker): add animation to calendar popup (#8542) Adds an animation when opening and closing the datepicker's calendar. --- src/lib/datepicker/datepicker-content.html | 1 + src/lib/datepicker/datepicker-content.scss | 5 ++ src/lib/datepicker/datepicker.spec.ts | 53 ++++++++++++++++++- src/lib/datepicker/datepicker.ts | 60 ++++++++++++++++++++-- 4 files changed, 114 insertions(+), 5 deletions(-) diff --git a/src/lib/datepicker/datepicker-content.html b/src/lib/datepicker/datepicker-content.html index 60c0cc74780..9b8f29bcf4d 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-content.scss b/src/lib/datepicker/datepicker-content.scss index 077311434a8..a1e2f6769b2 100644 --- a/src/lib/datepicker/datepicker-content.scss +++ b/src/lib/datepicker/datepicker-content.scss @@ -28,6 +28,7 @@ $mat-datepicker-touch-max-height: 788px; @include mat-elevation(8); display: block; + transform-origin: top center; .mat-calendar { width: $mat-datepicker-non-touch-calendar-width; @@ -35,6 +36,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 3c74d6d315d..22cff664703 100644 --- a/src/lib/datepicker/datepicker.spec.ts +++ b/src/lib/datepicker/datepicker.spec.ts @@ -167,7 +167,7 @@ describe('MatDatepicker', () => { expect(parseInt(getComputedStyle(popup).height as string)).toBe(0); }); - it('should close the popup when pressing ESCAPE', () => { + it('should close the popup when pressing ESCAPE', fakeAsync(() => { testComponent.datepicker.open(); fixture.detectChanges(); @@ -179,6 +179,7 @@ describe('MatDatepicker', () => { dispatchEvent(content, keyboardEvent); fixture.detectChanges(); + flush(); content = document.querySelector('.cdk-overlay-pane mat-datepicker-content')!; @@ -186,7 +187,7 @@ describe('MatDatepicker', () => { expect(stopPropagationSpy).toHaveBeenCalled(); expect(keyboardEvent.defaultPrevented) .toBe(true, 'Expected default ESCAPE action to be prevented.'); - }); + })); it('close should close dialog', async(() => { testComponent.touch = true; @@ -1236,6 +1237,54 @@ describe('MatDatepicker', () => { }); })); }); + + describe('popup 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 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 527605aca82..c0e5abbd1a6 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'; @@ -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,73 @@ 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', + '[class.mat-datepicker-content-above]': '_isAbove', '(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 { +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; - @ViewChild(MatCalendar) _calendar: MatCalendar; + /** Whether the datepicker is above or below the input. */ + _isAbove: boolean; + + 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 isAbove = change.connectionPair.overlayY === 'bottom'; + + if (isAbove !== this._isAbove) { + this._isAbove = isAbove; + 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. @@ -214,7 +268,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;