445 lines
14 KiB
TypeScript
445 lines
14 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright Google LLC All Rights Reserved.
|
|
*
|
|
* Use of this source code is governed by an MIT-style license that can be
|
|
* found in the LICENSE file at https://angular.dev/license
|
|
*/
|
|
|
|
import {
|
|
DOWN_ARROW,
|
|
END,
|
|
ENTER,
|
|
HOME,
|
|
LEFT_ARROW,
|
|
PAGE_DOWN,
|
|
PAGE_UP,
|
|
RIGHT_ARROW,
|
|
UP_ARROW,
|
|
SPACE,
|
|
} from '@angular/cdk/keycodes';
|
|
import {
|
|
AfterContentInit,
|
|
ChangeDetectionStrategy,
|
|
ChangeDetectorRef,
|
|
Component,
|
|
EventEmitter,
|
|
Input,
|
|
Output,
|
|
ViewChild,
|
|
ViewEncapsulation,
|
|
OnDestroy,
|
|
inject,
|
|
} from '@angular/core';
|
|
import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '@angular/material/core';
|
|
import {Directionality} from '@angular/cdk/bidi';
|
|
import {
|
|
MatCalendarBody,
|
|
MatCalendarCell,
|
|
MatCalendarUserEvent,
|
|
MatCalendarCellClassFunction,
|
|
} from './calendar-body';
|
|
import {createMissingDateImplError} from './datepicker-errors';
|
|
import {Subscription} from 'rxjs';
|
|
import {startWith} from 'rxjs/operators';
|
|
import {DateRange} from './date-selection-model';
|
|
|
|
/**
|
|
* An internal component used to display a single year in the datepicker.
|
|
* @docs-private
|
|
*/
|
|
@Component({
|
|
selector: 'mat-year-view',
|
|
templateUrl: 'year-view.html',
|
|
exportAs: 'matYearView',
|
|
encapsulation: ViewEncapsulation.None,
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
imports: [MatCalendarBody],
|
|
})
|
|
export class MatYearView<D> implements AfterContentInit, OnDestroy {
|
|
readonly _changeDetectorRef = inject(ChangeDetectorRef);
|
|
private _dateFormats = inject<MatDateFormats>(MAT_DATE_FORMATS, {optional: true})!;
|
|
_dateAdapter = inject<DateAdapter<D>>(DateAdapter, {optional: true})!;
|
|
private _dir = inject(Directionality, {optional: true});
|
|
|
|
private _rerenderSubscription = Subscription.EMPTY;
|
|
|
|
/** Flag used to filter out space/enter keyup events that originated outside of the view. */
|
|
private _selectionKeyPressed: boolean;
|
|
|
|
/** The date to display in this year view (everything other than the year is ignored). */
|
|
@Input()
|
|
get activeDate(): D {
|
|
return this._activeDate;
|
|
}
|
|
set activeDate(value: D) {
|
|
let oldActiveDate = this._activeDate;
|
|
const validDate =
|
|
this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value)) ||
|
|
this._dateAdapter.today();
|
|
this._activeDate = this._dateAdapter.clampDate(validDate, this.minDate, this.maxDate);
|
|
if (this._dateAdapter.getYear(oldActiveDate) !== this._dateAdapter.getYear(this._activeDate)) {
|
|
this._init();
|
|
}
|
|
}
|
|
private _activeDate: D;
|
|
|
|
/** The currently selected date. */
|
|
@Input()
|
|
get selected(): DateRange<D> | D | null {
|
|
return this._selected;
|
|
}
|
|
set selected(value: DateRange<D> | D | null) {
|
|
if (value instanceof DateRange) {
|
|
this._selected = value;
|
|
} else {
|
|
this._selected = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value));
|
|
}
|
|
|
|
this._setSelectedMonth(value);
|
|
}
|
|
private _selected: DateRange<D> | D | null;
|
|
|
|
/** The minimum selectable date. */
|
|
@Input()
|
|
get minDate(): D | null {
|
|
return this._minDate;
|
|
}
|
|
set minDate(value: D | null) {
|
|
this._minDate = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value));
|
|
}
|
|
private _minDate: D | null;
|
|
|
|
/** The maximum selectable date. */
|
|
@Input()
|
|
get maxDate(): D | null {
|
|
return this._maxDate;
|
|
}
|
|
set maxDate(value: D | null) {
|
|
this._maxDate = this._dateAdapter.getValidDateOrNull(this._dateAdapter.deserialize(value));
|
|
}
|
|
private _maxDate: D | null;
|
|
|
|
/** A function used to filter which dates are selectable. */
|
|
@Input() dateFilter: (date: D) => boolean;
|
|
|
|
/** Function that can be used to add custom CSS classes to date cells. */
|
|
@Input() dateClass: MatCalendarCellClassFunction<D>;
|
|
|
|
/** Emits when a new month is selected. */
|
|
@Output() readonly selectedChange: EventEmitter<D> = new EventEmitter<D>();
|
|
|
|
/** Emits the selected month. This doesn't imply a change on the selected date */
|
|
@Output() readonly monthSelected: EventEmitter<D> = new EventEmitter<D>();
|
|
|
|
/** Emits when any date is activated. */
|
|
@Output() readonly activeDateChange: EventEmitter<D> = new EventEmitter<D>();
|
|
|
|
/** The body of calendar table */
|
|
@ViewChild(MatCalendarBody) _matCalendarBody: MatCalendarBody;
|
|
|
|
/** Grid of calendar cells representing the months of the year. */
|
|
_months: MatCalendarCell[][];
|
|
|
|
/** The label for this year (e.g. "2017"). */
|
|
_yearLabel: string;
|
|
|
|
/** The month in this year that today falls on. Null if today is in a different year. */
|
|
_todayMonth: number | null;
|
|
|
|
/**
|
|
* The month in this year that the selected Date falls on.
|
|
* Null if the selected Date is in a different year.
|
|
*/
|
|
_selectedMonth: number | null;
|
|
|
|
constructor(...args: unknown[]);
|
|
|
|
constructor() {
|
|
if (typeof ngDevMode === 'undefined' || ngDevMode) {
|
|
if (!this._dateAdapter) {
|
|
throw createMissingDateImplError('DateAdapter');
|
|
}
|
|
if (!this._dateFormats) {
|
|
throw createMissingDateImplError('MAT_DATE_FORMATS');
|
|
}
|
|
}
|
|
|
|
this._activeDate = this._dateAdapter.today();
|
|
}
|
|
|
|
ngAfterContentInit() {
|
|
this._rerenderSubscription = this._dateAdapter.localeChanges
|
|
.pipe(startWith(null))
|
|
.subscribe(() => this._init());
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this._rerenderSubscription.unsubscribe();
|
|
}
|
|
|
|
/** Handles when a new month is selected. */
|
|
_monthSelected(event: MatCalendarUserEvent<number>) {
|
|
const month = event.value;
|
|
|
|
const selectedMonth = this._dateAdapter.createDate(
|
|
this._dateAdapter.getYear(this.activeDate),
|
|
month,
|
|
1,
|
|
);
|
|
this.monthSelected.emit(selectedMonth);
|
|
|
|
const selectedDate = this._getDateFromMonth(month);
|
|
this.selectedChange.emit(selectedDate);
|
|
}
|
|
|
|
/**
|
|
* Takes the index of a calendar body cell wrapped 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 function 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 asynchronously 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._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. */
|
|
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
|
|
// TODO(mmalerba): We currently allow keyboard navigation to disabled dates, but just prevent
|
|
// disabled ones from being selected. This may not be ideal, we should look into whether
|
|
// navigation should skip over disabled dates, and if so, how to implement that efficiently.
|
|
|
|
const oldActiveDate = this._activeDate;
|
|
const isRtl = this._isRtl();
|
|
|
|
switch (event.keyCode) {
|
|
case LEFT_ARROW:
|
|
this.activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, isRtl ? 1 : -1);
|
|
break;
|
|
case RIGHT_ARROW:
|
|
this.activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, isRtl ? -1 : 1);
|
|
break;
|
|
case UP_ARROW:
|
|
this.activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, -4);
|
|
break;
|
|
case DOWN_ARROW:
|
|
this.activeDate = this._dateAdapter.addCalendarMonths(this._activeDate, 4);
|
|
break;
|
|
case HOME:
|
|
this.activeDate = this._dateAdapter.addCalendarMonths(
|
|
this._activeDate,
|
|
-this._dateAdapter.getMonth(this._activeDate),
|
|
);
|
|
break;
|
|
case END:
|
|
this.activeDate = this._dateAdapter.addCalendarMonths(
|
|
this._activeDate,
|
|
11 - this._dateAdapter.getMonth(this._activeDate),
|
|
);
|
|
break;
|
|
case PAGE_UP:
|
|
this.activeDate = this._dateAdapter.addCalendarYears(
|
|
this._activeDate,
|
|
event.altKey ? -10 : -1,
|
|
);
|
|
break;
|
|
case PAGE_DOWN:
|
|
this.activeDate = this._dateAdapter.addCalendarYears(
|
|
this._activeDate,
|
|
event.altKey ? 10 : 1,
|
|
);
|
|
break;
|
|
case ENTER:
|
|
case SPACE:
|
|
// Note that we only prevent the default action here while the selection happens in
|
|
// `keyup` below. We can't do the selection here, because it can cause the calendar to
|
|
// reopen if focus is restored immediately. We also can't call `preventDefault` on `keyup`
|
|
// because it's too late (see #23305).
|
|
this._selectionKeyPressed = true;
|
|
break;
|
|
default:
|
|
// Don't prevent default or focus active cell on keys that we don't explicitly handle.
|
|
return;
|
|
}
|
|
|
|
if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) {
|
|
this.activeDateChange.emit(this.activeDate);
|
|
this._focusActiveCellAfterViewChecked();
|
|
}
|
|
|
|
// Prevent unexpected default actions such as form submission.
|
|
event.preventDefault();
|
|
}
|
|
|
|
/** Handles keyup events on the calendar body when calendar is in year view. */
|
|
_handleCalendarBodyKeyup(event: KeyboardEvent): void {
|
|
if (event.keyCode === SPACE || event.keyCode === ENTER) {
|
|
if (this._selectionKeyPressed) {
|
|
this._monthSelected({value: this._dateAdapter.getMonth(this._activeDate), event});
|
|
}
|
|
|
|
this._selectionKeyPressed = false;
|
|
}
|
|
}
|
|
|
|
/** Initializes this year view. */
|
|
_init() {
|
|
this._setSelectedMonth(this.selected);
|
|
this._todayMonth = this._getMonthInCurrentYear(this._dateAdapter.today());
|
|
this._yearLabel = this._dateAdapter.getYearName(this.activeDate);
|
|
|
|
let monthNames = this._dateAdapter.getMonthNames('short');
|
|
// First row of months only contains 5 elements so we can fit the year label on the same row.
|
|
this._months = [
|
|
[0, 1, 2, 3],
|
|
[4, 5, 6, 7],
|
|
[8, 9, 10, 11],
|
|
].map(row => row.map(month => this._createCellForMonth(month, monthNames[month])));
|
|
this._changeDetectorRef.markForCheck();
|
|
}
|
|
|
|
/** Focuses the active cell after the microtask queue is empty. */
|
|
_focusActiveCell() {
|
|
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.
|
|
*/
|
|
private _getMonthInCurrentYear(date: D | null) {
|
|
return date && this._dateAdapter.getYear(date) == this._dateAdapter.getYear(this.activeDate)
|
|
? this._dateAdapter.getMonth(date)
|
|
: 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);
|
|
const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.monthYearA11yLabel);
|
|
const cellClasses = this.dateClass ? this.dateClass(date, 'year') : undefined;
|
|
|
|
return new MatCalendarCell(
|
|
month,
|
|
monthName.toLocaleUpperCase(),
|
|
ariaLabel,
|
|
this._shouldEnableMonth(month),
|
|
cellClasses,
|
|
);
|
|
}
|
|
|
|
/** Whether the given month is enabled. */
|
|
private _shouldEnableMonth(month: number) {
|
|
const activeYear = this._dateAdapter.getYear(this.activeDate);
|
|
|
|
if (
|
|
month === undefined ||
|
|
month === null ||
|
|
this._isYearAndMonthAfterMaxDate(activeYear, month) ||
|
|
this._isYearAndMonthBeforeMinDate(activeYear, month)
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
if (!this.dateFilter) {
|
|
return true;
|
|
}
|
|
|
|
const firstOfMonth = this._dateAdapter.createDate(activeYear, month, 1);
|
|
|
|
// If any date in the month is enabled count the month as enabled.
|
|
for (
|
|
let date = firstOfMonth;
|
|
this._dateAdapter.getMonth(date) == month;
|
|
date = this._dateAdapter.addCalendarDays(date, 1)
|
|
) {
|
|
if (this.dateFilter(date)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Tests whether the combination month/year is after this.maxDate, considering
|
|
* just the month and year of this.maxDate
|
|
*/
|
|
private _isYearAndMonthAfterMaxDate(year: number, month: number) {
|
|
if (this.maxDate) {
|
|
const maxYear = this._dateAdapter.getYear(this.maxDate);
|
|
const maxMonth = this._dateAdapter.getMonth(this.maxDate);
|
|
|
|
return year > maxYear || (year === maxYear && month > maxMonth);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Tests whether the combination month/year is before this.minDate, considering
|
|
* just the month and year of this.minDate
|
|
*/
|
|
private _isYearAndMonthBeforeMinDate(year: number, month: number) {
|
|
if (this.minDate) {
|
|
const minYear = this._dateAdapter.getYear(this.minDate);
|
|
const minMonth = this._dateAdapter.getMonth(this.minDate);
|
|
|
|
return year < minYear || (year === minYear && month < minMonth);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** Determines whether the user has the RTL layout direction. */
|
|
private _isRtl() {
|
|
return this._dir && this._dir.value === 'rtl';
|
|
}
|
|
|
|
/** Sets the currently-selected month based on a model value. */
|
|
private _setSelectedMonth(value: DateRange<D> | D | null) {
|
|
if (value instanceof DateRange) {
|
|
this._selectedMonth =
|
|
this._getMonthInCurrentYear(value.start) || this._getMonthInCurrentYear(value.end);
|
|
} else {
|
|
this._selectedMonth = this._getMonthInCurrentYear(value);
|
|
}
|
|
}
|
|
}
|