diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts
index c4fb499e5505..e65883d496d6 100644
--- a/src/lib/datepicker/calendar.spec.ts
+++ b/src/lib/datepicker/calendar.spec.ts
@@ -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._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._monthView).toBe(false, 'should be in year view');
+ 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();
});
});
diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts
index d7d76ddbf64e..755c48fa7b3f 100644
--- a/src/lib/datepicker/calendar.ts
+++ b/src/lib/datepicker/calendar.ts
@@ -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 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 implements AfterContentInit, OnDestroy, OnChanges {
/** Reference to the current year view component. */
@ViewChild(MatYearView) yearView: MatYearView;
- /** Date filter for the month and year views. */
+ /** Reference to the current multi-year view component. */
+ @ViewChild(MatMultiYearView) multiYearView: MatMultiYearView;
+
+ /** 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 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 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 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 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 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 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 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,7 @@ export class MatCalendar 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.
@@ -371,22 +403,50 @@ export class MatCalendar 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);
- }
+ /** 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.
+ return;
+ }
- /**
- * 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);
+ this._focusActiveCell();
+ // Prevent unexpected default actions such as form submission.
+ event.preventDefault();
}
/**
diff --git a/src/lib/datepicker/datepicker-intl.ts b/src/lib/datepicker/datepicker-intl.ts
index c21e476afc49..7a0b77c9bc6d 100644
--- a/src/lib/datepicker/datepicker-intl.ts
+++ b/src/lib/datepicker/datepicker-intl.ts
@@ -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';
}
diff --git a/src/lib/datepicker/datepicker-module.ts b/src/lib/datepicker/datepicker-module.ts
index 30effd62c312..cdee74a9691b 100644
--- a/src/lib/datepicker/datepicker-module.ts
+++ b/src/lib/datepicker/datepicker-module.ts
@@ -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,
diff --git a/src/lib/datepicker/datepicker.md b/src/lib/datepicker/datepicker.md
index ae93f0197c8e..95eddfc04eb3 100644
--- a/src/lib/datepicker/datepicker.md
+++ b/src/lib/datepicker/datepicker.md
@@ -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 `` 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 `` 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 ``. 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 ``. In this case the calendar will open to the month or
+year containing the `startAt` date.
@@ -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
diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html
index a371beaebdd7..3551bdef1e27 100644
--- a/src/lib/datepicker/month-view.html
+++ b/src/lib/datepicker/month-view.html
@@ -4,7 +4,6 @@
+
+
+
+
+
+
diff --git a/src/lib/datepicker/multi-year-view.spec.ts b/src/lib/datepicker/multi-year-view.spec.ts
new file mode 100644
index 000000000000..4f7f0897ac64
--- /dev/null
+++ b/src/lib/datepicker/multi-year-view.spec.ts
@@ -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;
+ 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: `
+ `,
+})
+class StandardMultiYearView {
+ date = new Date(2017, JAN, 1);
+ selected = new Date(2020, JAN, 1);
+
+ @ViewChild(MatYearView) yearView: MatYearView;
+}
diff --git a/src/lib/datepicker/multi-year-view.ts b/src/lib/datepicker/multi-year-view.ts
new file mode 100644
index 000000000000..93849dcb7c25
--- /dev/null
+++ b/src/lib/datepicker/multi-year-view.ts
@@ -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 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();
+
+ /** 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,
+ 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;
+ }
+}
diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html
index ca50a5b1cd64..ba92dc8f8550 100644
--- a/src/lib/datepicker/year-view.html
+++ b/src/lib/datepicker/year-view.html
@@ -3,7 +3,6 @@
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());