sass-references/angular-material/material/datepicker/year-view.ts

445 lines
14 KiB
TypeScript
Raw Permalink Normal View History

2024-12-06 10:42:08 +08:00
/**
* @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);
}
}
}