Skip to content

Commit 0bb1d49

Browse files
crisbetommalerba
authored andcommitted
feat(datepicker): add animation to calendar popup
Adds an animation when opening and closing the datepicker's calendar. This is a resubmit of #8542.
1 parent 140c94e commit 0bb1d49

File tree

6 files changed

+167
-47
lines changed

6 files changed

+167
-47
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
import {
9+
animate,
10+
state,
11+
style,
12+
transition,
13+
trigger,
14+
AnimationTriggerMetadata,
15+
} from '@angular/animations';
16+
17+
/** Animations used by the Material datepicker. */
18+
export const matDatepickerAnimations: {
19+
readonly transformPanel: AnimationTriggerMetadata;
20+
readonly fadeInCalendar: AnimationTriggerMetadata;
21+
} = {
22+
/** Transforms the height of the datepicker's calendar. */
23+
transformPanel: trigger('transformPanel', [
24+
state('void', style({opacity: 0, transform: 'scale(1, 0)'})),
25+
state('enter', style({opacity: 1, transform: 'scale(1, 1)'})),
26+
transition('void => enter', animate('400ms cubic-bezier(0.25, 0.8, 0.25, 1)')),
27+
transition('* => void', animate('100ms linear', style({opacity: 0})))
28+
]),
29+
30+
/** Fades in the content of the calendar. */
31+
fadeInCalendar: trigger('fadeInCalendar', [
32+
state('void', style({opacity: 0})),
33+
state('enter', style({opacity: 1})),
34+
transition('void => *', animate('400ms 100ms cubic-bezier(0.55, 0, 0.55, 0.2)'))
35+
])
36+
};

src/lib/datepicker/datepicker-content.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
[maxDate]="datepicker._maxDate"
88
[dateFilter]="datepicker._dateFilter"
99
[selected]="datepicker._selected"
10+
[@fadeInCalendar]="'enter'"
1011
(selectedChange)="datepicker._select($event)"
1112
(yearSelected)="datepicker._selectYear($event)"
1213
(monthSelected)="datepicker._selectMonth($event)"

src/lib/datepicker/datepicker-content.scss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,18 @@ $mat-datepicker-touch-max-height: 788px;
2929

3030
display: block;
3131
border-radius: 2px;
32+
transform-origin: top center;
3233

3334
.mat-calendar {
3435
width: $mat-datepicker-non-touch-calendar-width;
3536
height: $mat-datepicker-non-touch-calendar-height;
3637
}
3738
}
3839

40+
.mat-datepicker-content-above {
41+
transform-origin: bottom center;
42+
}
43+
3944
.mat-datepicker-content-touch {
4045
@include mat-elevation(0);
4146

src/lib/datepicker/datepicker.spec.ts

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -98,40 +98,19 @@ describe('MatDatepicker', () => {
9898
.not.toBeNull();
9999
});
100100

101-
it('should pass the datepicker theme color to the overlay', fakeAsync(() => {
102-
testComponent.datepicker.color = 'primary';
103-
testComponent.datepicker.open();
104-
fixture.detectChanges();
105-
106-
let contentEl = document.querySelector('.mat-datepicker-content')!;
107-
108-
expect(contentEl.classList).toContain('mat-primary');
109-
110-
testComponent.datepicker.close();
111-
fixture.detectChanges();
112-
flush();
113-
114-
testComponent.datepicker.color = 'warn';
115-
testComponent.datepicker.open();
116-
117-
contentEl = document.querySelector('.mat-datepicker-content')!;
118-
fixture.detectChanges();
119-
120-
expect(contentEl.classList).toContain('mat-warn');
121-
expect(contentEl.classList).not.toContain('mat-primary');
122-
}));
123-
124-
it('should open datepicker if opened input is set to true', () => {
101+
it('should open datepicker if opened input is set to true', fakeAsync(() => {
125102
testComponent.opened = true;
126103
fixture.detectChanges();
104+
flush();
127105

128106
expect(document.querySelector('.mat-datepicker-content')).not.toBeNull();
129107

130108
testComponent.opened = false;
131109
fixture.detectChanges();
110+
flush();
132111

133112
expect(document.querySelector('.mat-datepicker-content')).toBeNull();
134-
});
113+
}));
135114

136115
it('open in disabled mode should not open the calendar', () => {
137116
testComponent.disabled = true;
@@ -165,7 +144,7 @@ describe('MatDatepicker', () => {
165144
fixture.detectChanges();
166145
flush();
167146

168-
let popup = document.querySelector('.cdk-overlay-pane')!;
147+
const popup = document.querySelector('.cdk-overlay-pane')!;
169148
expect(popup).not.toBeNull();
170149
expect(parseInt(getComputedStyle(popup).height as string)).not.toBe(0);
171150

@@ -211,6 +190,7 @@ describe('MatDatepicker', () => {
211190

212191
testComponent.datepicker.open();
213192
fixture.detectChanges();
193+
flush();
214194

215195
expect(document.querySelector('mat-dialog-container')).not.toBeNull();
216196
expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1));
@@ -224,28 +204,30 @@ describe('MatDatepicker', () => {
224204
expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2));
225205
}));
226206

227-
it('setting selected via enter press should update input and close calendar', () => {
228-
testComponent.touch = true;
229-
fixture.detectChanges();
207+
it('setting selected via enter press should update input and close calendar',
208+
fakeAsync(() => {
209+
testComponent.touch = true;
210+
fixture.detectChanges();
230211

231-
testComponent.datepicker.open();
232-
fixture.detectChanges();
212+
testComponent.datepicker.open();
213+
fixture.detectChanges();
214+
flush();
233215

234-
expect(document.querySelector('mat-dialog-container')).not.toBeNull();
235-
expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1));
216+
expect(document.querySelector('mat-dialog-container')).not.toBeNull();
217+
expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 1));
236218

237-
let calendarBodyEl = document.querySelector('.mat-calendar-body') as HTMLElement;
219+
let calendarBodyEl = document.querySelector('.mat-calendar-body') as HTMLElement;
238220

239-
dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW);
240-
fixture.detectChanges();
241-
dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER);
242-
fixture.detectChanges();
221+
dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW);
222+
fixture.detectChanges();
223+
flush();
224+
dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER);
225+
fixture.detectChanges();
226+
flush();
243227

244-
fixture.whenStable().then(() => {
245228
expect(document.querySelector('mat-dialog-container')).toBeNull();
246229
expect(testComponent.datepickerInput.value).toEqual(new Date(2020, JAN, 2));
247-
});
248-
});
230+
}));
249231

250232
it('clicking the currently selected date should close the calendar ' +
251233
'without firing selectedChanged', fakeAsync(() => {
@@ -1342,6 +1324,54 @@ describe('MatDatepicker', () => {
13421324
expect(testComponent.datepickerInput.value).toBe(selected);
13431325
}));
13441326
});
1327+
1328+
describe('popup animations', () => {
1329+
let fixture: ComponentFixture<StandardDatepicker>;
1330+
let testComponent: StandardDatepicker;
1331+
1332+
beforeEach(fakeAsync(() => {
1333+
TestBed.configureTestingModule({
1334+
imports: [MatDatepickerModule, MatNativeDateModule, NoopAnimationsModule],
1335+
declarations: [StandardDatepicker],
1336+
}).compileComponents();
1337+
1338+
fixture = TestBed.createComponent(StandardDatepicker);
1339+
fixture.detectChanges();
1340+
testComponent = fixture.componentInstance;
1341+
}));
1342+
1343+
it('should not set the `mat-datepicker-content-above` class when opening downwards',
1344+
fakeAsync(() => {
1345+
fixture.componentInstance.datepicker.open();
1346+
fixture.detectChanges();
1347+
flush();
1348+
fixture.detectChanges();
1349+
1350+
const content =
1351+
document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement;
1352+
1353+
expect(content.classList).not.toContain('mat-datepicker-content-above');
1354+
}));
1355+
1356+
it('should set the `mat-datepicker-content-above` class when opening upwards', fakeAsync(() => {
1357+
const input = fixture.debugElement.nativeElement.querySelector('input');
1358+
1359+
// Push the input to the bottom of the page to force the calendar to open upwards
1360+
input.style.position = 'fixed';
1361+
input.style.bottom = '0';
1362+
1363+
fixture.componentInstance.datepicker.open();
1364+
fixture.detectChanges();
1365+
flush();
1366+
fixture.detectChanges();
1367+
1368+
const content =
1369+
document.querySelector('.cdk-overlay-pane mat-datepicker-content')! as HTMLElement;
1370+
1371+
expect(content.classList).toContain('mat-datepicker-content-above');
1372+
}));
1373+
1374+
});
13451375
});
13461376

13471377

src/lib/datepicker/datepicker.ts

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
PositionStrategy,
1717
RepositionScrollStrategy,
1818
ScrollStrategy,
19+
ConnectedPositionStrategy,
1920
} from '@angular/cdk/overlay';
2021
import {ComponentPortal} from '@angular/cdk/portal';
2122
import {take} from 'rxjs/operators/take';
@@ -37,6 +38,8 @@ import {
3738
ViewChild,
3839
ViewContainerRef,
3940
ViewEncapsulation,
41+
ChangeDetectorRef,
42+
OnInit,
4043
} from '@angular/core';
4144
import {CanColor, DateAdapter, mixinColor, ThemePalette} from '@angular/material/core';
4245
import {MatDialog, MatDialogRef} from '@angular/material/dialog';
@@ -47,7 +50,7 @@ import {merge} from 'rxjs/observable/merge';
4750
import {createMissingDateImplError} from './datepicker-errors';
4851
import {MatDatepickerInput} from './datepicker-input';
4952
import {MatCalendar} from './calendar';
50-
53+
import {matDatepickerAnimations} from './datepicker-animations';
5154

5255
/** Used to generate a unique ID for each datepicker instance. */
5356
let datepickerUid = 0;
@@ -90,23 +93,61 @@ export const _MatDatepickerContentMixinBase = mixinColor(MatDatepickerContentBas
9093
styleUrls: ['datepicker-content.css'],
9194
host: {
9295
'class': 'mat-datepicker-content',
96+
'[@transformPanel]': '"enter"',
9397
'[class.mat-datepicker-content-touch]': 'datepicker.touchUi',
98+
'[class.mat-datepicker-content-above]': '_isAbove',
9499
},
100+
animations: [
101+
matDatepickerAnimations.transformPanel,
102+
matDatepickerAnimations.fadeInCalendar,
103+
],
95104
exportAs: 'matDatepickerContent',
96105
encapsulation: ViewEncapsulation.None,
97106
changeDetection: ChangeDetectionStrategy.OnPush,
98107
inputs: ['color'],
99108
})
100109
export class MatDatepickerContent<D> extends _MatDatepickerContentMixinBase
101-
implements AfterContentInit, CanColor {
102-
datepicker: MatDatepicker<D>;
110+
implements AfterContentInit, CanColor, OnInit, OnDestroy {
111+
112+
/** Subscription to changes in the overlay's position. */
113+
private _positionChange: Subscription|null;
103114

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

106-
constructor(elementRef: ElementRef, private _ngZone: NgZone) {
118+
/** Reference to the datepicker that created the overlay. */
119+
datepicker: MatDatepicker<D>;
120+
121+
/** Whether the datepicker is above or below the input. */
122+
_isAbove: boolean;
123+
124+
constructor(
125+
elementRef: ElementRef,
126+
private _changeDetectorRef: ChangeDetectorRef,
127+
private _ngZone: NgZone) {
107128
super(elementRef);
108129
}
109130

131+
ngOnInit() {
132+
if (!this.datepicker._popupRef || this._positionChange) {
133+
return;
134+
}
135+
136+
const positionStrategy =
137+
this.datepicker._popupRef.getConfig().positionStrategy! as ConnectedPositionStrategy;
138+
139+
this._positionChange = positionStrategy.onPositionChange.subscribe(change => {
140+
const isAbove = change.connectionPair.overlayY === 'bottom';
141+
142+
if (isAbove !== this._isAbove) {
143+
this._ngZone.run(() => {
144+
this._isAbove = isAbove;
145+
this._changeDetectorRef.markForCheck();
146+
});
147+
}
148+
});
149+
}
150+
110151
ngAfterContentInit() {
111152
this._focusActiveCell();
112153
}
@@ -119,6 +160,13 @@ export class MatDatepickerContent<D> extends _MatDatepickerContentMixinBase
119160
});
120161
});
121162
}
163+
164+
ngOnDestroy() {
165+
if (this._positionChange) {
166+
this._positionChange.unsubscribe();
167+
this._positionChange = null;
168+
}
169+
}
122170
}
123171

124172

@@ -246,7 +294,7 @@ export class MatDatepicker<D> implements OnDestroy, CanColor {
246294
}
247295

248296
/** A reference to the overlay when the calendar is opened as a popup. */
249-
private _popupRef: OverlayRef;
297+
_popupRef: OverlayRef;
250298

251299
/** A reference to the dialog when the calendar is opened as a dialog. */
252300
private _dialogRef: MatDialogRef<MatDatepickerContent<D>> | null;

src/lib/datepicker/public-api.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ export * from './datepicker-module';
1010
export * from './calendar';
1111
export * from './calendar-body';
1212
export * from './datepicker';
13+
export * from './datepicker-animations';
1314
export * from './datepicker-input';
1415
export * from './datepicker-intl';
1516
export * from './datepicker-toggle';
1617
export * from './month-view';
1718
export * from './year-view';
18-

0 commit comments

Comments
 (0)