diff --git a/src/lib/datepicker/calendar.spec.ts b/src/lib/datepicker/calendar.spec.ts index 542ea1e97da7..a5f389c473fc 100644 --- a/src/lib/datepicker/calendar.spec.ts +++ b/src/lib/datepicker/calendar.spec.ts @@ -2,8 +2,13 @@ import { ENTER, RIGHT_ARROW, } from '@angular/cdk/keycodes'; -import {dispatchFakeEvent, dispatchKeyboardEvent, dispatchMouseEvent} from '@angular/cdk/testing'; -import {Component} from '@angular/core'; +import { + dispatchFakeEvent, + dispatchKeyboardEvent, + dispatchMouseEvent, + MockNgZone, +} from '@angular/cdk/testing'; +import {Component, NgZone} from '@angular/core'; import {ComponentFixture, TestBed, async, inject} from '@angular/core/testing'; import {DEC, FEB, JAN, MatNativeDateModule, NOV} from '@angular/material/core'; import {By} from '@angular/platform-browser'; @@ -14,6 +19,7 @@ import {MatDatepickerModule} from './datepicker-module'; describe('MatCalendar', () => { let dir: {value: Direction}; + let zone: MockNgZone; beforeEach(async(() => { TestBed.configureTestingModule({ @@ -29,6 +35,7 @@ describe('MatCalendar', () => { ], providers: [ MatDatepickerIntl, + {provide: NgZone, useFactory: () => zone = new MockNgZone()}, {provide: Directionality, useFactory: () => dir = {value: 'ltr'}} ], }); @@ -150,6 +157,34 @@ describe('MatCalendar', () => { expect(calendarBodyEl.getAttribute('tabindex')).toBe('-1'); }); + it('should not move focus to the active cell on init', () => { + const activeCell = + calendarBodyEl.querySelector('.mat-calendar-body-active')! as HTMLElement; + + spyOn(activeCell, 'focus').and.callThrough(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + expect(activeCell.focus).not.toHaveBeenCalled(); + }); + + it('should move focus to the active cell when the view changes', () => { + const activeCell = + calendarBodyEl.querySelector('.mat-calendar-body-active')! as HTMLElement; + + spyOn(activeCell, 'focus').and.callThrough(); + fixture.detectChanges(); + zone.simulateZoneExit(); + + expect(activeCell.focus).not.toHaveBeenCalled(); + + calendarInstance.currentView = 'multi-year'; + fixture.detectChanges(); + zone.simulateZoneExit(); + + expect(activeCell.focus).toHaveBeenCalled(); + }); + describe('year view', () => { beforeEach(() => { dispatchMouseEvent(periodButton, 'click'); diff --git a/src/lib/datepicker/calendar.ts b/src/lib/datepicker/calendar.ts index 4772e5d38960..a1215d3ec4d0 100644 --- a/src/lib/datepicker/calendar.ts +++ b/src/lib/datepicker/calendar.ts @@ -9,6 +9,7 @@ import {ComponentPortal, ComponentType, Portal} from '@angular/cdk/portal'; import { AfterContentInit, + AfterViewChecked, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -32,6 +33,12 @@ import {MatMonthView} from './month-view'; import {MatMultiYearView, yearsPerPage} from './multi-year-view'; import {MatYearView} from './year-view'; +/** + * Possible views for the calendar. + * @docs-private + */ +export type MatCalendarView = 'month' | 'year' | 'multi-year'; + /** Default header for MatCalendar */ @Component({ moduleId: module.id, @@ -162,7 +169,7 @@ export class MatCalendarHeader { encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, }) -export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { +export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDestroy, OnChanges { /** An input indicating the type of the header component, if set. */ @Input() headerComponent: ComponentType; @@ -171,6 +178,13 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { private _intlChanges: Subscription; + /** + * Used for scheduling that focus should be moved to the active cell on the next tick. + * We need to schedule it, rather than do it immediately, because we have to wait + * for Angular to re-evaluate the view children. + */ + private _moveFocusOnNextTick = false; + /** A date representing the period (month or year) to start the calendar in. */ @Input() get startAt(): D | null { return this._startAt; } @@ -180,7 +194,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' | 'multi-year' = 'month'; + @Input() startView: MatCalendarView = 'month'; /** The currently selected date. */ @Input() @@ -248,7 +262,12 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { private _clampedActiveDate: D; /** Whether the calendar is in month view. */ - currentView: 'month' | 'year' | 'multi-year'; + get currentView(): MatCalendarView { return this._currentView; } + set currentView(value: MatCalendarView) { + this._currentView = value; + this._moveFocusOnNextTick = true; + } + private _currentView: MatCalendarView; /** * Emits whenever there is a state change that the header may need to respond to. @@ -276,9 +295,17 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { ngAfterContentInit() { this._calendarHeaderPortal = new ComponentPortal(this.headerComponent || MatCalendarHeader); - this.activeDate = this.startAt || this._dateAdapter.today(); - this.currentView = this.startView; + + // Assign to the private property since we don't want to move focus on init. + this._currentView = this.startView; + } + + ngAfterViewChecked() { + if (this._moveFocusOnNextTick) { + this._moveFocusOnNextTick = false; + this.focusActiveCell(); + } } ngOnDestroy() { @@ -290,7 +317,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 || this.multiYearView; + const view = this._getCurrentViewComponent(); if (view) { view._init(); @@ -300,6 +327,10 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { this.stateChanges.next(); } + focusActiveCell() { + this._getCurrentViewComponent()._focusActiveCell(); + } + /** Handles date selection in the month view. */ _dateSelected(date: D): void { if (!this._dateAdapter.sameDate(date, this.selected)) { @@ -334,4 +365,9 @@ export class MatCalendar implements AfterContentInit, OnDestroy, OnChanges { private _getValidDateOrNull(obj: any): D | null { return (this._dateAdapter.isDateInstance(obj) && this._dateAdapter.isValid(obj)) ? obj : null; } + + /** Returns the component instance that corresponds to the current calendar view. */ + private _getCurrentViewComponent() { + return this.monthView || this.yearView || this.multiYearView; + } } diff --git a/src/lib/datepicker/datepicker.ts b/src/lib/datepicker/datepicker.ts index f6fc4626881f..9b35f13bb759 100644 --- a/src/lib/datepicker/datepicker.ts +++ b/src/lib/datepicker/datepicker.ts @@ -21,7 +21,7 @@ import {ComponentPortal, ComponentType} from '@angular/cdk/portal'; import {DOCUMENT} from '@angular/common'; import {take, filter} from 'rxjs/operators'; import { - AfterContentInit, + AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, @@ -97,7 +97,7 @@ export const _MatDatepickerContentMixinBase = mixinColor(MatDatepickerContentBas inputs: ['color'], }) export class MatDatepickerContent extends _MatDatepickerContentMixinBase - implements AfterContentInit, CanColor, OnInit, OnDestroy { + implements AfterViewInit, CanColor, OnInit, OnDestroy { /** Subscription to changes in the overlay's position. */ private _positionChange: Subscription|null; @@ -138,17 +138,8 @@ export class MatDatepickerContent extends _MatDatepickerContentMixinBase }); } - ngAfterContentInit() { - this._focusActiveCell(); - } - - /** Focuses the active cell after the microtask queue is empty. */ - private _focusActiveCell() { - this._ngZone.runOutsideAngular(() => { - this._ngZone.onStable.asObservable().pipe(take(1)).subscribe(() => { - this._elementRef.nativeElement.querySelector('.mat-calendar-body-active').focus(); - }); - }); + ngAfterViewInit() { + this._calendar.focusActiveCell(); } ngOnDestroy() { diff --git a/src/lib/datepicker/month-view.ts b/src/lib/datepicker/month-view.ts index 1dcf444ef54e..3afdeb4e65aa 100644 --- a/src/lib/datepicker/month-view.ts +++ b/src/lib/datepicker/month-view.ts @@ -106,7 +106,7 @@ export class MatMonthView implements AfterContentInit { @Output() readonly activeDateChange: EventEmitter = new EventEmitter(); /** The body of calendar table */ - @ViewChild(MatCalendarBody) _matCalendarBody; + @ViewChild(MatCalendarBody) _matCalendarBody: MatCalendarBody; /** The label for this month (e.g. "January 2017"). */ _monthLabel: string; @@ -155,7 +155,6 @@ export class MatMonthView implements AfterContentInit { ngAfterContentInit() { this._init(); - this._focusActiveCell(); } /** Handles when a new date is selected. */ @@ -253,7 +252,7 @@ export class MatMonthView implements AfterContentInit { } /** Focuses the active cell after the microtask queue is empty. */ - private _focusActiveCell() { + _focusActiveCell() { this._matCalendarBody._focusActiveCell(); } diff --git a/src/lib/datepicker/multi-year-view.ts b/src/lib/datepicker/multi-year-view.ts index 29f29542d26c..a074c63f35ac 100644 --- a/src/lib/datepicker/multi-year-view.ts +++ b/src/lib/datepicker/multi-year-view.ts @@ -102,7 +102,7 @@ export class MatMultiYearView implements AfterContentInit { @Output() readonly yearSelected: EventEmitter = new EventEmitter(); /** The body of calendar table */ - @ViewChild(MatCalendarBody) _matCalendarBody; + @ViewChild(MatCalendarBody) _matCalendarBody: MatCalendarBody; /** Grid of calendar cells representing the currently displayed years. */ _years: MatCalendarCell[][]; @@ -125,7 +125,6 @@ export class MatMultiYearView implements AfterContentInit { ngAfterContentInit() { this._init(); - this._focusActiveCell(); } /** Initializes this multi-year view. */ @@ -211,7 +210,7 @@ export class MatMultiYearView implements AfterContentInit { } /** Focuses the active cell after the microtask queue is empty. */ - private _focusActiveCell() { + _focusActiveCell() { this._matCalendarBody._focusActiveCell(); } diff --git a/src/lib/datepicker/year-view.ts b/src/lib/datepicker/year-view.ts index fd7e12478232..46600d80fd57 100644 --- a/src/lib/datepicker/year-view.ts +++ b/src/lib/datepicker/year-view.ts @@ -97,7 +97,7 @@ export class MatYearView implements AfterContentInit { @Output() readonly monthSelected: EventEmitter = new EventEmitter(); /** The body of calendar table */ - @ViewChild(MatCalendarBody) _matCalendarBody; + @ViewChild(MatCalendarBody) _matCalendarBody: MatCalendarBody; /** Grid of calendar cells representing the months of the year. */ _months: MatCalendarCell[][]; @@ -130,7 +130,6 @@ export class MatYearView implements AfterContentInit { ngAfterContentInit() { this._init(); - this._focusActiveCell(); } /** Handles when a new month is selected. */ @@ -211,7 +210,7 @@ export class MatYearView implements AfterContentInit { } /** Focuses the active cell after the microtask queue is empty. */ - private _focusActiveCell() { + _focusActiveCell() { this._matCalendarBody._focusActiveCell(); }