From bda87c066d7fd31dfed4b721b5f88358f7d86db8 Mon Sep 17 00:00:00 2001 From: mmalerba Date: Fri, 24 Mar 2017 15:57:52 -0700 Subject: [PATCH] feat(datepicker): add keyboard support to calendar (#3655) * add active cell support to calendar-table, month-view, and year-view * stop normalizing the active date in calendar since we now care about the exact active date * add some key handlers * add keyboard support, break some tests * add tests * some finishing touches * fix tabindex * fix rxjs import * addressed some comments * refactor handleKeydown * fix commas --- src/lib/datepicker/_datepicker-theme.scss | 3 +- src/lib/datepicker/calendar-table.html | 7 +- src/lib/datepicker/calendar-table.spec.ts | 6 + src/lib/datepicker/calendar-table.ts | 18 +- src/lib/datepicker/calendar.html | 7 +- src/lib/datepicker/calendar.spec.ts | 381 ++++++++++++++++++++-- src/lib/datepicker/calendar.ts | 217 ++++++++++-- src/lib/datepicker/datepicker-input.ts | 2 +- src/lib/datepicker/index.ts | 2 + src/lib/datepicker/month-view.html | 1 + src/lib/datepicker/month-view.spec.ts | 12 +- src/lib/datepicker/month-view.ts | 32 +- src/lib/datepicker/year-view.html | 1 + src/lib/datepicker/year-view.spec.ts | 9 +- src/lib/datepicker/year-view.ts | 21 +- 15 files changed, 635 insertions(+), 84 deletions(-) diff --git a/src/lib/datepicker/_datepicker-theme.scss b/src/lib/datepicker/_datepicker-theme.scss index d3904a327186..bd3d1a56726e 100644 --- a/src/lib/datepicker/_datepicker-theme.scss +++ b/src/lib/datepicker/_datepicker-theme.scss @@ -45,7 +45,8 @@ } } - :not(.mat-calendar-table-disabled):hover { + :not(.mat-calendar-table-disabled):hover, + .cdk-keyboard-focused .mat-calendar-table-active { & > .mat-calendar-table-cell-content:not(.mat-calendar-table-selected) { background-color: mat-color($background, hover); } diff --git a/src/lib/datepicker/calendar-table.html b/src/lib/datepicker/calendar-table.html index 37b29b265d79..af00052cfc34 100644 --- a/src/lib/datepicker/calendar-table.html +++ b/src/lib/datepicker/calendar-table.html @@ -5,15 +5,16 @@ - - + {{_firstRowOffset >= labelMinRequiredCells ? label : ''}} -
{ expect(todayElement.classList) .toContain('mat-calendar-table-selected', 'today should be selected'); }); + + it('should mark active date', () => { + expect((cellEls[10] as HTMLElement).innerText.trim()).toBe('11'); + expect(cellEls[10].classList).toContain('mat-calendar-table-active'); + }); }); describe('calendar table with disabled cells', () => { @@ -129,6 +134,7 @@ describe('MdCalendarTable', () => { [selectedValue]="selectedValue" [labelMinRequiredCells]="labelMinRequiredCells" [numCols]="numCols" + [activeCell]="10" (selectedValueChange)="onSelect($event)"> `, }) diff --git a/src/lib/datepicker/calendar-table.ts b/src/lib/datepicker/calendar-table.ts index aabe9c76fe70..a9195f71cd8d 100644 --- a/src/lib/datepicker/calendar-table.ts +++ b/src/lib/datepicker/calendar-table.ts @@ -51,10 +51,13 @@ export class MdCalendarTable { /** Whether to allow selection of disabled cells. */ @Input() allowDisabledSelection = false; + /** The cell number of the active cell in the table. */ + @Input() activeCell = 0; + /** Emits when a new value is selected. */ @Output() selectedValueChange = new EventEmitter(); - _cellClicked(cell: MdCalendarCell) { + _cellClicked(cell: MdCalendarCell): void { if (!this.allowDisabledSelection && !cell.enabled) { return; } @@ -62,8 +65,19 @@ export class MdCalendarTable { } /** The number of blank cells to put at the beginning for the first row. */ - get _firstRowOffset() { + get _firstRowOffset(): number { return this.rows && this.rows.length && this.rows[0].length ? this.numCols - this.rows[0].length : 0; } + + _isActiveCell(rowIndex: number, colIndex: number): boolean { + let cellNumber = rowIndex * this.numCols + colIndex; + + // Account for the fact that the first row may not have as many cells. + if (rowIndex) { + cellNumber -= this._firstRowOffset; + } + + return cellNumber == this.activeCell; + } } diff --git a/src/lib/datepicker/calendar.html b/src/lib/datepicker/calendar.html index e570acda369f..1c84c68480c7 100644 --- a/src/lib/datepicker/calendar.html +++ b/src/lib/datepicker/calendar.html @@ -29,10 +29,11 @@
-
+
@@ -40,7 +41,7 @@ diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 7c40c71ce0a7..29f484bbdb9a 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -7,6 +7,21 @@ import {MdMonthView} from './month-view'; import {MdYearView} from './year-view'; import {MdCalendarTable} from './calendar-table'; import {DatetimeModule} from '../core/datetime/index'; +import { + dispatchFakeEvent, dispatchKeyboardEvent, + dispatchMouseEvent +} from '../core/testing/dispatch-events'; +import { + DOWN_ARROW, + END, + ENTER, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW +} from '../core/keyboard/keycodes'; describe('MdCalendar', () => { @@ -54,9 +69,9 @@ describe('MdCalendar', () => { testComponent = fixture.componentInstance; }); - it('should be in month view with specified month visible', () => { + it('should be in month view with specified month active', () => { expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); }); it('should toggle view when period clicked', () => { @@ -74,17 +89,17 @@ describe('MdCalendar', () => { }); it('should go to next and previous month', () => { - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 1, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 28)); }); it('should go to previous and next year', () => { @@ -92,17 +107,17 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false, 'should be in year view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 31)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); }); it('should go back to month view after selecting month in year view', () => { @@ -110,14 +125,14 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(calendarInstance._monthView).toBe(false, 'should be in year view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); let monthCells = calendarElement.querySelectorAll('.mat-calendar-table-cell'); (monthCells[monthCells.length - 1] as HTMLElement).click(); fixture.detectChanges(); expect(calendarInstance._monthView).toBe(true, 'should be in month view'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 11, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); expect(testComponent.selected).toBeFalsy('no date should be selected yet'); }); @@ -129,6 +144,290 @@ describe('MdCalendar', () => { expect(calendarInstance._monthView).toBe(true, 'should be in month view'); expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 31)); }); + + describe('a11y', () => { + describe('calendar body', () => { + let calendarBodyEl: HTMLElement; + + beforeEach(() => { + calendarBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; + expect(calendarBodyEl).not.toBeNull(); + + dispatchFakeEvent(calendarBodyEl, 'focus'); + fixture.detectChanges(); + }); + + it('should initially set start date active', () => { + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + }); + + describe('month view', () => { + it('should decrement date on left arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + + calendarInstance._activeDate = new SimpleDate(2017, 0, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + }); + + it('should increment date on right arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 2)); + }); + + it('should go up a row on up arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 24)); + + calendarInstance._activeDate = new SimpleDate(2017, 0, 7); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + }); + + it('should go down a row on down arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 7)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 14)); + }); + + it('should go to beginning of the month on home press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + }); + + it('should go to end of the month on end press', () => { + calendarInstance._activeDate = new SimpleDate(2017, 0, 10); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 31)); + }); + + it('should go back one month on page up press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 10, 30)); + }); + + it('should go forward one month on page down press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 2, 28)); + }); + + it('should select active date on enter', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(testComponent.selected).toBeNull(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 30)); + }); + }); + + describe('year view', () => { + beforeEach(() => { + dispatchMouseEvent(periodButton, 'click'); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false); + }); + + it('should decrement month on left arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 11, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 10, 30)); + }); + + it('should increment month on right arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 2, 28)); + }); + + it('should go up a row on up arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 7, 31)); + + calendarInstance._activeDate = new SimpleDate(2017, 6, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 6, 1)); + + calendarInstance._activeDate = new SimpleDate(2017, 11, 10); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', UP_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 4, 10)); + }); + + it('should go down a row on down arrow press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 7, 31)); + + calendarInstance._activeDate = new SimpleDate(2017, 5, 1); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 5, 1)); + + calendarInstance._activeDate = new SimpleDate(2017, 8, 30); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', DOWN_ARROW); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 1, 28)); + }); + + it('should go to first month of the year on home press', () => { + calendarInstance._activeDate = new SimpleDate(2017, 8, 30); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 30)); + }); + + it('should go to last month of the year on end press', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', END); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 31)); + }); + + it('should go back one year on page up press', () => { + calendarInstance._activeDate = new SimpleDate(2016, 1, 29); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2015, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_UP); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2014, 1, 28)); + }); + + it('should go forward one year on page down press', () => { + calendarInstance._activeDate = new SimpleDate(2016, 1, 29); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', PAGE_DOWN); + fixture.detectChanges(); + + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 1, 28)); + }); + + it('should return to month view on enter', () => { + dispatchKeyboardEvent(calendarBodyEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 1, 28)); + expect(testComponent.selected).toBeNull(); + }); + }); + }); + }); }); describe('calendar with min and max date', () => { @@ -154,14 +453,14 @@ describe('MdCalendar', () => { testComponent.startAt = new SimpleDate(2000, 0, 1); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); }); it('should clamp startAt value above max date', () => { testComponent.startAt = new SimpleDate(2020, 0, 1); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); }); it('should not go back past min date', () => { @@ -169,18 +468,18 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(prevButton.classList).not.toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 1, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 1, 1)); prevButton.click(); fixture.detectChanges(); expect(prevButton.classList).toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); prevButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2016, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2016, 0, 1)); }); it('should not go forward past max date', () => { @@ -188,18 +487,18 @@ describe('MdCalendar', () => { fixture.detectChanges(); expect(nextButton.classList).not.toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2017, 11, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 11, 1)); nextButton.click(); fixture.detectChanges(); expect(nextButton.classList).toContain('mat-calendar-disabled'); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); nextButton.click(); fixture.detectChanges(); - expect(calendarInstance._currentPeriod).toEqual(new SimpleDate(2018, 0, 1)); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2018, 0, 1)); }); }); @@ -231,6 +530,46 @@ describe('MdCalendar', () => { expect(testComponent.selected).toEqual(new SimpleDate(2017, 0, 2)); }); + + describe('a11y', () => { + let calendarBodyEl: HTMLElement; + + beforeEach(() => { + calendarBodyEl = calendarElement.querySelector('.mat-calendar-body') as HTMLElement; + expect(calendarBodyEl).not.toBeNull(); + + dispatchFakeEvent(calendarBodyEl, 'focus'); + fixture.detectChanges(); + }); + + it('should not allow selection of disabled date in month view', () => { + expect(calendarInstance._monthView).toBe(true); + expect(calendarInstance._activeDate).toEqual(new SimpleDate(2017, 0, 1)); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(testComponent.selected).toBeNull(); + }); + + it('should allow entering month view at disabled month', () => { + let periodButton = + calendarElement.querySelector('.mat-calendar-period-button') as HTMLElement; + dispatchMouseEvent(periodButton, 'click'); + fixture.detectChanges(); + + calendarInstance._activeDate = new SimpleDate(2017, 10, 1); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(false); + + dispatchKeyboardEvent(calendarBodyEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(calendarInstance._monthView).toBe(true); + expect(testComponent.selected).toBeNull(); + }); + }); }); }); @@ -239,7 +578,7 @@ describe('MdCalendar', () => { template: `` }) class StandardCalendar { - selected: SimpleDate; + selected: SimpleDate = null; } @@ -257,9 +596,9 @@ class CalendarWithMinMax { ` }) class CalendarWithDateFilter { - selected: SimpleDate; + selected: SimpleDate = null; dateFilter (date: SimpleDate) { - return date.date % 2 == 0; + return date.date % 2 == 0 && date.month != 10; } } diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index ed2d587a84a2..048d7c227c55 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -1,12 +1,24 @@ import { + AfterContentInit, ChangeDetectionStrategy, - ViewEncapsulation, Component, + EventEmitter, Input, - AfterContentInit, Output, EventEmitter + Output, + ViewEncapsulation } from '@angular/core'; import {SimpleDate} from '../core/datetime/simple-date'; import {CalendarLocale} from '../core/datetime/calendar-locale'; +import { + DOWN_ARROW, + END, ENTER, + HOME, + LEFT_ARROW, + PAGE_DOWN, + PAGE_UP, + RIGHT_ARROW, + UP_ARROW +} from '../core/keyboard/keycodes'; /** @@ -67,16 +79,14 @@ export class MdCalendar implements AfterContentInit { } /** - * A date representing the current period shown in the calendar. The current period is always - * normalized to the 1st of a month, this prevents date overflow issues (e.g. adding a month to - * January 31st and overflowing into March). + * The current active date. This determines which time period is shown and which date is + * highlighted when using keyboard navigation. */ - get _currentPeriod() { return this._normalizedCurrentPeriod; } - set _currentPeriod(value: SimpleDate) { - const clampedValue = value.clamp(this.minDate, this.maxDate); - this._normalizedCurrentPeriod = new SimpleDate(clampedValue.year, clampedValue.month, 1); + get _activeDate() { return this._clampedActiveDate; } + set _activeDate(value: SimpleDate) { + this._clampedActiveDate = value.clamp(this.minDate, this.maxDate); } - private _normalizedCurrentPeriod: SimpleDate; + private _clampedActiveDate: SimpleDate; /** Whether the calendar is in month view. */ _monthView: boolean; @@ -87,8 +97,8 @@ export class MdCalendar implements AfterContentInit { /** The label for the current calendar view. */ get _label(): string { return this._monthView ? - this._locale.getCalendarMonthHeaderLabel(this._currentPeriod).toLocaleUpperCase() : - this._locale.getCalendarYearHeaderLabel(this._currentPeriod); + this._locale.getCalendarMonthHeaderLabel(this._activeDate).toLocaleUpperCase() : + this._locale.getCalendarYearHeaderLabel(this._activeDate); } constructor(private _locale: CalendarLocale) { @@ -97,57 +107,208 @@ export class MdCalendar implements AfterContentInit { } ngAfterContentInit() { - this._currentPeriod = this.startAt || SimpleDate.today(); + this._activeDate = this.startAt || SimpleDate.today(); this._monthView = this.startView != 'year'; } /** Handles date selection in the month view. */ - _dateSelected(date: SimpleDate) { + _dateSelected(date: SimpleDate): void { if ((!date || !this.selected) && date != this.selected || date.compare(this.selected)) { this.selectedChange.emit(date); } } /** Handles month selection in the year view. */ - _monthSelected(month: SimpleDate) { - this._currentPeriod = month; + _monthSelected(month: SimpleDate): void { + this._activeDate = month; this._monthView = true; } /** Handles user clicks on the period label. */ - _currentPeriodClicked() { + _currentPeriodClicked(): void { this._monthView = !this._monthView; } /** Handles user clicks on the previous button. */ - _previousClicked() { - let amount = this._monthView ? {months: -1} : {years: -1}; - this._currentPeriod = this._currentPeriod.add(amount); + _previousClicked(): void { + this._activeDate = this._monthView ? + this._addCalendarMonths(this._activeDate, -1) : + this._addCalendarYears(this._activeDate, -1); } /** Handles user clicks on the next button. */ - _nextClicked() { - let amount = this._monthView ? {months: 1} : {years: 1}; - this._currentPeriod = this._currentPeriod.add(amount); + _nextClicked(): void { + this._activeDate = this._monthView ? + this._addCalendarMonths(this._activeDate, 1) : this._addCalendarYears(this._activeDate, 1); } /** Whether the previous period button is enabled. */ - _previousEnabled() { + _previousEnabled(): boolean { if (!this.minDate) { return true; } - return !this.minDate || !this._isSameView(this._currentPeriod, this.minDate); + return !this.minDate || !this._isSameView(this._activeDate, this.minDate); } /** Whether the next period button is enabled. */ - _nextEnabled() { - return !this.maxDate || !this._isSameView(this._currentPeriod, this.maxDate); + _nextEnabled(): boolean { + return !this.maxDate || !this._isSameView(this._activeDate, this.maxDate); } /** Whether the two dates represent the same view in the current view mode (month or year). */ - private _isSameView(date1: SimpleDate, date2: SimpleDate) { + private _isSameView(date1: SimpleDate, date2: SimpleDate): boolean { return this._monthView ? date1.year == date2.year && date1.month == date2.month : date1.year == date2.year; } + + /** Handles keydown events on the calendar body. */ + _handleCalendarBodyKeydown(event: KeyboardEvent): void { + // 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) { + this._handleCalendarBodyKeydownInMonthView(event); + } else { + this._handleCalendarBodyKeydownInYearView(event); + } + } + + /** Handles keydown events on the calendar body when calendar is in month view. */ + private _handleCalendarBodyKeydownInMonthView(event: KeyboardEvent): void { + switch (event.keyCode) { + case LEFT_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, -1); + break; + case RIGHT_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, 1); + break; + case UP_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, -7); + break; + case DOWN_ARROW: + this._activeDate = this._addCalendarDays(this._activeDate, 7); + break; + case HOME: + this._activeDate = new SimpleDate(this._activeDate.year, this._activeDate.month, 1); + break; + case END: + this._activeDate = new SimpleDate(this._activeDate.year, this._activeDate.month + 1, 0); + break; + case PAGE_UP: + this._activeDate = event.altKey ? + this._addCalendarYears(this._activeDate, -1) : + this._addCalendarMonths(this._activeDate, -1); + break; + case PAGE_DOWN: + this._activeDate = event.altKey ? + this._addCalendarYears(this._activeDate, 1) : + this._addCalendarMonths(this._activeDate, 1); + break; + case ENTER: + if (this._dateFilterForViews(this._activeDate)) { + this._dateSelected(this._activeDate); + break; + } + return; + default: + // Don't prevent default on keys that we don't explicitly handle. + return; + } + + event.preventDefault(); + } + + /** Handles keydown events on the calendar body when calendar is in year view. */ + private _handleCalendarBodyKeydownInYearView(event: KeyboardEvent): void { + switch (event.keyCode) { + case LEFT_ARROW: + this._activeDate = this._addCalendarMonths(this._activeDate, -1); + break; + case RIGHT_ARROW: + this._activeDate = this._addCalendarMonths(this._activeDate, 1); + break; + case UP_ARROW: + this._activeDate = this._prevMonthInSameCol(this._activeDate); + break; + case DOWN_ARROW: + this._activeDate = this._nextMonthInSameCol(this._activeDate); + break; + case HOME: + this._activeDate = this._addCalendarMonths(this._activeDate, -this._activeDate.month); + break; + case END: + this._activeDate = this._addCalendarMonths(this._activeDate, 11 - this._activeDate.month); + break; + case PAGE_UP: + this._activeDate = this._addCalendarYears(this._activeDate, event.altKey ? -10 : -1); + break; + case PAGE_DOWN: + this._activeDate = this._addCalendarYears(this._activeDate, event.altKey ? 10 : 1); + break; + case ENTER: + this._monthSelected(this._activeDate); + break; + default: + // Don't prevent default on keys that we don't explicitly handle. + return; + } + + event.preventDefault(); + } + + /** Adds the given number of days to the date. */ + private _addCalendarDays(date: SimpleDate, days: number): SimpleDate { + return date.add({days}); + } + + /** + * Adds the given number of months to the date. Months are counted as if flipping pages on a + * calendar and then finding the closest date in the new month. For example when adding 1 month to + * Jan 31, 2017, the resulting date will be Feb 28, 2017. + */ + private _addCalendarMonths(date: SimpleDate, months: number): SimpleDate { + let newDate = date.add({months}); + + // It's possible to wind up in the wrong month if the original month has more days than the new + // month. In this case we want to go to the last day of the desired month. + // Note: the additional + 12 % 12 ensures we end up with a positive number, since JS % doesn't + // guarantee this. + if (newDate.month != ((date.month + months) % 12 + 12) % 12) { + newDate = new SimpleDate(newDate.year, newDate.month, 0); + } + + return newDate; + } + + /** + * Adds the given number of months to the date. Months are counted as if flipping 12 pages for + * each year on a calendar and then finding the closest date in the new month. For example when + * adding 1 year to Feb 29, 2016, the resulting date will be Feb 28, 2017. + */ + private _addCalendarYears(date: SimpleDate, years: number): SimpleDate { + return this._addCalendarMonths(date, years * 12); + } + + /** + * Determine the date for the month that comes before the given month in the same column in the + * calendar table. + */ + private _prevMonthInSameCol(date: SimpleDate) { + // Determine how many months to jump forward given that there are 2 empty slots at the beginning + // of each year. + let increment = date.month <= 4 ? -5 : (date.month >= 7 ? -7 : -12); + return this._addCalendarMonths(date, increment); + } + + /** + * Determine the date for the month that comes after the given month in the same column in the + * calendar table. + */ + private _nextMonthInSameCol(date: SimpleDate): SimpleDate { + // Determine how many months to jump forward given that there are 2 empty slots at the beginning + // of each year. + let increment = date.month <= 4 ? 7 : (date.month >= 7 ? 5 : 12); + return this._addCalendarMonths(date, increment); + } } diff --git a/src/lib/datepicker/datepicker-input.ts b/src/lib/datepicker/datepicker-input.ts index f24dddf04c3d..e4426b0567fd 100644 --- a/src/lib/datepicker/datepicker-input.ts +++ b/src/lib/datepicker/datepicker-input.ts @@ -12,7 +12,7 @@ import {MdDatepicker} from './datepicker'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms'; import {SimpleDate} from '../core/datetime/simple-date'; import {CalendarLocale} from '../core/datetime/calendar-locale'; -import {Subscription} from 'rxjs'; +import {Subscription} from 'rxjs/Subscription'; import {MdInputContainer} from '../input/input-container'; import {DOWN_ARROW} from '../core/keyboard/keycodes'; diff --git a/src/lib/datepicker/index.ts b/src/lib/datepicker/index.ts index f6afa6f473f2..7b924a7b5bdc 100644 --- a/src/lib/datepicker/index.ts +++ b/src/lib/datepicker/index.ts @@ -10,6 +10,7 @@ import {MdDatepickerInput} from './datepicker-input'; import {MdDialogModule} from '../dialog/index'; import {MdCalendar} from './calendar'; import {MdDatepickerToggle} from './datepicker-toggle'; +import {StyleModule} from '../core/style/index'; export * from './calendar'; @@ -26,6 +27,7 @@ export * from './year-view'; DatetimeModule, MdDialogModule, OverlayModule, + StyleModule, ], exports: [ MdDatepicker, diff --git a/src/lib/datepicker/month-view.html b/src/lib/datepicker/month-view.html index e0e0559cfc9c..af76b0a61a4e 100644 --- a/src/lib/datepicker/month-view.html +++ b/src/lib/datepicker/month-view.html @@ -3,5 +3,6 @@ [todayValue]="_todayDate" [selectedValue]="_selectedDate" [labelMinRequiredCells]="3" + [activeCell]="activeDate.date - 1" (selectedValueChange)="_dateSelected($event)"> diff --git a/src/lib/datepicker/month-view.spec.ts b/src/lib/datepicker/month-view.spec.ts index 5a3fee271e61..d7fdefc1e985 100644 --- a/src/lib/datepicker/month-view.spec.ts +++ b/src/lib/datepicker/month-view.spec.ts @@ -71,6 +71,12 @@ describe('MdMonthView', () => { let selectedEl = monthViewNativeElement.querySelector('.mat-calendar-table-selected'); expect(selectedEl.innerHTML.trim()).toBe('31'); }); + + it('should mark active date', () => { + let cellEls = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect((cellEls[4] as HTMLElement).innerText.trim()).toBe('5'); + expect(cellEls[4].classList).toContain('mat-calendar-table-active'); + }); }); describe('month view with date filter', () => { @@ -87,7 +93,7 @@ describe('MdMonthView', () => { testComponent = fixture.componentInstance; }); - it('should disabled filtered dates', () => { + it('should disable filtered dates', () => { let cells = monthViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); expect(cells[0].classList).toContain('mat-calendar-table-disabled'); expect(cells[1].classList).not.toContain('mat-calendar-table-disabled'); @@ -97,7 +103,7 @@ describe('MdMonthView', () => { @Component({ - template: ``, + template: ``, }) class StandardMonthView { date = new SimpleDate(2017, 0, 5); @@ -106,7 +112,7 @@ class StandardMonthView { @Component({ - template: `` + template: `` }) class MonthViewWithDateFilter { dateFilter(date: SimpleDate) { diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index ced1014d9e00..411405f9ef96 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -31,12 +31,15 @@ export class MdMonthView implements AfterContentInit { * The date to display in this month view (everything other than the month and year is ignored). */ @Input() - get date() { return this._date; } - set date(value) { - this._date = this._locale.parseDate(value) || SimpleDate.today(); - this._init(); + get activeDate() { return this._activeDate; } + set activeDate(value) { + let oldActiveDate = this._activeDate; + this._activeDate = this._locale.parseDate(value) || SimpleDate.today(); + if (!this._hasSameMonthAndYear(oldActiveDate, this._activeDate)) { + this._init(); + } } - private _date = SimpleDate.today(); + private _activeDate = SimpleDate.today(); /** The currently selected date. */ @Input() @@ -82,16 +85,16 @@ export class MdMonthView implements AfterContentInit { if (this._selectedDate == date) { return; } - this.selectedChange.emit(new SimpleDate(this.date.year, this.date.month, date)); + this.selectedChange.emit(new SimpleDate(this.activeDate.year, this.activeDate.month, date)); } /** Initializes this month view. */ private _init() { this._selectedDate = this._getDateInCurrentMonth(this.selected); this._todayDate = this._getDateInCurrentMonth(SimpleDate.today()); - this._monthLabel = this._locale.shortMonths[this.date.month].toLocaleUpperCase(); + this._monthLabel = this._locale.shortMonths[this.activeDate.month].toLocaleUpperCase(); - let firstOfMonth = new SimpleDate(this.date.year, this.date.month, 1); + let firstOfMonth = new SimpleDate(this.activeDate.year, this.activeDate.month, 1); this._firstWeekOffset = (DAYS_PER_WEEK + firstOfMonth.day - this._locale.firstDayOfWeek) % DAYS_PER_WEEK; @@ -100,7 +103,7 @@ export class MdMonthView implements AfterContentInit { /** Creates MdCalendarCells for the dates in this month. */ private _createWeekCells() { - let daysInMonth = new SimpleDate(this.date.year, this.date.month + 1, 0).date; + let daysInMonth = new SimpleDate(this.activeDate.year, this.activeDate.month + 1, 0).date; this._weeks = [[]]; for (let i = 0, cell = this._firstWeekOffset; i < daysInMonth; i++, cell++) { if (cell == DAYS_PER_WEEK) { @@ -108,7 +111,7 @@ export class MdMonthView implements AfterContentInit { cell = 0; } let enabled = !this.dateFilter || - this.dateFilter(new SimpleDate(this.date.year, this.date.month, i + 1)); + this.dateFilter(new SimpleDate(this.activeDate.year, this.activeDate.month, i + 1)); this._weeks[this._weeks.length - 1] .push(new MdCalendarCell(i + 1, this._locale.dates[i + 1], enabled)); } @@ -118,7 +121,12 @@ export class MdMonthView implements AfterContentInit { * Gets the date in this month that the given Date falls on. * Returns null if the given Date is in another month. */ - private _getDateInCurrentMonth(date: SimpleDate) { - return date && date.month == this.date.month && date.year == this.date.year ? date.date : null; + private _getDateInCurrentMonth(date: SimpleDate): number { + return this._hasSameMonthAndYear(date, this.activeDate) ? date.date : null; + } + + /** Checks whether the 2 dates are non-null and fall within the same month of the same year. */ + private _hasSameMonthAndYear(d1: SimpleDate, d2: SimpleDate): boolean { + return !!(d1 && d2 && d1.month == d2.month && d1.year == d2.year); } } diff --git a/src/lib/datepicker/year-view.html b/src/lib/datepicker/year-view.html index 2e70db4cf0a3..3ec753e219cc 100644 --- a/src/lib/datepicker/year-view.html +++ b/src/lib/datepicker/year-view.html @@ -4,5 +4,6 @@ [todayValue]="_todayMonth" [selectedValue]="_selectedMonth" [labelMinRequiredCells]="2" + [activeCell]="activeDate.month" (selectedValueChange)="_monthSelected($event)"> diff --git a/src/lib/datepicker/year-view.spec.ts b/src/lib/datepicker/year-view.spec.ts index a5cb6118df21..66674cba4c25 100644 --- a/src/lib/datepicker/year-view.spec.ts +++ b/src/lib/datepicker/year-view.spec.ts @@ -71,6 +71,12 @@ describe('MdYearView', () => { let selectedEl = yearViewNativeElement.querySelector('.mat-calendar-table-selected'); expect(selectedEl.innerHTML.trim()).toBe('DEC'); }); + + it('should mark active date', () => { + let cellEls = yearViewNativeElement.querySelectorAll('.mat-calendar-table-cell'); + expect((cellEls[0] as HTMLElement).innerText.trim()).toBe('JAN'); + expect(cellEls[0].classList).toContain('mat-calendar-table-active'); + }); }); describe('year view with date filter', () => { @@ -97,7 +103,8 @@ describe('MdYearView', () => { @Component({ - template: ``, + template: ` + `, }) class StandardYearView { date = new SimpleDate(2017, 0, 5); diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index 40f5d8f1bbd2..39ce2a1600ec 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -26,12 +26,15 @@ import {SimpleDate} from '../core/datetime/simple-date'; export class MdYearView implements AfterContentInit { /** The date to display in this year view (everything other than the year is ignored). */ @Input() - get date() { return this._date; } - set date(value) { - this._date = this._locale.parseDate(value) || SimpleDate.today(); - this._init(); + get activeDate() { return this._activeDate; } + set activeDate(value) { + let oldActiveDate = this._activeDate; + this._activeDate = this._locale.parseDate(value) || SimpleDate.today(); + if (oldActiveDate.year != this._activeDate.year) { + this._init(); + } } - private _date = SimpleDate.today(); + private _activeDate = SimpleDate.today(); /** The currently selected date. */ @Input() @@ -71,14 +74,14 @@ export class MdYearView implements AfterContentInit { /** Handles when a new month is selected. */ _monthSelected(month: number) { - this.selectedChange.emit(new SimpleDate(this.date.year, month, 1)); + this.selectedChange.emit(new SimpleDate(this.activeDate.year, month, this._activeDate.date)); } /** Initializes this month view. */ private _init() { this._selectedMonth = this._getMonthInCurrentYear(this.selected); this._todayMonth = this._getMonthInCurrentYear(SimpleDate.today()); - this._yearLabel = this._locale.getCalendarYearHeaderLabel(this._date); + this._yearLabel = this._locale.getCalendarYearHeaderLabel(this.activeDate); // First row of months only contains 5 elements so we can fit the year label on the same row. this._months = [[0, 1, 2, 3, 4], [5, 6, 7, 8, 9, 10, 11]].map(row => row.map( @@ -90,7 +93,7 @@ export class MdYearView implements AfterContentInit { * Returns null if the given Date is in another year. */ private _getMonthInCurrentYear(date: SimpleDate) { - return date && date.year == this.date.year ? date.month : null; + return date && date.year == this.activeDate.year ? date.month : null; } /** Creates an MdCalendarCell for the given month. */ @@ -106,7 +109,7 @@ export class MdYearView implements AfterContentInit { } // If any date in the month is enabled count the month as enabled. - for (let date = new SimpleDate(this.date.year, month, 1); date.month === month; + for (let date = new SimpleDate(this.activeDate.year, month, 1); date.month === month; date = date.add({days: 1})) { if (this.dateFilter(date)) { return true;