diff --git a/src/lib/datepicker/calendar-body.ts b/src/lib/datepicker/calendar-body.ts index bac222427799..18b7e3c971d8 100644 --- a/src/lib/datepicker/calendar-body.ts +++ b/src/lib/datepicker/calendar-body.ts @@ -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, diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index 3b16ffa92658..924d07c4f80f 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -3,7 +3,7 @@
@@ -21,9 +21,9 @@
+ [ngSwitch]="_currentView" cdkMonitorSubtreeFocus> + (selectedChange)="_goToDateInView($event, 'month')"> + + +
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());