Skip to content

Commit 052b97d

Browse files
authored
fix(material/datepicker): update active date on focusing a calendar cell (#24279)
When a a date cell on the calendar recieves focus, set the active date to that cell. This ensures that the active date matches the date with browser focus. Previously, we set the active date on keydown and click, but that was problematic for screenreaders. That's because many screenreaders trigger a focus event instead of a keydown event when using screenreader specific navigation (VoiceOver, Chromevox, NVDA). Fixes #23483
1 parent 35090a7 commit 052b97d

12 files changed

+262
-33
lines changed

src/material/datepicker/calendar-body.html

+2-1
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@
6363
[attr.aria-disabled]="!item.enabled || null"
6464
[attr.aria-pressed]="_isSelected(item.compareValue)"
6565
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
66-
(click)="_cellClicked(item, $event)">
66+
(click)="_cellClicked(item, $event)"
67+
(focus)="_emitActiveDateChange(item, $event)">
6768
<div class="mat-calendar-body-cell-content mat-focus-indicator"
6869
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
6970
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"

src/material/datepicker/calendar-body.ts

+27-1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
OnChanges,
1919
SimpleChanges,
2020
OnDestroy,
21+
AfterViewChecked,
2122
} from '@angular/core';
2223
import {take} from 'rxjs/operators';
2324

@@ -67,13 +68,18 @@ export interface MatCalendarUserEvent<D> {
6768
encapsulation: ViewEncapsulation.None,
6869
changeDetection: ChangeDetectionStrategy.OnPush,
6970
})
70-
export class MatCalendarBody implements OnChanges, OnDestroy {
71+
export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
7172
/**
7273
* Used to skip the next focus event when rendering the preview range.
7374
* We need a flag like this, because some browsers fire focus events asynchronously.
7475
*/
7576
private _skipNextFocus: boolean;
7677

78+
/**
79+
* Used to focus the active cell after change detection has run.
80+
*/
81+
private _focusActiveCellAfterViewChecked = false;
82+
7783
/** The label for the table. (e.g. "Jan 2017"). */
7884
@Input() label: string;
7985

@@ -98,6 +104,13 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
98104
/** The cell number of the active cell in the table. */
99105
@Input() activeCell: number = 0;
100106

107+
ngAfterViewChecked() {
108+
if (this._focusActiveCellAfterViewChecked) {
109+
this._focusActiveCell();
110+
this._focusActiveCellAfterViewChecked = false;
111+
}
112+
}
113+
101114
/** Whether a range is being selected. */
102115
@Input() isRange: boolean = false;
103116

@@ -127,6 +140,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
127140
MatCalendarUserEvent<MatCalendarCell | null>
128141
>();
129142

143+
@Output() readonly activeDateChange = new EventEmitter<MatCalendarUserEvent<number>>();
144+
130145
/** The number of blank cells to put at the beginning for the first row. */
131146
_firstRowOffset: number;
132147

@@ -153,6 +168,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
153168
}
154169
}
155170

171+
_emitActiveDateChange(cell: MatCalendarCell, event: FocusEvent): void {
172+
if (cell.enabled) {
173+
this.activeDateChange.emit({value: cell.value, event});
174+
}
175+
}
176+
156177
/** Returns whether a cell should be marked as selected. */
157178
_isSelected(value: number) {
158179
return this.startValue === value || this.endValue === value;
@@ -214,6 +235,11 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
214235
});
215236
}
216237

238+
/** Focuses the active cell after change detection has run and the microtask queue is empty. */
239+
_scheduleFocusActiveCellAfterViewChecked() {
240+
this._focusActiveCellAfterViewChecked = true;
241+
}
242+
217243
/** Gets whether a value is the start of the main range. */
218244
_isRangeStart(value: number) {
219245
return isStart(value, this.startValue, this.endValue);

src/material/datepicker/month-view.html

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
[labelMinRequiredCells]="3"
2222
[activeCell]="_dateAdapter.getDate(activeDate) - 1"
2323
(selectedValueChange)="_dateSelected($event)"
24+
(activeDateChange)="_updateActiveDate($event)"
2425
(previewChange)="_previewChanged($event)"
2526
(keyup)="_handleCalendarBodyKeyup($event)"
2627
(keydown)="_handleCalendarBodyKeydown($event)">

src/material/datepicker/month-view.spec.ts

+24
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,30 @@ describe('MatMonthView', () => {
520520
);
521521
},
522522
);
523+
524+
it('should go to month that is focused', () => {
525+
const jan11Cell = fixture.debugElement.nativeElement.querySelector(
526+
'[data-mat-row="1"][data-mat-col="3"] button',
527+
) as HTMLElement;
528+
529+
dispatchFakeEvent(jan11Cell, 'focus');
530+
fixture.detectChanges();
531+
532+
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 11));
533+
});
534+
535+
it('should not call `.focus()` when the active date is focused', () => {
536+
const jan5Cell = fixture.debugElement.nativeElement.querySelector(
537+
'[data-mat-row="0"][data-mat-col="4"] button',
538+
) as HTMLElement;
539+
const focusSpy = (jan5Cell.focus = jasmine.createSpy('cellFocused'));
540+
541+
dispatchFakeEvent(jan5Cell, 'focus');
542+
fixture.detectChanges();
543+
544+
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
545+
expect(focusSpy).not.toHaveBeenCalled();
546+
});
523547
});
524548
});
525549
});

src/material/datepicker/month-view.ts

+40-4
Original file line numberDiff line numberDiff line change
@@ -230,9 +230,7 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
230230
/** Handles when a new date is selected. */
231231
_dateSelected(event: MatCalendarUserEvent<number>) {
232232
const date = event.value;
233-
const selectedYear = this._dateAdapter.getYear(this.activeDate);
234-
const selectedMonth = this._dateAdapter.getMonth(this.activeDate);
235-
const selectedDate = this._dateAdapter.createDate(selectedYear, selectedMonth, date);
233+
const selectedDate = this._getDateFromDayOfMonth(date);
236234
let rangeStartDate: number | null;
237235
let rangeEndDate: number | null;
238236

@@ -252,6 +250,26 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
252250
this._changeDetectorRef.markForCheck();
253251
}
254252

253+
/**
254+
* Takes the index of a calendar body cell wrapped in in an event as argument. For the date that
255+
* corresponds to the given cell, set `activeDate` to that date and fire `activeDateChange` with
256+
* that date.
257+
*
258+
* This fucntion is used to match each component's model of the active date with the calendar
259+
* body cell that was focused. It updates its value of `activeDate` synchronously and updates the
260+
* parent's value asynchonously via the `activeDateChange` event. The child component receives an
261+
* updated value asynchronously via the `activeCell` Input.
262+
*/
263+
_updateActiveDate(event: MatCalendarUserEvent<number>) {
264+
const month = event.value;
265+
const oldActiveDate = this._activeDate;
266+
this.activeDate = this._getDateFromDayOfMonth(month);
267+
268+
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
269+
this.activeDateChange.emit(this._activeDate);
270+
}
271+
}
272+
255273
/** Handles keydown events on the calendar body when calendar is in month view. */
256274
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
257275
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
@@ -327,9 +345,10 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
327345

328346
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
329347
this.activeDateChange.emit(this.activeDate);
348+
349+
this._focusActiveCellAfterViewChecked();
330350
}
331351

332-
this._focusActiveCell();
333352
// Prevent unexpected default actions such as form submission.
334353
event.preventDefault();
335354
}
@@ -376,6 +395,11 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
376395
this._matCalendarBody._focusActiveCell(movePreview);
377396
}
378397

398+
/** Focuses the active cell after change detection has run and the microtask queue is empty. */
399+
_focusActiveCellAfterViewChecked() {
400+
this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked();
401+
}
402+
379403
/** Called when the user has activated a new cell and the preview needs to be updated. */
380404
_previewChanged({event, value: cell}: MatCalendarUserEvent<MatCalendarCell<D> | null>) {
381405
if (this._rangeStrategy) {
@@ -398,6 +422,18 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
398422
}
399423
}
400424

425+
/**
426+
* Takes a day of the month and returns a new date in the same month and year as the currently
427+
* active date. The returned date will have the same day of the month as the argument date.
428+
*/
429+
private _getDateFromDayOfMonth(dayOfMonth: number): D {
430+
return this._dateAdapter.createDate(
431+
this._dateAdapter.getYear(this.activeDate),
432+
this._dateAdapter.getMonth(this.activeDate),
433+
dayOfMonth,
434+
);
435+
}
436+
401437
/** Initializes the weekdays. */
402438
private _initWeekdays() {
403439
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();

src/material/datepicker/multi-year-view.html

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
[cellAspectRatio]="4 / 7"
1212
[activeCell]="_getActiveCell()"
1313
(selectedValueChange)="_yearSelected($event)"
14+
(activeDateChange)="_updateActiveDate($event)"
1415
(keyup)="_handleCalendarBodyKeyup($event)"
1516
(keydown)="_handleCalendarBodyKeydown($event)">
1617
</tbody>

src/material/datepicker/multi-year-view.spec.ts

+29-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {dispatchFakeEvent, dispatchKeyboardEvent} from '../../cdk/testing/privat
1313
import {Component, ViewChild} from '@angular/core';
1414
import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
1515
import {MatNativeDateModule} from '@angular/material/core';
16-
import {JAN} from '../testing';
16+
import {JAN, MAR} from '../testing';
1717
import {By} from '@angular/platform-browser';
1818
import {MatCalendarBody} from './calendar-body';
1919
import {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view';
@@ -216,6 +216,34 @@ describe('MatMultiYearView', () => {
216216

217217
expect(calendarInstance.date).toEqual(new Date(2017 + yearsPerPage * 2, JAN, 1));
218218
});
219+
220+
it('should go to the year that is focused', () => {
221+
fixture.componentInstance.date = new Date(2017, MAR, 5);
222+
fixture.detectChanges();
223+
expect(calendarInstance.date).toEqual(new Date(2017, MAR, 5));
224+
225+
const year2022Cell = fixture.debugElement.nativeElement.querySelector(
226+
'[data-mat-row="1"][data-mat-col="2"] button',
227+
) as HTMLElement;
228+
229+
dispatchFakeEvent(year2022Cell, 'focus');
230+
fixture.detectChanges();
231+
232+
expect(calendarInstance.date).toEqual(new Date(2022, MAR, 5));
233+
});
234+
235+
it('should not call `.focus()` when the active date is focused', () => {
236+
const year2017Cell = fixture.debugElement.nativeElement.querySelector(
237+
'[data-mat-row="0"][data-mat-col="1"] button',
238+
) as HTMLElement;
239+
const focusSpy = (year2017Cell.focus = jasmine.createSpy('cellFocused'));
240+
241+
dispatchFakeEvent(year2017Cell, 'focus');
242+
fixture.detectChanges();
243+
244+
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 1));
245+
expect(focusSpy).not.toHaveBeenCalled();
246+
});
219247
});
220248
});
221249
});

src/material/datepicker/multi-year-view.ts

+48-13
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,31 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
204204
/** Handles when a new year is selected. */
205205
_yearSelected(event: MatCalendarUserEvent<number>) {
206206
const year = event.value;
207-
this.yearSelected.emit(this._dateAdapter.createDate(year, 0, 1));
208-
let month = this._dateAdapter.getMonth(this.activeDate);
209-
let daysInMonth = this._dateAdapter.getNumDaysInMonth(
210-
this._dateAdapter.createDate(year, month, 1),
211-
);
212-
this.selectedChange.emit(
213-
this._dateAdapter.createDate(
214-
year,
215-
month,
216-
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
217-
),
218-
);
207+
const selectedYear = this._dateAdapter.createDate(year, 0, 1);
208+
const selectedDate = this._getDateFromYear(year);
209+
210+
this.yearSelected.emit(selectedYear);
211+
this.selectedChange.emit(selectedDate);
212+
}
213+
214+
/**
215+
* Takes the index of a calendar body cell wrapped in in an event as argument. For the date that
216+
* corresponds to the given cell, set `activeDate` to that date and fire `activeDateChange` with
217+
* that date.
218+
*
219+
* This fucntion is used to match each component's model of the active date with the calendar
220+
* body cell that was focused. It updates its value of `activeDate` synchronously and updates the
221+
* parent's value asynchonously via the `activeDateChange` event. The child component receives an
222+
* updated value asynchronously via the `activeCell` Input.
223+
*/
224+
_updateActiveDate(event: MatCalendarUserEvent<number>) {
225+
const year = event.value;
226+
const oldActiveDate = this._activeDate;
227+
228+
this.activeDate = this._getDateFromYear(year);
229+
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
230+
this.activeDateChange.emit(this.activeDate);
231+
}
219232
}
220233

221234
/** Handles keydown events on the calendar body when calendar is in multi-year view. */
@@ -278,7 +291,7 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
278291
this.activeDateChange.emit(this.activeDate);
279292
}
280293

281-
this._focusActiveCell();
294+
this._focusActiveCellAfterViewChecked();
282295
// Prevent unexpected default actions such as form submission.
283296
event.preventDefault();
284297
}
@@ -303,6 +316,28 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
303316
this._matCalendarBody._focusActiveCell();
304317
}
305318

319+
/** Focuses the active cell after change detection has run and the microtask queue is empty. */
320+
_focusActiveCellAfterViewChecked() {
321+
this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked();
322+
}
323+
324+
/**
325+
* Takes a year and returns a new date on the same day and month as the currently active date
326+
* The returned date will have the same year as the argument date.
327+
*/
328+
private _getDateFromYear(year: number) {
329+
const activeMonth = this._dateAdapter.getMonth(this.activeDate);
330+
const daysInMonth = this._dateAdapter.getNumDaysInMonth(
331+
this._dateAdapter.createDate(year, activeMonth, 1),
332+
);
333+
const normalizedDate = this._dateAdapter.createDate(
334+
year,
335+
activeMonth,
336+
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
337+
);
338+
return normalizedDate;
339+
}
340+
306341
/** Creates an MatCalendarCell for the given year. */
307342
private _createCellForYear(year: number) {
308343
const date = this._dateAdapter.createDate(year, 0, 1);

src/material/datepicker/year-view.html

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
[cellAspectRatio]="4 / 7"
1414
[activeCell]="_dateAdapter.getMonth(activeDate)"
1515
(selectedValueChange)="_monthSelected($event)"
16+
(activeDateChange)="_updateActiveDate($event)"
1617
(keyup)="_handleCalendarBodyKeyup($event)"
1718
(keydown)="_handleCalendarBodyKeydown($event)">
1819
</tbody>

src/material/datepicker/year-view.spec.ts

+24
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,30 @@ describe('MatYearView', () => {
292292

293293
expect(calendarInstance.date).toEqual(new Date(2018, FEB, 28));
294294
});
295+
296+
it('should go to date that is focused', () => {
297+
const juneCell = fixture.debugElement.nativeElement.querySelector(
298+
'[data-mat-row="1"][data-mat-col="1"] button',
299+
) as HTMLElement;
300+
301+
dispatchFakeEvent(juneCell, 'focus');
302+
fixture.detectChanges();
303+
304+
expect(calendarInstance.date).toEqual(new Date(2017, JUN, 5));
305+
});
306+
307+
it('should not call `.focus()` when the active date is focused', () => {
308+
const janCell = fixture.debugElement.nativeElement.querySelector(
309+
'[data-mat-row="0"][data-mat-col="0"] button',
310+
) as HTMLElement;
311+
const focusSpy = (janCell.focus = jasmine.createSpy('cellFocused'));
312+
313+
dispatchFakeEvent(janCell, 'focus');
314+
fixture.detectChanges();
315+
316+
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
317+
expect(focusSpy).not.toHaveBeenCalled();
318+
});
295319
});
296320
});
297321
});

0 commit comments

Comments
 (0)