feat(datepicker): add year selection mode (#8565)

* feat(datepicker): add multi-year view.

* fix broken tests

* add calendar tests for multi-year

* add tests for multi year view

* update docs

* address comments

* address comments
This commit is contained in:
mmalerba
2018-01-12 09:15:50 -08:00
committed by Andrew Seguin
parent 1701b9871f
commit cdbabf7366
13 changed files with 642 additions and 117 deletions
+2
View File
@@ -39,6 +39,8 @@ export class MatCalendarCell {
styleUrls: ['calendar-body.css'],
host: {
'class': 'mat-calendar-body',
'role': 'grid',
'attr.aria-readonly': 'true'
},
exportAs: 'matCalendarBody',
encapsulation: ViewEncapsulation.None,
+13 -5
View File
@@ -3,7 +3,7 @@
<button mat-button class="mat-calendar-period-button"
(click)="_currentPeriodClicked()" [attr.aria-label]="_periodButtonLabel">
{{_periodButtonText}}
<div class="mat-calendar-arrow" [class.mat-calendar-invert]="!_monthView"></div>
<div class="mat-calendar-arrow" [class.mat-calendar-invert]="_currentView != 'month'"></div>
</button>
<div class="mat-calendar-spacer"></div>
@@ -21,9 +21,9 @@
</div>
<div class="mat-calendar-content" (keydown)="_handleCalendarBodyKeydown($event)"
[ngSwitch]="_monthView" cdkMonitorSubtreeFocus>
[ngSwitch]="_currentView" cdkMonitorSubtreeFocus>
<mat-month-view
*ngSwitchCase="true"
*ngSwitchCase="'month'"
[activeDate]="_activeDate"
[selected]="selected"
[dateFilter]="_dateFilterForViews"
@@ -32,10 +32,18 @@
</mat-month-view>
<mat-year-view
*ngSwitchDefault
*ngSwitchCase="'year'"
[activeDate]="_activeDate"
[selected]="selected"
[dateFilter]="_dateFilterForViews"
(selectedChange)="_monthSelected($event)">
(selectedChange)="_goToDateInView($event, 'month')">
</mat-year-view>
<mat-multi-year-view
*ngSwitchCase="'multi-year'"
[activeDate]="_activeDate"
[selected]="selected"
[dateFilter]="_dateFilterForViews"
(selectedChange)="_goToDateInView($event, 'year')">
</mat-multi-year-view>
</div>
+211 -17
View File
@@ -32,6 +32,7 @@ import {MatCalendar} from './calendar';
import {MatCalendarBody} from './calendar-body';
import {MatDatepickerIntl} from './datepicker-intl';
import {MatMonthView} from './month-view';
import {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view';
import {MatYearView} from './year-view';
@@ -47,6 +48,7 @@ describe('MatCalendar', () => {
MatCalendarBody,
MatMonthView,
MatYearView,
MatMultiYearView,
// Test components.
StandardCalendar,
@@ -85,22 +87,22 @@ describe('MatCalendar', () => {
});
it('should be in month view with specified month active', () => {
expect(calendarInstance._monthView).toBe(true, 'should be in month view');
expect(calendarInstance._currentView).toBe('month');
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31));
});
it('should toggle view when period clicked', () => {
expect(calendarInstance._monthView).toBe(true, 'should be in month view');
expect(calendarInstance._currentView).toBe('month');
periodButton.click();
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(false, 'should be in year view');
expect(calendarInstance._currentView).toBe('multi-year');
periodButton.click();
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(true, 'should be in month view');
expect(calendarInstance._currentView).toBe('month');
});
it('should go to next and previous month', () => {
@@ -121,9 +123,14 @@ describe('MatCalendar', () => {
periodButton.click();
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(false, 'should be in year view');
expect(calendarInstance._currentView).toBe('multi-year');
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31));
(calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click();
fixture.detectChanges();
expect(calendarInstance._currentView).toBe('year');
nextButton.click();
fixture.detectChanges();
@@ -135,19 +142,44 @@ describe('MatCalendar', () => {
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31));
});
it('should go back to month view after selecting month in year view', () => {
it('should go to previous and next multi-year range', () => {
periodButton.click();
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(false, 'should be in year view');
expect(calendarInstance._currentView).toBe('multi-year');
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31));
nextButton.click();
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 + yearsPerPage, JAN, 31));
prevButton.click();
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31));
});
it('should go back to month view after selecting year and month', () => {
periodButton.click();
fixture.detectChanges();
expect(calendarInstance._currentView).toBe('multi-year');
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 31));
let yearCells = calendarElement.querySelectorAll('.mat-calendar-body-cell');
(yearCells[0] as HTMLElement).click();
fixture.detectChanges();
expect(calendarInstance._currentView).toBe('year');
expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 31));
let monthCells = calendarElement.querySelectorAll('.mat-calendar-body-cell');
(monthCells[monthCells.length - 1] as HTMLElement).click();
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(true, 'should be in month view');
expect(calendarInstance._activeDate).toEqual(new Date(2017, DEC, 31));
expect(calendarInstance._currentView).toBe('month');
expect(calendarInstance._activeDate).toEqual(new Date(2016, DEC, 31));
expect(testComponent.selected).toBeFalsy('no date should be selected yet');
});
@@ -156,7 +188,7 @@ describe('MatCalendar', () => {
(monthCells[monthCells.length - 1] as HTMLElement).click();
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(true, 'should be in month view');
expect(calendarInstance._currentView).toBe('month');
expect(testComponent.selected).toEqual(new Date(2017, JAN, 31));
});
@@ -165,11 +197,11 @@ describe('MatCalendar', () => {
const button = fixture.debugElement.nativeElement
.querySelector('.mat-calendar-period-button');
intl.switchToYearViewLabel = 'Go to year view?';
intl.switchToMultiYearViewLabel = 'Go to multi-year view?';
intl.changes.next();
fixture.detectChanges();
expect(button.getAttribute('aria-label')).toBe('Go to year view?');
expect(button.getAttribute('aria-label')).toBe('Go to multi-year view?');
}));
describe('a11y', () => {
@@ -311,7 +343,12 @@ describe('MatCalendar', () => {
dispatchMouseEvent(periodButton, 'click');
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(false);
expect(calendarInstance._currentView).toBe('multi-year');
(calendarBodyEl.querySelector('.mat-calendar-body-active') as HTMLElement).click();
fixture.detectChanges();
expect(calendarInstance._currentView).toBe('year');
});
it('should decrement month on left arrow press', () => {
@@ -448,11 +485,130 @@ describe('MatCalendar', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER);
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(true);
expect(calendarInstance._currentView).toBe('month');
expect(calendarInstance._activeDate).toEqual(new Date(2017, FEB, 28));
expect(testComponent.selected).toBeUndefined();
});
});
describe('multi-year view', () => {
beforeEach(() => {
dispatchMouseEvent(periodButton, 'click');
fixture.detectChanges();
expect(calendarInstance._currentView).toBe('multi-year');
});
it('should decrement year on left arrow press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2015, JAN, 31));
});
it('should increment year on right arrow press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2019, JAN, 31));
});
it('should go up a row on up arrow press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 - yearsPerRow, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 - yearsPerRow * 2, JAN, 31));
});
it('should go down a row on down arrow press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 + yearsPerRow, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 + yearsPerRow * 2, JAN, 31));
});
it('should go to first year in current range on home press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2016, JAN, 31));
});
it('should go to last year in current range on end press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', END);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2039, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', END);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2039, JAN, 31));
});
it('should go to same index in previous year range page up press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 - yearsPerPage, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP);
fixture.detectChanges();
expect(calendarInstance._activeDate)
.toEqual(new Date(2017 - yearsPerPage * 2, JAN, 31));
});
it('should go to same index in next year range on page down press', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN);
fixture.detectChanges();
expect(calendarInstance._activeDate).toEqual(new Date(2017 + yearsPerPage, JAN, 31));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN);
fixture.detectChanges();
expect(calendarInstance._activeDate)
.toEqual(new Date(2017 + yearsPerPage * 2, JAN, 31));
});
it('should go to year view on enter', () => {
dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW);
fixture.detectChanges();
dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER);
fixture.detectChanges();
expect(calendarInstance._currentView).toBe('year');
expect(calendarInstance._activeDate).toEqual(new Date(2018, JAN, 31));
expect(testComponent.selected).toBeUndefined();
});
});
});
});
});
@@ -557,6 +713,9 @@ describe('MatCalendar', () => {
periodButton.click();
fixture.detectChanges();
(calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click();
fixture.detectChanges();
spyOn(calendarInstance.yearView, '_init').and.callThrough();
testComponent.minDate = new Date(2017, NOV, 1);
@@ -572,6 +731,9 @@ describe('MatCalendar', () => {
periodButton.click();
fixture.detectChanges();
(calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click();
fixture.detectChanges();
spyOn(calendarInstance.yearView, '_init').and.callThrough();
testComponent.maxDate = new Date(2017, DEC, 1);
@@ -580,6 +742,35 @@ describe('MatCalendar', () => {
expect(calendarInstance.yearView._init).toHaveBeenCalled();
});
it('should re-render the multi-year view when the minDate changes', () => {
fixture.detectChanges();
const periodButton =
calendarElement.querySelector('.mat-calendar-period-button') as HTMLElement;
periodButton.click();
fixture.detectChanges();
spyOn(calendarInstance.multiYearView, '_init').and.callThrough();
testComponent.minDate = new Date(2017, NOV, 1);
fixture.detectChanges();
expect(calendarInstance.multiYearView._init).toHaveBeenCalled();
});
it('should re-render the multi-year view when the maxDate changes', () => {
fixture.detectChanges();
const periodButton =
calendarElement.querySelector('.mat-calendar-period-button') as HTMLElement;
periodButton.click();
fixture.detectChanges();
spyOn(calendarInstance.multiYearView, '_init').and.callThrough();
testComponent.maxDate = new Date(2017, DEC, 1);
fixture.detectChanges();
expect(calendarInstance.multiYearView._init).toHaveBeenCalled();
});
});
describe('calendar with date filter', () => {
@@ -623,7 +814,7 @@ describe('MatCalendar', () => {
});
it('should not allow selection of disabled date in month view', () => {
expect(calendarInstance._monthView).toBe(true);
expect(calendarInstance._currentView).toBe('month');
expect(calendarInstance._activeDate).toEqual(new Date(2017, JAN, 1));
dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER);
@@ -638,15 +829,18 @@ describe('MatCalendar', () => {
dispatchMouseEvent(periodButton, 'click');
fixture.detectChanges();
(calendarElement.querySelector('.mat-calendar-body-active') as HTMLElement).click();
fixture.detectChanges();
calendarInstance._activeDate = new Date(2017, NOV, 1);
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(false);
expect(calendarInstance._currentView).toBe('year');
dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER);
fixture.detectChanges();
expect(calendarInstance._monthView).toBe(true);
expect(calendarInstance._currentView).toBe('month');
expect(testComponent.selected).toBeUndefined();
});
});
+111 -51
View File
@@ -27,13 +27,13 @@ import {
Inject,
Input,
NgZone,
OnChanges,
OnDestroy,
Optional,
Output,
ViewEncapsulation,
ViewChild,
OnChanges,
SimpleChanges,
ViewChild,
ViewEncapsulation,
} from '@angular/core';
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
import {take} from 'rxjs/operators/take';
@@ -41,6 +41,7 @@ import {Subscription} from 'rxjs/Subscription';
import {createMissingDateImplError} from './datepicker-errors';
import {MatDatepickerIntl} from './datepicker-intl';
import {MatMonthView} from './month-view';
import {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view';
import {MatYearView} from './year-view';
@@ -73,7 +74,7 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
private _startAt: D | null;
/** Whether the calendar should be started in month or year view. */
@Input() startView: 'month' | 'year' = 'month';
@Input() startView: 'month' | 'year' | 'multi-year' = 'month';
/** The currently selected date. */
@Input()
@@ -114,7 +115,10 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
/** Reference to the current year view component. */
@ViewChild(MatYearView) yearView: MatYearView<D>;
/** Date filter for the month and year views. */
/** Reference to the current multi-year view component. */
@ViewChild(MatMultiYearView) multiYearView: MatMultiYearView<D>;
/** Date filter for the month, year, and multi-year views. */
_dateFilterForViews = (date: D) => {
return !!date &&
(!this.dateFilter || this.dateFilter(date)) &&
@@ -133,28 +137,46 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
private _clampedActiveDate: D;
/** Whether the calendar is in month view. */
_monthView: boolean;
_currentView: 'month' | 'year' | 'multi-year';
/** The label for the current calendar view. */
get _periodButtonText(): string {
return this._monthView ?
this._dateAdapter.format(this._activeDate, this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase() :
this._dateAdapter.getYearName(this._activeDate);
if (this._currentView == 'month') {
return this._dateAdapter.format(this._activeDate, this._dateFormats.display.monthYearLabel)
.toLocaleUpperCase();
}
if (this._currentView == 'year') {
return this._dateAdapter.getYearName(this._activeDate);
}
const activeYear = this._dateAdapter.getYear(this._activeDate);
const firstYearInView = this._dateAdapter.getYearName(
this._dateAdapter.createDate(activeYear - activeYear % 24, 0, 1));
const lastYearInView = this._dateAdapter.getYearName(
this._dateAdapter.createDate(activeYear + yearsPerPage - 1 - activeYear % 24, 0, 1));
return `${firstYearInView} \u2013 ${lastYearInView}`;
}
get _periodButtonLabel(): string {
return this._monthView ? this._intl.switchToYearViewLabel : this._intl.switchToMonthViewLabel;
return this._currentView == 'month' ?
this._intl.switchToMultiYearViewLabel : this._intl.switchToMonthViewLabel;
}
/** The label for the the previous button. */
get _prevButtonLabel(): string {
return this._monthView ? this._intl.prevMonthLabel : this._intl.prevYearLabel;
return {
'month': this._intl.prevMonthLabel,
'year': this._intl.prevYearLabel,
'multi-year': this._intl.prevMultiYearLabel
}[this._currentView];
}
/** The label for the the next button. */
get _nextButtonLabel(): string {
return this._monthView ? this._intl.nextMonthLabel : this._intl.nextYearLabel;
return {
'month': this._intl.nextMonthLabel,
'year': this._intl.nextYearLabel,
'multi-year': this._intl.nextMultiYearLabel
}[this._currentView];
}
constructor(private _elementRef: ElementRef,
@@ -178,7 +200,7 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
ngAfterContentInit() {
this._activeDate = this.startAt || this._dateAdapter.today();
this._focusActiveCell();
this._monthView = this.startView != 'year';
this._currentView = this.startView;
}
ngOnDestroy() {
@@ -189,7 +211,7 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
const change = changes.minDate || changes.maxDate || changes.dateFilter;
if (change && !change.firstChange) {
const view = this.monthView || this.yearView;
const view = this.monthView || this.yearView || this.multiYearView;
if (view) {
view._init();
@@ -208,29 +230,31 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
this._userSelection.emit();
}
/** Handles month selection in the year view. */
_monthSelected(month: D): void {
this._activeDate = month;
this._monthView = true;
/** Handles month selection in the multi-year view. */
_goToDateInView(date: D, view: 'month' | 'year' | 'multi-year'): void {
this._activeDate = date;
this._currentView = view;
}
/** Handles user clicks on the period label. */
_currentPeriodClicked(): void {
this._monthView = !this._monthView;
this._currentView = this._currentView == 'month' ? 'multi-year' : 'month';
}
/** Handles user clicks on the previous button. */
_previousClicked(): void {
this._activeDate = this._monthView ?
this._activeDate = this._currentView == 'month' ?
this._dateAdapter.addCalendarMonths(this._activeDate, -1) :
this._dateAdapter.addCalendarYears(this._activeDate, -1);
this._dateAdapter.addCalendarYears(
this._activeDate, this._currentView == 'year' ? -1 : -yearsPerPage);
}
/** Handles user clicks on the next button. */
_nextClicked(): void {
this._activeDate = this._monthView ?
this._activeDate = this._currentView == 'month' ?
this._dateAdapter.addCalendarMonths(this._activeDate, 1) :
this._dateAdapter.addCalendarYears(this._activeDate, 1);
this._dateAdapter.addCalendarYears(
this._activeDate, this._currentView == 'year' ? 1 : yearsPerPage);
}
/** Whether the previous period button is enabled. */
@@ -251,10 +275,12 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
// disabled ones from being selected. This may not be ideal, we should look into whether
// navigation should skip over disabled dates, and if so, how to implement that efficiently.
if (this._monthView) {
if (this._currentView == 'month') {
this._handleCalendarBodyKeydownInMonthView(event);
} else {
} else if (this._currentView == 'year') {
this._handleCalendarBodyKeydownInYearView(event);
} else {
this._handleCalendarBodyKeydownInMultiYearView(event);
}
}
@@ -269,10 +295,16 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
/** Whether the two dates represent the same view in the current view mode (month or year). */
private _isSameView(date1: D, date2: D): boolean {
return this._monthView ?
this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) &&
this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2) :
this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2);
if (this._currentView == 'month') {
return this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2) &&
this._dateAdapter.getMonth(date1) == this._dateAdapter.getMonth(date2);
}
if (this._currentView == 'year') {
return this._dateAdapter.getYear(date1) == this._dateAdapter.getYear(date2);
}
// Otherwise we are in 'multi-year' view.
return Math.floor(this._dateAdapter.getYear(date1) / yearsPerPage) ==
Math.floor(this._dateAdapter.getYear(date2) / yearsPerPage);
}
/** Handles keydown events on the calendar body when calendar is in month view. */
@@ -337,10 +369,10 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, 1);
break;
case UP_ARROW:
this._activeDate = this._prevMonthInSameCol(this._activeDate);
this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, -4);
break;
case DOWN_ARROW:
this._activeDate = this._nextMonthInSameCol(this._activeDate);
this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, 4);
break;
case HOME:
this._activeDate = this._dateAdapter.addCalendarMonths(this._activeDate,
@@ -359,7 +391,53 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
this._dateAdapter.addCalendarYears(this._activeDate, event.altKey ? 10 : 1);
break;
case ENTER:
this._monthSelected(this._activeDate);
this._goToDateInView(this._activeDate, 'month');
break;
default:
// Don't prevent default or focus active cell on keys that we don't explicitly handle.
return;
}
this._focusActiveCell();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
/** Handles keydown events on the calendar body when calendar is in multi-year view. */
private _handleCalendarBodyKeydownInMultiYearView(event: KeyboardEvent): void {
switch (event.keyCode) {
case LEFT_ARROW:
this._activeDate = this._dateAdapter.addCalendarYears(this._activeDate, -1);
break;
case RIGHT_ARROW:
this._activeDate = this._dateAdapter.addCalendarYears(this._activeDate, 1);
break;
case UP_ARROW:
this._activeDate = this._dateAdapter.addCalendarYears(this._activeDate, -yearsPerRow);
break;
case DOWN_ARROW:
this._activeDate = this._dateAdapter.addCalendarYears(this._activeDate, yearsPerRow);
break;
case HOME:
this._activeDate = this._dateAdapter.addCalendarYears(this._activeDate,
-this._dateAdapter.getYear(this._activeDate) % yearsPerPage);
break;
case END:
this._activeDate = this._dateAdapter.addCalendarYears(this._activeDate,
yearsPerPage - this._dateAdapter.getYear(this._activeDate) % yearsPerPage - 1);
break;
case PAGE_UP:
this._activeDate =
this._dateAdapter.addCalendarYears(
this._activeDate, event.altKey ? -yearsPerPage * 10 : -yearsPerPage);
break;
case PAGE_DOWN:
this._activeDate =
this._dateAdapter.addCalendarYears(
this._activeDate, event.altKey ? yearsPerPage * 10 : yearsPerPage);
break;
case ENTER:
this._goToDateInView(this._activeDate, 'year');
break;
default:
// Don't prevent default or focus active cell on keys that we don't explicitly handle.
@@ -371,24 +449,6 @@ export class MatCalendar<D> implements AfterContentInit, OnDestroy, OnChanges {
event.preventDefault();
}
/**
* Determine the date for the month that comes before the given month in the same column in the
* calendar table.
*/
private _prevMonthInSameCol(date: D): D {
// Decrement by 4 since there are 4 months per row.
return this._dateAdapter.addCalendarMonths(date, -4);
}
/**
* Determine the date for the month that comes after the given month in the same column in the
* calendar table.
*/
private _nextMonthInSameCol(date: D): D {
// Increment by 4 since there are 4 months per row.
return this._dateAdapter.addCalendarMonths(date, 4);
}
/**
* @param obj The object to check.
* @returns The given object if it is both a date instance and valid, otherwise null.
+8 -2
View File
@@ -37,9 +37,15 @@ export class MatDatepickerIntl {
/** A label for the next year button (used by screen readers). */
nextYearLabel = 'Next year';
/** A label for the previous multi-year button (used by screen readers). */
prevMultiYearLabel = 'Previous 20 years';
/** A label for the next multi-year button (used by screen readers). */
nextMultiYearLabel = 'Next 20 years';
/** A label for the 'switch to month view' button (used by screen readers). */
switchToMonthViewLabel = 'Change to month view';
switchToMonthViewLabel = 'Choose date';
/** A label for the 'switch to year view' button (used by screen readers). */
switchToYearViewLabel = 'Change to year view';
switchToMultiYearViewLabel = 'Choose month and year';
}
+3
View File
@@ -24,6 +24,7 @@ import {MatDatepickerInput} from './datepicker-input';
import {MatDatepickerIntl} from './datepicker-intl';
import {MatDatepickerToggle} from './datepicker-toggle';
import {MatMonthView} from './month-view';
import {MatMultiYearView} from './multi-year-view';
import {MatYearView} from './year-view';
@@ -45,6 +46,7 @@ import {MatYearView} from './year-view';
MatDatepickerToggle,
MatMonthView,
MatYearView,
MatMultiYearView,
],
declarations: [
MatCalendar,
@@ -55,6 +57,7 @@ import {MatYearView} from './year-view';
MatDatepickerToggle,
MatMonthView,
MatYearView,
MatMultiYearView,
],
providers: [
MatDatepickerIntl,
+55 -39
View File
@@ -34,15 +34,15 @@ can easily be used as a prefix or suffix on the material input:
### Setting the calendar starting view
By default the calendar will open in month view, this can be changed by setting the `startView`
property of `<mat-datepicker>` to `year`. In year view the user will see all months of the year and
then proceed to month view after choosing a month.
The `startView` property of `<mat-datepicker>` can be used to set the view that will show up when
the calendar first opens. It can be set to `month`, `year`, or `multi-year`; by default it will open
to month view.
The month or year that the calendar opens to is determined by first checking if any date is
currently selected, if so it will open to the month or year containing that date. Otherwise it will
open to the month or year containing today's date. This behavior can be overridden by using the
`startAt` property of `<mat-datepicker>`. In this case the calendar will open to the month or year
containing the `startAt` date.
The month, year, or range of years that the calendar opens to is determined by first checking if any
date is currently selected, if so it will open to the month or year containing that date. Otherwise
it will open to the month or year containing today's date. This behavior can be overridden by using
the `startAt` property of `<mat-datepicker>`. In this case the calendar will open to the month or
year containing the `startAt` date.
<!-- example(datepicker-start-view) -->
@@ -275,46 +275,62 @@ should have a placeholder or be given a meaningful label via `aria-label`, `aria
#### Keyboard shortcuts
The keyboard shortcuts to handle datepicker are:
The datepicker supports the following keyboard shortcuts:
| Shortcut | Action |
|----------------------|-------------------------------------|
| `ALT` + `DOWN_ARROW` | Open the calendar pop-up |
| `ESCAPE` | Close the calendar pop-up |
| Shortcut | Action |
|----------------------|-------------------------------------------|
| `ALT` + `DOWN_ARROW` | Open the calendar pop-up |
| `ESCAPE` | Close the calendar pop-up |
In month view:
| Shortcut | Action |
|----------------------|-------------------------------------|
| `LEFT_ARROW` | Go to previous day |
| `RIGHT_ARROW` | Go to next day |
| `UP_ARROW` | Go to same day in the previous week |
| `DOWN_ARROW` | Go to same day in the next week |
| `HOME` | Go to the first day of the month |
| `END` | Go to the last day of the month |
| `PAGE_UP` | Go to previous month |
| `ALT` + `PAGE_UP` | Go to previous year |
| `PAGE_DOWN` | Go to next month |
| `ALT` + `PAGE_DOWN` | Go to next year |
| `ENTER` | Select current date |
| Shortcut | Action |
|----------------------|-------------------------------------------|
| `LEFT_ARROW` | Go to previous day |
| `RIGHT_ARROW` | Go to next day |
| `UP_ARROW` | Go to same day in the previous week |
| `DOWN_ARROW` | Go to same day in the next week |
| `HOME` | Go to the first day of the month |
| `END` | Go to the last day of the month |
| `PAGE_UP` | Go to the same day in the previous month |
| `ALT` + `PAGE_UP` | Go to the same day in the previous year |
| `PAGE_DOWN` | Go to the same day in the next month |
| `ALT` + `PAGE_DOWN` | Go to the same day in the next year |
| `ENTER` | Select current date |
In year view:
| Shortcut | Action |
|----------------------|-------------------------------------|
| `LEFT_ARROW` | Go to previous month |
| `RIGHT_ARROW` | Go to next month |
| `UP_ARROW` | Go to previous 6 months |
| `DOWN_ARROW` | Go to next 6 months |
| `HOME` | Go to the first month of the year |
| `END` | Go to the last month of the year |
| `PAGE_UP` | Go to previous year |
| `ALT` + `PAGE_UP` | Go to previous 10 years |
| `PAGE_DOWN` | Go to next year |
| `ALT` + `PAGE_DOWN` | Go to next 10 years |
| `ENTER` | Select current month |
| Shortcut | Action |
|----------------------|-------------------------------------------|
| `LEFT_ARROW` | Go to previous month |
| `RIGHT_ARROW` | Go to next month |
| `UP_ARROW` | Go up a row (back 4 months) |
| `DOWN_ARROW` | Go down a row (forward 4 months) |
| `HOME` | Go to the first month of the year |
| `END` | Go to the last month of the year |
| `PAGE_UP` | Go to the same month in the previous year |
| `ALT` + `PAGE_UP` | Go to the same month 10 years back |
| `PAGE_DOWN` | Go to the same month in the next year |
| `ALT` + `PAGE_DOWN` | Go to the same month 10 years forward |
| `ENTER` | Select current month |
In multi-year view:
| Shortcut | Action |
|----------------------|-------------------------------------------|
| `LEFT_ARROW` | Go to previous year |
| `RIGHT_ARROW` | Go to next year |
| `UP_ARROW` | Go up a row (back 4 years) |
| `DOWN_ARROW` | Go down a row (forward 4 years) |
| `HOME` | Go to the first year in the current range |
| `END` | Go to the last year in the current range |
| `PAGE_UP` | Go back 24 years |
| `ALT` + `PAGE_UP` | Go back 240 years |
| `PAGE_DOWN` | Go forward 24 years |
| `ALT` + `PAGE_DOWN` | Go forward 240 years |
| `ENTER` | Select current year |
### Troubleshooting
-1
View File
@@ -4,7 +4,6 @@
<tr><th class="mat-calendar-table-header-divider" colspan="7" aria-hidden="true"></th></tr>
</thead>
<tbody mat-calendar-body
role="grid"
[label]="_monthLabel"
[rows]="_weeks"
[todayValue]="_todayDate"
+15
View File
@@ -0,0 +1,15 @@
<table class="mat-calendar-table">
<thead class="mat-calendar-table-header">
<tr><th class="mat-calendar-table-header-divider" colspan="4"></th></tr>
</thead>
<tbody mat-calendar-body
allowDisabledSelection="true"
[rows]="_years"
[todayValue]="_todayYear"
[selectedValue]="_selectedYear"
[numCols]="4"
[cellAspectRatio]="4 / 7"
[activeCell]="_getActiveCell()"
(selectedValueChange)="_yearSelected($event)">
</tbody>
</table>
@@ -0,0 +1,86 @@
import {Component, ViewChild} from '@angular/core';
import {ComponentFixture, TestBed} from '@angular/core/testing';
import {JAN, MatNativeDateModule} from '@angular/material/core';
import {By} from '@angular/platform-browser';
import {MatCalendarBody} from './calendar-body';
import {MatMultiYearView, yearsPerPage} from './multi-year-view';
import {MatYearView} from './year-view';
describe('MatMultiYearView', () => {
beforeEach(() => {
TestBed.configureTestingModule({
imports: [
MatNativeDateModule,
],
declarations: [
MatCalendarBody,
MatMultiYearView,
// Test components.
StandardMultiYearView,
],
});
TestBed.compileComponents();
});
describe('standard multi-year view', () => {
let fixture: ComponentFixture<StandardMultiYearView>;
let testComponent: StandardMultiYearView;
let multiYearViewNativeElement: Element;
beforeEach(() => {
fixture = TestBed.createComponent(StandardMultiYearView);
fixture.detectChanges();
let multiYearViewDebugElement = fixture.debugElement.query(By.directive(MatMultiYearView));
multiYearViewNativeElement = multiYearViewDebugElement.nativeElement;
testComponent = fixture.componentInstance;
});
it('has correct number of years', () => {
let cellEls = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell')!;
expect(cellEls.length).toBe(yearsPerPage);
});
it('shows selected year if in same range', () => {
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
expect(selectedEl.innerHTML.trim()).toBe('2020');
});
it('does not show selected year if in different range', () => {
testComponent.selected = new Date(2040, JAN, 10);
fixture.detectChanges();
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected');
expect(selectedEl).toBeNull();
});
it('fires selected change event on cell clicked', () => {
let cellEls = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
(cellEls[cellEls.length - 1] as HTMLElement).click();
fixture.detectChanges();
let selectedEl = multiYearViewNativeElement.querySelector('.mat-calendar-body-selected')!;
expect(selectedEl.innerHTML.trim()).toBe('2039');
});
it('should mark active date', () => {
let cellEls = multiYearViewNativeElement.querySelectorAll('.mat-calendar-body-cell');
expect((cellEls[1] as HTMLElement).innerText.trim()).toBe('2017');
expect(cellEls[1].classList).toContain('mat-calendar-body-active');
});
});
});
@Component({
template: `
<mat-multi-year-view [activeDate]="date" [(selected)]="selected"></mat-multi-year-view>`,
})
class StandardMultiYearView {
date = new Date(2017, JAN, 1);
selected = new Date(2020, JAN, 1);
@ViewChild(MatYearView) yearView: MatYearView<Date>;
}
+137
View File
@@ -0,0 +1,137 @@
/**
* @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 {
AfterContentInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
Optional,
Output,
ViewEncapsulation
} from '@angular/core';
import {DateAdapter} from '@angular/material/core';
import {MatCalendarCell} from './calendar-body';
import {createMissingDateImplError} from './datepicker-errors';
export const yearsPerPage = 24;
export const yearsPerRow = 4;
/**
* An internal component used to display a year selector in the datepicker.
* @docs-private
*/
@Component({
moduleId: module.id,
selector: 'mat-multi-year-view',
templateUrl: 'multi-year-view.html',
exportAs: 'matMultiYearView',
encapsulation: ViewEncapsulation.None,
preserveWhitespaces: false,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MatMultiYearView<D> implements AfterContentInit {
/** The date to display in this multi-year view (everything other than the year is ignored). */
@Input()
get activeDate(): D { return this._activeDate; }
set activeDate(value: D) {
let oldActiveDate = this._activeDate;
this._activeDate =
this._getValidDateOrNull(this._dateAdapter.deserialize(value)) || this._dateAdapter.today();
if (Math.floor(this._dateAdapter.getYear(oldActiveDate) / yearsPerPage) !=
Math.floor(this._dateAdapter.getYear(this._activeDate) / yearsPerPage)) {
this._init();
}
}
private _activeDate: D;
/** The currently selected date. */
@Input()
get selected(): D | null { return this._selected; }
set selected(value: D | null) {
this._selected = this._getValidDateOrNull(this._dateAdapter.deserialize(value));
this._selectedYear = this._selected && this._dateAdapter.getYear(this._selected);
}
private _selected: D | null;
/** A function used to filter which dates are selectable. */
@Input() dateFilter: (date: D) => boolean;
/** Emits when a new month is selected. */
@Output() selectedChange = new EventEmitter<D>();
/** Grid of calendar cells representing the currently displayed years. */
_years: MatCalendarCell[][];
/** The year that today falls on. */
_todayYear: number;
/** The year of the selected date. Null if the selected date is null. */
_selectedYear: number | null;
constructor(@Optional() public _dateAdapter: DateAdapter<D>,
private _changeDetectorRef: ChangeDetectorRef) {
if (!this._dateAdapter) {
throw createMissingDateImplError('DateAdapter');
}
this._activeDate = this._dateAdapter.today();
}
ngAfterContentInit() {
this._init();
}
/** Initializes this multi-year view. */
_init() {
this._todayYear = this._dateAdapter.getYear(this._dateAdapter.today());
let activeYear = this._dateAdapter.getYear(this._activeDate);
let activeOffset = activeYear % yearsPerPage;
this._years = [];
for (let i = 0, row: number[] = []; i < yearsPerPage; i++) {
row.push(activeYear - activeOffset + i);
if (row.length == yearsPerRow) {
this._years.push(row.map(year => this._createCellForYear(year)));
row = [];
}
}
this._changeDetectorRef.markForCheck();
}
/** Handles when a new year is selected. */
_yearSelected(year: number) {
let month = this._dateAdapter.getMonth(this.activeDate);
let daysInMonth =
this._dateAdapter.getNumDaysInMonth(this._dateAdapter.createDate(year, month, 1));
this.selectedChange.emit(this._dateAdapter.createDate(year, month,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth)));
}
_getActiveCell(): number {
return this._dateAdapter.getYear(this.activeDate) % yearsPerPage;
}
/** Creates an MatCalendarCell for the given year. */
private _createCellForYear(year: number) {
let yearName = this._dateAdapter.getYearName(this._dateAdapter.createDate(year, 0, 1));
return new MatCalendarCell(year, yearName, yearName, true);
}
/**
* @param obj The object to check.
* @returns The given object if it is both a date instance and valid, otherwise null.
*/
private _getValidDateOrNull(obj: any): D | null {
return (this._dateAdapter.isDateInstance(obj) && this._dateAdapter.isValid(obj)) ? obj : null;
}
}
-1
View File
@@ -3,7 +3,6 @@
<tr><th class="mat-calendar-table-header-divider" colspan="4"></th></tr>
</thead>
<tbody mat-calendar-body
role="grid"
allowDisabledSelection="true"
[label]="_yearLabel"
[rows]="_months"
+1 -1
View File
@@ -106,7 +106,7 @@ export class MatYearView<D> implements AfterContentInit {
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth)));
}
/** Initializes this month view. */
/** Initializes this year view. */
_init() {
this._selectedMonth = this._getMonthInCurrentYear(this.selected);
this._todayMonth = this._getMonthInCurrentYear(this._dateAdapter.today());