Skip to content

Commit 0ab736c

Browse files
committed
fix(material/datepicker): update active date on focusing a calendar cell
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 25fb01d commit 0ab736c

10 files changed

+184
-37
lines changed

src/material/datepicker/calendar-body.ts

+4-8
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
102102
@Input() numCols: number = 7;
103103

104104
/** The cell number of the active cell in the table. */
105-
@Input() get activeCell(): number {
106-
return this._activeCell;
107-
}
108-
set activeCell(activeCell: number) {
109-
this._activeCell = activeCell;
110-
}
111-
private _activeCell: number = 0;
105+
@Input() activeCell: number = 0;
112106

113107
ngAfterViewChecked() {
114108
if (this._focusActiveCellAfterViewChecked) {
@@ -146,6 +140,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
146140
MatCalendarUserEvent<MatCalendarCell | null>
147141
>();
148142

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

@@ -174,7 +170,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
174170

175171
_cellFocused(cell: MatCalendarCell, event: FocusEvent): void {
176172
if (cell.enabled) {
177-
// TODO: make argument cell the active date
173+
this.activeDateChange.emit({value: cell.value, event});
178174
}
179175
}
180176

src/material/datepicker/month-view.html

+1
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
[labelMinRequiredCells]="3"
2525
[activeCell]="_dateAdapter.getDate(activeDate) - 1"
2626
(selectedValueChange)="_dateSelected($event)"
27+
(activeDateChange)="_dateBecomesActive($event)"
2728
(previewChange)="_previewChanged($event)"
2829
(keyup)="_handleCalendarBodyKeyup($event)"
2930
(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 jan11El = fixture.debugElement.nativeElement.querySelector(
526+
'[data-mat-row="1"][data-mat-col="3"] button',
527+
) as HTMLElement;
528+
529+
dispatchFakeEvent(jan11El, '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 jan5El = fixture.debugElement.nativeElement.querySelector(
537+
'[data-mat-row="0"][data-mat-col="4"] button',
538+
) as HTMLElement;
539+
const focusSpy = (jan5El.focus = jasmine.createSpy('cellFocused'));
540+
541+
dispatchFakeEvent(jan5El, '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

+25-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._normalizeDayOfMonthToActiveDate(date);
236234
let rangeStartDate: number | null;
237235
let rangeEndDate: number | null;
238236

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

253+
/** Handles when a new date becomes active. */
254+
_dateBecomesActive(event: MatCalendarUserEvent<number>) {
255+
const month = event.value;
256+
const oldActiveDate = this._activeDate;
257+
this.activeDate = this._normalizeDayOfMonthToActiveDate(month);
258+
259+
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
260+
this.activeDateChange.emit(this._activeDate);
261+
262+
this._focusActiveCellAfterViewChecked();
263+
}
264+
}
265+
255266
/** Handles keydown events on the calendar body when calendar is in month view. */
256267
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
257268
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
@@ -327,9 +338,10 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
327338

328339
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
329340
this.activeDateChange.emit(this.activeDate);
341+
342+
this._focusActiveCellAfterViewChecked();
330343
}
331344

332-
this._focusActiveCellAfterViewChecked();
333345
// Prevent unexpected default actions such as form submission.
334346
event.preventDefault();
335347
}
@@ -403,6 +415,15 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
403415
}
404416
}
405417

418+
/** Used for turning a cell number into a date. */
419+
private _normalizeDayOfMonthToActiveDate(dayOfMonth: number): D {
420+
return this._dateAdapter.createDate(
421+
this._dateAdapter.getYear(this.activeDate),
422+
this._dateAdapter.getMonth(this.activeDate),
423+
dayOfMonth,
424+
);
425+
}
426+
406427
/** Initializes the weekdays. */
407428
private _initWeekdays() {
408429
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)="_yearBecomesActive($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 year2022El = fixture.debugElement.nativeElement.querySelector(
226+
'[data-mat-row="1"][data-mat-col="2"] button',
227+
) as HTMLElement;
228+
229+
dispatchFakeEvent(year2022El, '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 year2017El = fixture.debugElement.nativeElement.querySelector(
237+
'[data-mat-row="0"][data-mat-col="1"] button',
238+
) as HTMLElement;
239+
const focusSpy = (year2017El.focus = jasmine.createSpy('cellFocused'));
240+
241+
dispatchFakeEvent(year2017El, '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

+36-13
Original file line numberDiff line numberDiff line change
@@ -204,18 +204,22 @@ 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._normalizeYearToActiveDate(year);
209+
210+
this.yearSelected.emit(selectedYear);
211+
this.selectedChange.emit(selectedDate);
212+
}
213+
214+
_yearBecomesActive(event: MatCalendarUserEvent<number>) {
215+
const year = event.value;
216+
const oldActiveDate = this._activeDate;
217+
218+
this.activeDate = this._normalizeYearToActiveDate(year);
219+
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
220+
this.activeDateChange.emit(this.activeDate);
221+
this._focusActiveCellAfterViewChecked();
222+
}
219223
}
220224

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

281-
this._focusActiveCell();
285+
this._focusActiveCellAfterViewChecked();
282286
// Prevent unexpected default actions such as form submission.
283287
event.preventDefault();
284288
}
@@ -303,6 +307,25 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
303307
this._matCalendarBody._focusActiveCell();
304308
}
305309

310+
/** Focuses the active cell after change detection has run and the microtask queue is empty. */
311+
_focusActiveCellAfterViewChecked() {
312+
this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked();
313+
}
314+
315+
/** Used for turning a cell number into a date. */
316+
private _normalizeYearToActiveDate(year: number) {
317+
const activeMonth = this._dateAdapter.getMonth(this.activeDate);
318+
const daysInMonth = this._dateAdapter.getNumDaysInMonth(
319+
this._dateAdapter.createDate(year, activeMonth, 1),
320+
);
321+
const normalizedDate = this._dateAdapter.createDate(
322+
year,
323+
activeMonth,
324+
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
325+
);
326+
return normalizedDate;
327+
}
328+
306329
/** Creates an MatCalendarCell for the given year. */
307330
private _createCellForYear(year: number) {
308331
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)="_monthBecomesActive($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 juneEl = fixture.debugElement.nativeElement.querySelector(
298+
'[data-mat-row="1"][data-mat-col="1"] button',
299+
) as HTMLElement;
300+
301+
dispatchFakeEvent(juneEl, '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 janEl = fixture.debugElement.nativeElement.querySelector(
309+
'[data-mat-row="0"][data-mat-col="0"] button',
310+
) as HTMLElement;
311+
const focusSpy = (janEl.focus = jasmine.createSpy('cellFocused'));
312+
313+
dispatchFakeEvent(janEl, 'focus');
314+
fixture.detectChanges();
315+
316+
expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
317+
expect(focusSpy).not.toHaveBeenCalled();
318+
});
295319
});
296320
});
297321
});

src/material/datepicker/year-view.ts

+39-11
Original file line numberDiff line numberDiff line change
@@ -179,23 +179,29 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
179179
/** Handles when a new month is selected. */
180180
_monthSelected(event: MatCalendarUserEvent<number>) {
181181
const month = event.value;
182-
const normalizedDate = this._dateAdapter.createDate(
182+
183+
const selectedMonth = this._dateAdapter.createDate(
183184
this._dateAdapter.getYear(this.activeDate),
184185
month,
185186
1,
186187
);
188+
this.monthSelected.emit(selectedMonth);
187189

188-
this.monthSelected.emit(normalizedDate);
190+
const selectedDate = this._normalizeMonthToActiveDate(month);
191+
this.selectedChange.emit(selectedDate);
192+
}
189193

190-
const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate);
194+
/** Handles when a new month becomes active. */
195+
_monthBecomesActive(event: MatCalendarUserEvent<number>) {
196+
const month = event.value;
197+
const oldActiveDate = this._activeDate;
191198

192-
this.selectedChange.emit(
193-
this._dateAdapter.createDate(
194-
this._dateAdapter.getYear(this.activeDate),
195-
month,
196-
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
197-
),
198-
);
199+
this.activeDate = this._normalizeMonthToActiveDate(month);
200+
201+
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
202+
this.activeDateChange.emit(this.activeDate);
203+
this._focusActiveCellAfterViewChecked();
204+
}
199205
}
200206

201207
/** Handles keydown events on the calendar body when calendar is in year view. */
@@ -259,9 +265,9 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
259265

260266
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
261267
this.activeDateChange.emit(this.activeDate);
268+
this._focusActiveCellAfterViewChecked();
262269
}
263270

264-
this._focusActiveCell();
265271
// Prevent unexpected default actions such as form submission.
266272
event.preventDefault();
267273
}
@@ -298,6 +304,11 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
298304
this._matCalendarBody._focusActiveCell();
299305
}
300306

307+
/** Schedules the matCalendarBody to focus the active cell after change detection has run */
308+
_focusActiveCellAfterViewChecked() {
309+
this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked();
310+
}
311+
301312
/**
302313
* Gets the month in this year that the given Date falls on.
303314
* Returns null if the given Date is in another year.
@@ -308,6 +319,23 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
308319
: null;
309320
}
310321

322+
/** Used for turning a cell number into a date. */
323+
private _normalizeMonthToActiveDate(month: number) {
324+
const normalizedDate = this._dateAdapter.createDate(
325+
this._dateAdapter.getYear(this.activeDate),
326+
month,
327+
1,
328+
);
329+
330+
const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate);
331+
332+
return this._dateAdapter.createDate(
333+
this._dateAdapter.getYear(this.activeDate),
334+
month,
335+
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
336+
);
337+
}
338+
311339
/** Creates an MatCalendarCell for the given month. */
312340
private _createCellForMonth(month: number, monthName: string) {
313341
const date = this._dateAdapter.createDate(this._dateAdapter.getYear(this.activeDate), month, 1);

0 commit comments

Comments
 (0)