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..9042a0a922bb 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,6 +340,11 @@ 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. Ensures that the calendar body focuses on the date that is assigned to `this.activeDate` in this method. + this._changeDetectorRef.detectChanges(); + } + 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..39a29d47c706 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,9 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { this.activeDateChange.emit(this.activeDate); } + // Ensure the calendar body has the correct active cell. + this._changeDetectorRef.detectChanges(); + this._focusActiveCell(); // Prevent unexpected default actions such as form submission. event.preventDefault(); diff --git a/src/material/datepicker/testing/calendar-cell-harness.ts b/src/material/datepicker/testing/calendar-cell-harness.ts index 4ebee4c8ccb4..c0274c0d281d 100644 --- a/src/material/datepicker/testing/calendar-cell-harness.ts +++ b/src/material/datepicker/testing/calendar-cell-harness.ts @@ -69,7 +69,7 @@ export class MatCalendarCellHarness extends ComponentHarness { /** Whether the cell is selected. */ async isSelected(): Promise { const host = await this.host(); - return (await host.getAttribute('aria-selected')) === 'true'; + return (await host.getAttribute('aria-pressed')) === 'true'; } /** Whether the cell is disabled. */ 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..be9274f37c55 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,9 @@ export class MatYearView implements AfterContentInit, OnDestroy { this.activeDateChange.emit(this.activeDate); } + // Ensure the calendar body has the correct active cell. + this._changeDetectorRef.detectChanges(); + 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;