diff --git a/src/material/datepicker/calendar-body.html b/src/material/datepicker/calendar-body.html
index 1b0f5f0b1a4a..c74441011172 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)="_cellFocused(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 {
}
}
+ _cellFocused(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..cfcd7103e479 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)="_handleCalendarBodyDateFocused($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..eaca04d627d2 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,19 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy {
this._changeDetectorRef.markForCheck();
}
+ /** Handles focus events on a cell in the calendar body. */
+ _handleCalendarBodyDateFocused(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);
+
+ 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
@@ -327,9 +338,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 +388,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 +415,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..fde27aa1d10b 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)="_handleCalendarBodyDateFocused($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..4e190090cf7b 100644
--- a/src/material/datepicker/multi-year-view.ts
+++ b/src/material/datepicker/multi-year-view.ts
@@ -204,18 +204,23 @@ 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);
+ }
+
+ /** Handles focus events on a cell in the calendar body. */
+ _handleCalendarBodyDateFocused(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);
+ this._focusActiveCellAfterViewChecked();
+ }
}
/** Handles keydown events on the calendar body when calendar is in multi-year view. */
@@ -278,7 +283,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 +308,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..f68fa51c7bfd 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)="_handleCalendarBodyDateFocused($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..f52a623dc332 100644
--- a/src/material/datepicker/year-view.ts
+++ b/src/material/datepicker/year-view.ts
@@ -179,23 +179,29 @@ 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);
+ /** Handles when a new month becomes active. */
+ _handleCalendarBodyDateFocused(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);
+ this._focusActiveCellAfterViewChecked();
+ }
}
/** Handles keydown events on the calendar body when calendar is in year view. */
@@ -259,9 +265,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 +304,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 +319,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 c8fb85e4fca7..f186e0f2cde9 100644
--- a/tools/public_api_guard/material/datepicker.md
+++ b/tools/public_api_guard/material/datepicker.md
@@ -197,11 +197,15 @@ 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;
+ // (undocumented)
+ _cellFocused(cell: MatCalendarCell, event: FocusEvent): void;
_cellPadding: string;
_cellWidth: string;
comparisonEnd: number | null;
@@ -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;
}
@@ -764,6 +771,8 @@ export class MatMonthView implements AfterContentInit, OnChanges, OnDestroy {
_dateSelected(event: MatCalendarUserEvent): void;
_firstWeekOffset: number;
_focusActiveCell(movePreview?: boolean): void;
+ _focusActiveCellAfterViewChecked(): void;
+ _handleCalendarBodyDateFocused(event: MatCalendarUserEvent): void;
_handleCalendarBodyKeydown(event: KeyboardEvent): void;
_handleCalendarBodyKeyup(event: KeyboardEvent): void;
_init(): void;
@@ -812,8 +821,10 @@ export class MatMultiYearView implements AfterContentInit, OnDestroy {
dateClass: MatCalendarCellClassFunction;
dateFilter: (date: D) => boolean;
_focusActiveCell(): void;
+ _focusActiveCellAfterViewChecked(): void;
// (undocumented)
_getActiveCell(): number;
+ _handleCalendarBodyDateFocused(event: MatCalendarUserEvent): void;
_handleCalendarBodyKeydown(event: KeyboardEvent): void;
_handleCalendarBodyKeyup(event: KeyboardEvent): void;
_init(): void;
@@ -899,6 +910,8 @@ export class MatYearView implements AfterContentInit, OnDestroy {
dateClass: MatCalendarCellClassFunction;
dateFilter: (date: D) => boolean;
_focusActiveCell(): void;
+ _focusActiveCellAfterViewChecked(): void;
+ _handleCalendarBodyDateFocused(event: MatCalendarUserEvent): void;
_handleCalendarBodyKeydown(event: KeyboardEvent): void;
_handleCalendarBodyKeyup(event: KeyboardEvent): void;
_init(): void;