From 4baa4ee62600ee89dc802da9e513039680b1ec6c Mon Sep 17 00:00:00 2001 From: crisbeto Date: Fri, 27 Apr 2018 10:30:13 -0400 Subject: [PATCH] fix(datepicker): don't autofocus calendar cell if used outside of overlay Currently the `mat-calendar` component assumes that it'll be used inside an overlay which means that it always tries to move focus to the active date on init. This means that if the component is used outside the overlay, it would steal the user's focus and scroll the page to the calendar. These changes rework the logic so that focus isn't moved on init, unless the calendar is part of a datepicker. Fixes #11023. --- src/lib/datepicker/calendar.spec.ts | 39 ++++++++++++++++++++-- src/lib/datepicker/calendar.ts | 48 +++++++++++++++++++++++---- src/lib/datepicker/datepicker.ts | 17 +++------- src/lib/datepicker/month-view.ts | 5 ++- src/lib/datepicker/multi-year-view.ts | 5 ++- src/lib/datepicker/year-view.ts | 5 ++- 6 files changed, 89 insertions(+), 30 deletions(-) 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(); }