Skip to content

Commit

Permalink
fix(material/datepicker): update active date on focusing a calendar c…
Browse files Browse the repository at this point in the history
…ell (#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
  • Loading branch information
zarend authored Feb 7, 2022
1 parent 35090a7 commit 052b97d
Show file tree
Hide file tree
Showing 12 changed files with 262 additions and 33 deletions.
3 changes: 2 additions & 1 deletion src/material/datepicker/calendar-body.html
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@
[attr.aria-disabled]="!item.enabled || null"
[attr.aria-pressed]="_isSelected(item.compareValue)"
[attr.aria-current]="todayValue === item.compareValue ? 'date' : null"
(click)="_cellClicked(item, $event)">
(click)="_cellClicked(item, $event)"
(focus)="_emitActiveDateChange(item, $event)">
<div class="mat-calendar-body-cell-content mat-focus-indicator"
[class.mat-calendar-body-selected]="_isSelected(item.compareValue)"
[class.mat-calendar-body-comparison-identical]="_isComparisonIdentical(item.compareValue)"
Expand Down
28 changes: 27 additions & 1 deletion src/material/datepicker/calendar-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
OnChanges,
SimpleChanges,
OnDestroy,
AfterViewChecked,
} from '@angular/core';
import {take} from 'rxjs/operators';

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

/**
* Used to focus the active cell after change detection has run.
*/
private _focusActiveCellAfterViewChecked = false;

/** The label for the table. (e.g. "Jan 2017"). */
@Input() label: string;

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

ngAfterViewChecked() {
if (this._focusActiveCellAfterViewChecked) {
this._focusActiveCell();
this._focusActiveCellAfterViewChecked = false;
}
}

/** Whether a range is being selected. */
@Input() isRange: boolean = false;

Expand Down Expand Up @@ -127,6 +140,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
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 All @@ -153,6 +168,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
}
}

_emitActiveDateChange(cell: MatCalendarCell, event: FocusEvent): void {
if (cell.enabled) {
this.activeDateChange.emit({value: cell.value, event});
}
}

/** Returns whether a cell should be marked as selected. */
_isSelected(value: number) {
return this.startValue === value || this.endValue === value;
Expand Down Expand Up @@ -214,6 +235,11 @@ export class MatCalendarBody implements OnChanges, OnDestroy {
});
}

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

/** Gets whether a value is the start of the main range. */
_isRangeStart(value: number) {
return isStart(value, this.startValue, this.endValue);
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 @@ -21,6 +21,7 @@
[labelMinRequiredCells]="3"
[activeCell]="_dateAdapter.getDate(activeDate) - 1"
(selectedValueChange)="_dateSelected($event)"
(activeDateChange)="_updateActiveDate($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 jan11Cell = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="1"][data-mat-col="3"] button',
) as HTMLElement;

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

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

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

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

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
});
Expand Down
44 changes: 40 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._getDateFromDayOfMonth(date);
let rangeStartDate: number | null;
let rangeEndDate: number | null;

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

/**
* Takes the index of a calendar body cell wrapped in in an event as argument. For the date that
* corresponds to the given cell, set `activeDate` to that date and fire `activeDateChange` with
* that date.
*
* This fucntion is used to match each component's model of the active date with the calendar
* body cell that was focused. It updates its value of `activeDate` synchronously and updates the
* parent's value asynchonously via the `activeDateChange` event. The child component receives an
* updated value asynchronously via the `activeCell` Input.
*/
_updateActiveDate(event: MatCalendarUserEvent<number>) {
const month = event.value;
const oldActiveDate = this._activeDate;
this.activeDate = this._getDateFromDayOfMonth(month);

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

/** 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 +345,10 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, 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 @@ -376,6 +395,11 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
this._matCalendarBody._focusActiveCell(movePreview);
}

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

/** Called when the user has activated a new cell and the preview needs to be updated. */
_previewChanged({event, value: cell}: MatCalendarUserEvent<MatCalendarCell<D> | null>) {
if (this._rangeStrategy) {
Expand All @@ -398,6 +422,18 @@ export class MatMonthView<D> implements AfterContentInit, OnChanges, OnDestroy {
}
}

/**
* Takes a day of the month and returns a new date in the same month and year as the currently
* active date. The returned date will have the same day of the month as the argument date.
*/
private _getDateFromDayOfMonth(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)="_updateActiveDate($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 year2022Cell = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="1"][data-mat-col="2"] button',
) as HTMLElement;

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

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

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

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

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 1));
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
});
Expand Down
61 changes: 48 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,31 @@ 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._getDateFromYear(year);

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

/**
* Takes the index of a calendar body cell wrapped in in an event as argument. For the date that
* corresponds to the given cell, set `activeDate` to that date and fire `activeDateChange` with
* that date.
*
* This fucntion is used to match each component's model of the active date with the calendar
* body cell that was focused. It updates its value of `activeDate` synchronously and updates the
* parent's value asynchonously via the `activeDateChange` event. The child component receives an
* updated value asynchronously via the `activeCell` Input.
*/
_updateActiveDate(event: MatCalendarUserEvent<number>) {
const year = event.value;
const oldActiveDate = this._activeDate;

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

/** Handles keydown events on the calendar body when calendar is in multi-year view. */
Expand Down Expand Up @@ -278,7 +291,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 +316,28 @@ 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();
}

/**
* Takes a year and returns a new date on the same day and month as the currently active date
* The returned date will have the same year as the argument date.
*/
private _getDateFromYear(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)="_updateActiveDate($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 juneCell = fixture.debugElement.nativeElement.querySelector(
'[data-mat-row="1"][data-mat-col="1"] button',
) as HTMLElement;

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

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

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

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

expect(calendarInstance.date).toEqual(new Date(2017, JAN, 5));
expect(focusSpy).not.toHaveBeenCalled();
});
});
});
});
Expand Down
Loading

0 comments on commit 052b97d

Please sign in to comment.