Skip to content

Commit

Permalink
fix(material/datepicker): update active date on focusing a calendar cell
Browse files Browse the repository at this point in the history
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 angular#23483
  • Loading branch information
zarend committed Feb 1, 2022
1 parent 74e8c83 commit f14b23a
Show file tree
Hide file tree
Showing 11 changed files with 200 additions and 39 deletions.
12 changes: 4 additions & 8 deletions src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
@Input() numCols: number = 7;

/** The cell number of the active cell in the table. */
@Input() get activeCell(): number {
return this._activeCell;
}
set activeCell(activeCell: number) {
this._activeCell = activeCell;
}
private _activeCell: number = 0;
@Input() activeCell: number = 0;

ngAfterViewChecked() {
if (this._focusActiveCellAfterViewChecked) {
Expand Down Expand Up @@ -146,6 +140,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {
MatCalendarUserEvent<MatCalendarCell | null>
>();

@Output() readonly activeDateChange = new EventEmitter<MatCalendarUserEvent<number>>();

/** The number of blank cells to put at the beginning for the first row. */
_firstRowOffset: number;

Expand Down Expand Up @@ -174,7 +170,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked {

_cellFocused(cell: MatCalendarCell, event: FocusEvent): void {
if (cell.enabled) {
// TODO: make argument cell the active date
this.activeDateChange.emit({value: cell.value, event});
}
}

Expand Down
1 change: 1 addition & 0 deletions src/material/datepicker/month-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
[labelMinRequiredCells]="3"
[activeCell]="_dateAdapter.getDate(activeDate) - 1"
(selectedValueChange)="_dateSelected($event)"
(activeDateChange)="_dateBecomesActive($event)"
(previewChange)="_previewChanged($event)"
(keyup)="_handleCalendarBodyKeyup($event)"
(keydown)="_handleCalendarBodyKeydown($event)">
Expand Down
24 changes: 24 additions & 0 deletions src/material/datepicker/month-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -520,6 +520,30 @@ describe('MatMonthView', () => {
);
},
);

it('should go to month that is focused', () => {
const jan11El = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="1"][data-mat-col="3"] button',
) as HTMLElement;

dispatchFakeEvent(jan11El, 'focus');
fixture.detectChanges();

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 11));
});

it('should not call `.focus()` when the active date is focused', () => {
const jan5El = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="0"][data-mat-col="4"] button',
) as HTMLElement;
const focusSpy = (jan5El.focus = jasmine.createSpy('cellFocused'));

dispatchFakeEvent(jan5El, 'focus');
fixture.detectChanges();

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
});
Expand Down
29 changes: 25 additions & 4 deletions src/material/datepicker/month-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -230,9 +230,7 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
/** Handles when a new date is selected. */
_dateSelected(event: MatCalendarUserEvent<number>) {
const date = event.value;
const selectedYear = this._dateAdapter.getYear(this.activeDate);
const selectedMonth = this._dateAdapter.getMonth(this.activeDate);
const selectedDate = this._dateAdapter.createDate(selectedYear, selectedMonth, date);
const selectedDate = this._normalizeDayOfMonthToActiveDate(date);
let rangeStartDate: number | null;
let rangeEndDate: number | null;

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

/** Handles when a new date becomes active. */
_dateBecomesActive(event: MatCalendarUserEvent<number>) {
const month = event.value;
const oldActiveDate = this._activeDate;
this.activeDate = this._normalizeDayOfMonthToActiveDate(month);

if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
this.activeDateChange.emit(this._activeDate);

this._focusActiveCellAfterViewChecked();
}
}

/** Handles keydown events on the calendar body when calendar is in month view. */
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
Expand Down Expand Up @@ -327,9 +338,10 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {

if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
this.activeDateChange.emit(this.activeDate);

this._focusActiveCellAfterViewChecked();
}

this._focusActiveCellAfterViewChecked();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
Expand Down Expand Up @@ -403,6 +415,15 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
}
}

/** Used for turning a cell number into a date. */
private _normalizeDayOfMonthToActiveDate(dayOfMonth: number): D {
return this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
this._dateAdapter.getMonth(this.activeDate),
dayOfMonth,
);
}

/** Initializes the weekdays. */
private _initWeekdays() {
const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek();
Expand Down
1 change: 1 addition & 0 deletions src/material/datepicker/multi-year-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
[cellAspectRatio]="4 / 7"
[activeCell]="_getActiveCell()"
(selectedValueChange)="_yearSelected($event)"
(activeDateChange)="_yearBecomesActive($event)"
(keyup)="_handleCalendarBodyKeyup($event)"
(keydown)="_handleCalendarBodyKeydown($event)">
</tbody>
Expand Down
30 changes: 29 additions & 1 deletion src/material/datepicker/multi-year-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {dispatchFakeEvent, dispatchKeyboardEvent} from '../../cdk/testing/privat
import {Component, ViewChild} from '@angular/core';
import {waitForAsync, ComponentFixture, TestBed} from '@angular/core/testing';
import {MatNativeDateModule} from '@angular/material/core';
import {JAN} from '../testing';
import {JAN, MAR} from '../testing';
import {By} from '@angular/platform-browser';
import {MatCalendarBody} from './calendar-body';
import {MatMultiYearView, yearsPerPage, yearsPerRow} from './multi-year-view';
Expand Down Expand Up @@ -216,6 +216,34 @@ describe('MatMultiYearView', () => {

expect(calendarInstance.date).toEqual(new Date(2017 + yearsPerPage * 2, JAN, 1));
});

it('should go to the year that is focused', () => {
fixture.componentInstance.date = new Date(2017, MAR, 5);
fixture.detectChanges();
expect(calendarInstance.date).toEqual(new Date(2017, MAR, 5));

const year2022El = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="1"][data-mat-col="2"] button',
) as HTMLElement;

dispatchFakeEvent(year2022El, 'focus');
fixture.detectChanges();

expect(calendarInstance.date).toEqual(new Date(2022, MAR, 5));
});

it('should not call `.focus()` when the active date is focused', () => {
const year2017El = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="0"][data-mat-col="1"] button',
) as HTMLElement;
const focusSpy = (year2017El.focus = jasmine.createSpy('cellFocused'));

dispatchFakeEvent(year2017El, 'focus');
fixture.detectChanges();

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 1));
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
});
Expand Down
49 changes: 36 additions & 13 deletions src/material/datepicker/multi-year-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,18 +204,22 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
/** Handles when a new year is selected. */
_yearSelected(event: MatCalendarUserEvent<number>) {
const year = event.value;
this.yearSelected.emit(this._dateAdapter.createDate(year, 0, 1));
let month = this._dateAdapter.getMonth(this.activeDate);
let daysInMonth = this._dateAdapter.getNumDaysInMonth(
this._dateAdapter.createDate(year, month, 1),
);
this.selectedChange.emit(
this._dateAdapter.createDate(
year,
month,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
),
);
const selectedYear = this._dateAdapter.createDate(year, 0, 1);
const selectedDate = this._normalizeYearToActiveDate(year);

this.yearSelected.emit(selectedYear);
this.selectedChange.emit(selectedDate);
}

_yearBecomesActive(event: MatCalendarUserEvent<number>) {
const year = event.value;
const oldActiveDate = this._activeDate;

this.activeDate = this._normalizeYearToActiveDate(year);
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
this.activeDateChange.emit(this.activeDate);
this._focusActiveCellAfterViewChecked();
}
}

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

this._focusActiveCell();
this._focusActiveCellAfterViewChecked();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
Expand All @@ -303,6 +307,25 @@ export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
this._matCalendarBody._focusActiveCell();
}

/** Focuses the active cell after change detection has run and the microtask queue is empty. */
_focusActiveCellAfterViewChecked() {
this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked();
}

/** Used for turning a cell number into a date. */
private _normalizeYearToActiveDate(year: number) {
const activeMonth = this._dateAdapter.getMonth(this.activeDate);
const daysInMonth = this._dateAdapter.getNumDaysInMonth(
this._dateAdapter.createDate(year, activeMonth, 1),
);
const normalizedDate = this._dateAdapter.createDate(
year,
activeMonth,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
);
return normalizedDate;
}

/** Creates an MatCalendarCell for the given year. */
private _createCellForYear(year: number) {
const date = this._dateAdapter.createDate(year, 0, 1);
Expand Down
1 change: 1 addition & 0 deletions src/material/datepicker/year-view.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
[cellAspectRatio]="4 / 7"
[activeCell]="_dateAdapter.getMonth(activeDate)"
(selectedValueChange)="_monthSelected($event)"
(activeDateChange)="_monthBecomesActive($event)"
(keyup)="_handleCalendarBodyKeyup($event)"
(keydown)="_handleCalendarBodyKeydown($event)">
</tbody>
Expand Down
24 changes: 24 additions & 0 deletions src/material/datepicker/year-view.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,30 @@ describe('MatYearView', () => {

expect(calendarInstance.date).toEqual(new Date(2018, FEB, 28));
});

it('should go to date that is focused', () => {
const juneEl = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="1"][data-mat-col="1"] button',
) as HTMLElement;

dispatchFakeEvent(juneEl, 'focus');
fixture.detectChanges();

expect(calendarInstance.date).toEqual(new Date(2017, JUN, 5));
});

it('should not call `.focus()` when the active date is focused', () => {
const janEl = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="0"][data-mat-col="0"] button',
) as HTMLElement;
const focusSpy = (janEl.focus = jasmine.createSpy('cellFocused'));

dispatchFakeEvent(janEl, 'focus');
fixture.detectChanges();

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
});
Expand Down
50 changes: 39 additions & 11 deletions src/material/datepicker/year-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,23 +179,29 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
/** Handles when a new month is selected. */
_monthSelected(event: MatCalendarUserEvent<number>) {
const month = event.value;
const normalizedDate = this._dateAdapter.createDate(

const selectedMonth = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
month,
1,
);
this.monthSelected.emit(selectedMonth);

this.monthSelected.emit(normalizedDate);
const selectedDate = this._normalizeMonthToActiveDate(month);
this.selectedChange.emit(selectedDate);
}

const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate);
/** Handles when a new month becomes active. */
_monthBecomesActive(event: MatCalendarUserEvent<number>) {
const month = event.value;
const oldActiveDate = this._activeDate;

this.selectedChange.emit(
this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
month,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
),
);
this.activeDate = this._normalizeMonthToActiveDate(month);

if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
this.activeDateChange.emit(this.activeDate);
this._focusActiveCellAfterViewChecked();
}
}

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

if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
this.activeDateChange.emit(this.activeDate);
this._focusActiveCellAfterViewChecked();
}

this._focusActiveCell();
// Prevent unexpected default actions such as form submission.
event.preventDefault();
}
Expand Down Expand Up @@ -298,6 +304,11 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
this._matCalendarBody._focusActiveCell();
}

/** Schedules the matCalendarBody to focus the active cell after change detection has run */
_focusActiveCellAfterViewChecked() {
this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked();
}

/**
* Gets the month in this year that the given Date falls on.
* Returns null if the given Date is in another year.
Expand All @@ -308,6 +319,23 @@ export class MatYearView<D> implements AfterContentInit, OnDestroy {
: null;
}

/** Used for turning a cell number into a date. */
private _normalizeMonthToActiveDate(month: number) {
const normalizedDate = this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
month,
1,
);

const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate);

return this._dateAdapter.createDate(
this._dateAdapter.getYear(this.activeDate),
month,
Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth),
);
}

/** Creates an MatCalendarCell for the given month. */
private _createCellForMonth(month: number, monthName: string) {
const date = this._dateAdapter.createDate(this._dateAdapter.getYear(this.activeDate), month, 1);
Expand Down
Loading

0 comments on commit f14b23a

Please sign in to comment.