diff --git a/src/material/datepicker/calendar-body.html b/src/material/datepicker/calendar-body.html index 1b0f5f0b1a4a..25db33ecbee5 100644 --- a/src/material/datepicker/calendar-body.html +++ b/src/material/datepicker/calendar-body.html @@ -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)">
{ 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; @@ -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; @@ -127,6 +140,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy { MatCalendarUserEvent >(); + @Output() readonly activeDateChange = new EventEmitter>(); + /** The number of blank cells to put at the beginning for the first row. */ _firstRowOffset: number; @@ -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; @@ -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); diff --git a/src/material/datepicker/month-view.html b/src/material/datepicker/month-view.html index 736d7eff7cd1..3ccafd6d2e94 100644 --- a/src/material/datepicker/month-view.html +++ b/src/material/datepicker/month-view.html @@ -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)"> diff --git a/src/material/datepicker/month-view.spec.ts b/src/material/datepicker/month-view.spec.ts index 7a4909f8efbb..27bada546455 100644 --- a/src/material/datepicker/month-view.spec.ts +++ b/src/material/datepicker/month-view.spec.ts @@ -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(); + }); }); }); }); diff --git a/src/material/datepicker/month-view.ts b/src/material/datepicker/month-view.ts index a46abfb4dfbd..03fdfa9dc0eb 100644 --- a/src/material/datepicker/month-view.ts +++ b/src/material/datepicker/month-view.ts @@ -230,9 +230,7 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { /** Handles when a new date is selected. */ _dateSelected(event: MatCalendarUserEvent) { 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; @@ -252,6 +250,26 @@ export class MatMonthView 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) { + 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 @@ -327,9 +345,10 @@ export class MatMonthView 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(); } @@ -376,6 +395,11 @@ export class MatMonthView 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 | null>) { if (this._rangeStrategy) { @@ -398,6 +422,18 @@ export class MatMonthView 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(); diff --git a/src/material/datepicker/multi-year-view.html b/src/material/datepicker/multi-year-view.html index ee12a9e67d29..84220c8fa95f 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)" + (activeDateChange)="_updateActiveDate($event)" (keyup)="_handleCalendarBodyKeyup($event)" (keydown)="_handleCalendarBodyKeydown($event)"> diff --git a/src/material/datepicker/multi-year-view.spec.ts b/src/material/datepicker/multi-year-view.spec.ts index 61c14607eb26..47e56f395d1a 100644 --- a/src/material/datepicker/multi-year-view.spec.ts +++ b/src/material/datepicker/multi-year-view.spec.ts @@ -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'; @@ -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(); + }); }); }); }); diff --git a/src/material/datepicker/multi-year-view.ts b/src/material/datepicker/multi-year-view.ts index da7206057bdc..4d1d6b133255 100644 --- a/src/material/datepicker/multi-year-view.ts +++ b/src/material/datepicker/multi-year-view.ts @@ -204,18 +204,31 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { /** Handles when a new year is selected. */ _yearSelected(event: MatCalendarUserEvent) { 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) { + 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. */ @@ -278,7 +291,7 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { this.activeDateChange.emit(this.activeDate); } - this._focusActiveCell(); + this._focusActiveCellAfterViewChecked(); // Prevent unexpected default actions such as form submission. event.preventDefault(); } @@ -303,6 +316,28 @@ export class MatMultiYearView 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); diff --git a/src/material/datepicker/year-view.html b/src/material/datepicker/year-view.html index dae81c5e2a27..afb9821dd416 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)" + (activeDateChange)="_updateActiveDate($event)" (keyup)="_handleCalendarBodyKeyup($event)" (keydown)="_handleCalendarBodyKeydown($event)"> diff --git a/src/material/datepicker/year-view.spec.ts b/src/material/datepicker/year-view.spec.ts index 99e6ca6783f9..2fb07ef50423 100644 --- a/src/material/datepicker/year-view.spec.ts +++ b/src/material/datepicker/year-view.spec.ts @@ -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(); + }); }); }); }); diff --git a/src/material/datepicker/year-view.ts b/src/material/datepicker/year-view.ts index 2e121f75cc15..b69c98bba7f9 100644 --- a/src/material/datepicker/year-view.ts +++ b/src/material/datepicker/year-view.ts @@ -179,23 +179,37 @@ export class MatYearView implements AfterContentInit, OnDestroy { /** Handles when a new month is selected. */ _monthSelected(event: MatCalendarUserEvent) { 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._getDateFromMonth(month); + this.selectedChange.emit(selectedDate); + } - const daysInMonth = this._dateAdapter.getNumDaysInMonth(normalizedDate); + /** + * 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) { + 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._getDateFromMonth(month); + + if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) { + this.activeDateChange.emit(this.activeDate); + } } /** Handles keydown events on the calendar body when calendar is in year view. */ @@ -259,9 +273,9 @@ export class MatYearView 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(); } @@ -298,6 +312,11 @@ export class MatYearView 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. @@ -308,6 +327,26 @@ export class MatYearView implements AfterContentInit, OnDestroy { : null; } + /** + * Takes a month and returns a new date in the same day and year as the currently active date. + * The returned date will have the same month as the argument date. + */ + private _getDateFromMonth(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); diff --git a/tools/public_api_guard/material/datepicker.md b/tools/public_api_guard/material/datepicker.md index e38e5f64f5fd..4d3a353d8c48 100644 --- a/tools/public_api_guard/material/datepicker.md +++ b/tools/public_api_guard/material/datepicker.md @@ -197,15 +197,19 @@ export class MatCalendar implements AfterContentInit, AfterViewChecked, OnDes } // @public -export class MatCalendarBody implements OnChanges, OnDestroy { +export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { constructor(_elementRef: ElementRef, _ngZone: NgZone); activeCell: number; + // (undocumented) + readonly activeDateChange: EventEmitter>; cellAspectRatio: number; _cellClicked(cell: MatCalendarCell, event: MouseEvent): void; _cellPadding: string; _cellWidth: string; comparisonEnd: number | null; comparisonStart: number | null; + // (undocumented) + _emitActiveDateChange(cell: MatCalendarCell, event: FocusEvent): void; endValue: number; _firstRowOffset: number; _focusActiveCell(movePreview?: boolean): void; @@ -227,6 +231,8 @@ export class MatCalendarBody implements OnChanges, OnDestroy { label: string; labelMinRequiredCells: number; // (undocumented) + ngAfterViewChecked(): void; + // (undocumented) ngOnChanges(changes: SimpleChanges): void; // (undocumented) ngOnDestroy(): void; @@ -235,11 +241,12 @@ export class MatCalendarBody implements OnChanges, OnDestroy { previewEnd: number | null; previewStart: number | null; rows: MatCalendarCell[][]; + _scheduleFocusActiveCellAfterViewChecked(): void; readonly selectedValueChange: EventEmitter>; startValue: number; todayValue: number; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } @@ -765,6 +772,7 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { _dateSelected(event: MatCalendarUserEvent): void; _firstWeekOffset: number; _focusActiveCell(movePreview?: boolean): void; + _focusActiveCellAfterViewChecked(): void; _handleCalendarBodyKeydown(event: KeyboardEvent): void; _handleCalendarBodyKeyup(event: KeyboardEvent): void; _init(): void; @@ -790,6 +798,7 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy { set selected(value: DateRange | D | null); readonly selectedChange: EventEmitter; _todayDate: number | null; + _updateActiveDate(event: MatCalendarUserEvent): void; readonly _userSelection: EventEmitter>; _weekdays: { long: string; @@ -813,6 +822,7 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { dateClass: MatCalendarCellClassFunction; dateFilter: (date: D) => boolean; _focusActiveCell(): void; + _focusActiveCellAfterViewChecked(): void; // (undocumented) _getActiveCell(): number; _handleCalendarBodyKeydown(event: KeyboardEvent): void; @@ -832,6 +842,7 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy { readonly selectedChange: EventEmitter; _selectedYear: number | null; _todayYear: number; + _updateActiveDate(event: MatCalendarUserEvent): void; _years: MatCalendarCell[][]; readonly yearSelected: EventEmitter; _yearSelected(event: MatCalendarUserEvent): void; @@ -900,6 +911,7 @@ export class MatYearView implements AfterContentInit, OnDestroy { dateClass: MatCalendarCellClassFunction; dateFilter: (date: D) => boolean; _focusActiveCell(): void; + _focusActiveCellAfterViewChecked(): void; _handleCalendarBodyKeydown(event: KeyboardEvent): void; _handleCalendarBodyKeyup(event: KeyboardEvent): void; _init(): void; @@ -920,6 +932,7 @@ export class MatYearView implements AfterContentInit, OnDestroy { readonly selectedChange: EventEmitter; _selectedMonth: number | null; _todayMonth: number | null; + _updateActiveDate(event: MatCalendarUserEvent): void; _yearLabel: string; // (undocumented) static ɵcmp: i0.ɵɵComponentDeclaration, "mat-year-view", ["matYearView"], { "activeDate": "activeDate"; "selected": "selected"; "minDate": "minDate"; "maxDate": "maxDate"; "dateFilter": "dateFilter"; "dateClass": "dateClass"; }, { "selectedChange": "selectedChange"; "monthSelected": "monthSelected"; "activeDateChange": "activeDateChange"; }, never, never>;