Skip to content

Commit

Permalink
feat(datepicker): add animation to calendar popup (#8999)
Browse files Browse the repository at this point in the history
Adds an animation when opening and closing the datepicker's calendar.

BREAKING CHANGES:
* `MatDatePicker` now requires an animations module to be loaded
  • Loading branch information
crisbeto authored and jelbourn committed Mar 9, 2018
1 parent c224300 commit c42549e
Show file tree
Hide file tree
Showing 6 changed files with 165 additions and 47 deletions.
36 changes: 36 additions & 0 deletions 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)'))
])
};
1 change: 1 addition & 0 deletions src/lib/datepicker/datepicker-content.html
Expand Up @@ -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)"
Expand Down
5 changes: 5 additions & 0 deletions src/lib/datepicker/datepicker-content.scss
Expand Up @@ -29,13 +29,18 @@ $mat-datepicker-touch-max-height: 788px;

display: block;
border-radius: 2px;
transform-origin: top center;

.mat-calendar {
width: $mat-datepicker-non-touch-calendar-width;
height: $mat-datepicker-non-touch-calendar-height;
}
}

.mat-datepicker-content-above {
transform-origin: bottom center;
}

.mat-datepicker-content-touch {
@include mat-elevation(0);

Expand Down
110 changes: 69 additions & 41 deletions src/lib/datepicker/datepicker.spec.ts
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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));
Expand All @@ -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(() => {
Expand Down Expand Up @@ -1342,6 +1324,52 @@ describe('MatDatepicker', () => {
expect(testComponent.datepickerInput.value).toBe(selected);
}));
});

describe('popup animations', () => {
let fixture: ComponentFixture<StandardDatepicker>;

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

});
});


Expand Down
58 changes: 53 additions & 5 deletions src/lib/datepicker/datepicker.ts
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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<D> extends _MatDatepickerContentMixinBase
implements AfterContentInit, CanColor {
datepicker: MatDatepicker<D>;
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<D>;

constructor(elementRef: ElementRef, private _ngZone: NgZone) {
/** Reference to the datepicker that created the overlay. */
datepicker: MatDatepicker<D>;

/** 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();
}
Expand All @@ -119,6 +160,13 @@ export class MatDatepickerContent<D> extends _MatDatepickerContentMixinBase
});
});
}

ngOnDestroy() {
if (this._positionChange) {
this._positionChange.unsubscribe();
this._positionChange = null;
}
}
}


Expand Down Expand Up @@ -246,7 +294,7 @@ export class MatDatepicker<D> 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<MatDatepickerContent<D>> | null;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/datepicker/public-api.ts
Expand Up @@ -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';

0 comments on commit c42549e

Please sign in to comment.