From 194ec1b7aad7df4b3deed4820c983151d0bad978 Mon Sep 17 00:00:00 2001 From: Zach Arend Date: Wed, 15 Dec 2021 18:36:47 +0000 Subject: [PATCH] fix(material/datepicker): update active date on focus 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). Addresses #23483 --- src/material/datepicker/calendar-body.html | 1 + src/material/datepicker/calendar-body.ts | 10 ++++++++ src/material/datepicker/month-view.html | 1 + src/material/datepicker/month-view.ts | 18 +++++++++++++ src/material/datepicker/multi-year-view.html | 1 + src/material/datepicker/multi-year-view.ts | 21 ++++++++++++++++ src/material/datepicker/year-view.html | 1 + src/material/datepicker/year-view.ts | 25 +++++++++++++++++++ tools/public_api_guard/material/datepicker.md | 9 ++++++- 9 files changed, 86 insertions(+), 1 deletion(-) diff --git a/src/material/datepicker/calendar-body.html b/src/material/datepicker/calendar-body.html index ffdfef27c1cd..dd0b34692735 100644 --- a/src/material/datepicker/calendar-body.html +++ b/src/material/datepicker/calendar-body.html @@ -51,6 +51,7 @@ [attr.aria-selected]="_isSelected(item.compareValue)" [attr.aria-current]="todayValue === item.compareValue ? 'date' : null" (click)="_cellClicked(item, $event)" + (focus)="_cellFocused(item, $event)" [style.width]="_cellWidth" [style.paddingTop]="_cellPadding" [style.paddingBottom]="_cellPadding"> diff --git a/src/material/datepicker/calendar-body.ts b/src/material/datepicker/calendar-body.ts index 895db4b41472..3882590bd858 100644 --- a/src/material/datepicker/calendar-body.ts +++ b/src/material/datepicker/calendar-body.ts @@ -122,6 +122,9 @@ export class MatCalendarBody implements OnChanges, OnDestroy { /** Emits when a new value is selected. */ @Output() readonly selectedValueChange = new EventEmitter>(); + /** Emits when a new date becomes active. */ + @Output() readonly activeValueChange = new EventEmitter>(); + /** Emits when the preview has changed as a result of a user action. */ @Output() readonly previewChange = new EventEmitter< MatCalendarUserEvent @@ -153,6 +156,13 @@ export class MatCalendarBody implements OnChanges, OnDestroy { } } + /** Called when a cell is focused. */ + _cellFocused(cell: MatCalendarCell, event: FocusEvent): void { + if (cell.enabled) { + this.activeValueChange.emit({value: cell.value, event}); + } + } + /** Returns whether a cell should be marked as selected. */ _isSelected(value: number) { return this.startValue === value || this.endValue === value; diff --git a/src/material/datepicker/month-view.html b/src/material/datepicker/month-view.html index 35deaf2339c2..5b6e09c104cc 100644 --- a/src/material/datepicker/month-view.html +++ b/src/material/datepicker/month-view.html @@ -24,6 +24,7 @@ [labelMinRequiredCells]="3" [activeCell]="_dateAdapter.getDate(activeDate) - 1" (selectedValueChange)="_dateSelected($event)" + (activeValueChange)="_dateBecomesActive($event)" (previewChange)="_previewChanged($event)" (keyup)="_handleCalendarBodyKeyup($event)" (keydown)="_handleCalendarBodyKeydown($event)"> diff --git a/src/material/datepicker/month-view.ts b/src/material/datepicker/month-view.ts index a46abfb4dfbd..870c18a19226 100644 --- a/src/material/datepicker/month-view.ts +++ b/src/material/datepicker/month-view.ts @@ -252,6 +252,17 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { this._changeDetectorRef.markForCheck(); } + _dateBecomesActive(event: MatCalendarUserEvent) { + const date = event.value; + const activeYear = this._dateAdapter.getYear(this.activeDate); + const activeMonth = this._dateAdapter.getMonth(this.activeDate); + const activeDate = this._dateAdapter.createDate(activeYear, activeMonth, date); + + if (!this._dateAdapter.sameDate(activeDate, this._activeDate)) { + this.activeDateChange.emit(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 @@ -329,7 +340,14 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { this.activeDateChange.emit(this.activeDate); } + if (!event.isTrusted) { + // Manually triggered events in unit tests do not trigger change detection. + this._changeDetectorRef.detectChanges(); + } + + // Focuses the active cell after the child component recieves the updated data from `this.activeDate`. this._focusActiveCell(); + // Prevent unexpected default actions such as form submission. event.preventDefault(); } diff --git a/src/material/datepicker/multi-year-view.html b/src/material/datepicker/multi-year-view.html index ee12a9e67d29..da2de1cf26e4 100644 --- a/src/material/datepicker/multi-year-view.html +++ b/src/material/datepicker/multi-year-view.html @@ -11,6 +11,7 @@ [cellAspectRatio]="4 / 7" [activeCell]="_getActiveCell()" (selectedValueChange)="_yearSelected($event)" + (activeValueChange)="_yearBecomesActive($event)" (keyup)="_handleCalendarBodyKeyup($event)" (keydown)="_handleCalendarBodyKeydown($event)"> diff --git a/src/material/datepicker/multi-year-view.ts b/src/material/datepicker/multi-year-view.ts index da7206057bdc..4bc9ef8ad04c 100644 --- a/src/material/datepicker/multi-year-view.ts +++ b/src/material/datepicker/multi-year-view.ts @@ -218,6 +218,21 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { ); } + _yearBecomesActive(event: MatCalendarUserEvent) { + const year = event.value; + let month = this._dateAdapter.getMonth(this.activeDate); + let daysInMonth = this._dateAdapter.getNumDaysInMonth( + this._dateAdapter.createDate(year, month, 1), + ); + this.activeDateChange.emit( + this._dateAdapter.createDate( + year, + month, + Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth), + ), + ); + } + /** Handles keydown events on the calendar body when calendar is in multi-year view. */ _handleCalendarBodyKeydown(event: KeyboardEvent): void { const oldActiveDate = this._activeDate; @@ -278,6 +293,12 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { this.activeDateChange.emit(this.activeDate); } + if (!event.isTrusted) { + // Manually triggered events in unit tests do not trigger change detection. + this._changeDetectorRef.detectChanges(); + } + + // Focuses the active cell after the child component recieves the updated data from `this.activeDate`. this._focusActiveCell(); // Prevent unexpected default actions such as form submission. event.preventDefault(); diff --git a/src/material/datepicker/year-view.html b/src/material/datepicker/year-view.html index dae81c5e2a27..c54b0c71269d 100644 --- a/src/material/datepicker/year-view.html +++ b/src/material/datepicker/year-view.html @@ -13,6 +13,7 @@ [cellAspectRatio]="4 / 7" [activeCell]="_dateAdapter.getMonth(activeDate)" (selectedValueChange)="_monthSelected($event)" + (activeValueChange)="_monthBecomesActive($event)" (keyup)="_handleCalendarBodyKeyup($event)" (keydown)="_handleCalendarBodyKeydown($event)"> diff --git a/src/material/datepicker/year-view.ts b/src/material/datepicker/year-view.ts index 2e121f75cc15..4b17a7cfa922 100644 --- a/src/material/datepicker/year-view.ts +++ b/src/material/datepicker/year-view.ts @@ -198,6 +198,25 @@ export class MatYearView implements AfterContentInit, OnDestroy { ); } + /** Handles when a new month becomes active. */ + _monthBecomesActive(event: MatCalendarUserEvent) { + const month = event.value; + const normalizedDate = this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), + month, + 1, + ); + + const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate); + + this.activeDateChange.emit( + this._dateAdapter.createDate( + this._dateAdapter.getYear(this.activeDate), + month, + Math.min(this._dateAdapter.getDate(this.activeDate), daysInMonth), + ), + ); + } /** Handles keydown events on the calendar body when calendar is in year view. */ _handleCalendarBodyKeydown(event: KeyboardEvent): void { // TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent @@ -261,6 +280,12 @@ export class MatYearView implements AfterContentInit, OnDestroy { this.activeDateChange.emit(this.activeDate); } + if (!event.isTrusted) { + // Manually triggered events in unit tests do not trigger change detection. + this._changeDetectorRef.detectChanges(); + } + + // Focuses the active cell after the child component recieves the updated data from `this.activeDate`. this._focusActiveCell(); // Prevent unexpected default actions such as form submission. event.preventDefault(); diff --git a/tools/public_api_guard/material/datepicker.md b/tools/public_api_guard/material/datepicker.md index c8fb85e4fca7..a475af4ae365 100644 --- a/tools/public_api_guard/material/datepicker.md +++ b/tools/public_api_guard/material/datepicker.md @@ -200,8 +200,10 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes export class MatCalendarBody implements OnChanges, OnDestroy { constructor(_elementRef: ElementRef, _ngZone: NgZone); activeCell: number; + readonly activeValueChange: EventEmitter>; cellAspectRatio: number; _cellClicked(cell: MatCalendarCell, event: MouseEvent): void; + _cellFocused(cell: MatCalendarCell, event: FocusEvent): void; _cellPadding: string; _cellWidth: string; comparisonEnd: number | null; @@ -239,7 +241,7 @@ export class MatCalendarBody implements OnChanges, OnDestroy { startValue: number; todayValue: number; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -759,6 +761,8 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { comparisonStart: D | null; // (undocumented) _dateAdapter: DateAdapter; + // (undocumented) + _dateBecomesActive(event: MatCalendarUserEvent): void; dateClass: MatCalendarCellClassFunction; dateFilter: (date: D) => boolean; _dateSelected(event: MatCalendarUserEvent): void; @@ -831,6 +835,8 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { readonly selectedChange: EventEmitter; _selectedYear: number | null; _todayYear: number; + // (undocumented) + _yearBecomesActive(event: MatCalendarUserEvent): void; _years: MatCalendarCell[][]; readonly yearSelected: EventEmitter; _yearSelected(event: MatCalendarUserEvent): void; @@ -907,6 +913,7 @@ export class MatYearView implements AfterContentInit, OnDestroy { set maxDate(value: D | null); get minDate(): D | null; set minDate(value: D | null); + _monthBecomesActive(event: MatCalendarUserEvent): void; _months: MatCalendarCell[][]; readonly monthSelected: EventEmitter; _monthSelected(event: MatCalendarUserEvent): void;