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

460 lines
14 KiB
TypeScript
Raw 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} 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';
export const yearsPerPage = 24;
export const yearsPerRow = 4;
/**
* An internal component used to display a year selector in the datepicker.
* @docs-private
*/
@Component({
selector: 'mat-multi-year-view',
templateUrl: 'multi-year-view.html',
exportAs: 'matMultiYearView',
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatCalendarBody],
})
export class MatMultiYearView<D> implements AfterContentInit, OnDestroy {
private _changeDetectorRef = inject(ChangeDetectorRef);
_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 multi-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 (
!isSameMultiYearView(
this._dateAdapter,
oldActiveDate,
this._activeDate,
this.minDate,
this.maxDate,
)
) {
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._setSelectedYear(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 year is selected. */
@Output() readonly selectedChange: EventEmitter<D> = new EventEmitter<D>();
/** Emits the selected year. This doesn't imply a change on the selected date */
@Output() readonly yearSelected: 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 currently displayed years. */
_years: MatCalendarCell[][];
/** The year that today falls on. */
_todayYear: number;
/** The year of the selected date. Null if the selected date is null. */
_selectedYear: number | null;
constructor(...args: unknown[]);
constructor() {
if (!this._dateAdapter && (typeof ngDevMode === 'undefined' || ngDevMode)) {
throw createMissingDateImplError('DateAdapter');
}
this._activeDate = this._dateAdapter.today();
}
ngAfterContentInit() {
this._rerenderSubscription = this._dateAdapter.localeChanges
.pipe(startWith(null))
.subscribe(() => this._init());
}
ngOnDestroy() {
this._rerenderSubscription.unsubscribe();
}
/** Initializes this multi-year view. */
_init() {
this._todayYear = this._dateAdapter.getYear(this._dateAdapter.today());
// We want a range years such that we maximize the number of
// enabled dates visible at once. This prevents issues where the minimum year
// is the last item of a page OR the maximum year is the first item of a page.
// The offset from the active year to the "slot" for the starting year is the
// *actual* first rendered year in the multi-year view.
const activeYear = this._dateAdapter.getYear(this._activeDate);
const minYearOfPage =
activeYear - getActiveOffset(this._dateAdapter, this.activeDate, this.minDate, this.maxDate);
this._years = [];
for (let i = 0, row: number[] = []; i < yearsPerPage; i++) {
row.push(minYearOfPage + i);
if (row.length == yearsPerRow) {
this._years.push(row.map(year => this._createCellForYear(year)));
row = [];
}
}
this._changeDetectorRef.markForCheck();
}
/** Handles when a new year is selected. */
_yearSelected(event: MatCalendarUserEvent<number>) {
const year = event.value;
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 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 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. */
_handleCalendarBodyKeydown(event: KeyboardEvent): void {
const oldActiveDate = this._activeDate;
const isRtl = this._isRtl();
switch (event.keyCode) {
case LEFT_ARROW:
this.activeDate = this._dateAdapter.addCalendarYears(this._activeDate, isRtl ? 1 : -1);
break;
case RIGHT_ARROW:
this.activeDate = this._dateAdapter.addCalendarYears(this._activeDate, isRtl ? -1 : 1);
break;
case UP_ARROW:
this.activeDate = this._dateAdapter.addCalendarYears(this._activeDate, -yearsPerRow);
break;
case DOWN_ARROW:
this.activeDate = this._dateAdapter.addCalendarYears(this._activeDate, yearsPerRow);
break;
case HOME:
this.activeDate = this._dateAdapter.addCalendarYears(
this._activeDate,
-getActiveOffset(this._dateAdapter, this.activeDate, this.minDate, this.maxDate),
);
break;
case END:
this.activeDate = this._dateAdapter.addCalendarYears(
this._activeDate,
yearsPerPage -
getActiveOffset(this._dateAdapter, this.activeDate, this.minDate, this.maxDate) -
1,
);
break;
case PAGE_UP:
this.activeDate = this._dateAdapter.addCalendarYears(
this._activeDate,
event.altKey ? -yearsPerPage * 10 : -yearsPerPage,
);
break;
case PAGE_DOWN:
this.activeDate = this._dateAdapter.addCalendarYears(
this._activeDate,
event.altKey ? yearsPerPage * 10 : yearsPerPage,
);
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 multi-year view. */
_handleCalendarBodyKeyup(event: KeyboardEvent): void {
if (event.keyCode === SPACE || event.keyCode === ENTER) {
if (this._selectionKeyPressed) {
this._yearSelected({value: this._dateAdapter.getYear(this._activeDate), event});
}
this._selectionKeyPressed = false;
}
}
_getActiveCell(): number {
return getActiveOffset(this._dateAdapter, this.activeDate, this.minDate, this.maxDate);
}
/** Focuses the active cell after the microtask queue is empty. */
_focusActiveCell() {
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);
const yearName = this._dateAdapter.getYearName(date);
const cellClasses = this.dateClass ? this.dateClass(date, 'multi-year') : undefined;
return new MatCalendarCell(year, yearName, yearName, this._shouldEnableYear(year), cellClasses);
}
/** Whether the given year is enabled. */
private _shouldEnableYear(year: number) {
// disable if the year is greater than maxDate lower than minDate
if (
year === undefined ||
year === null ||
(this.maxDate && year > this._dateAdapter.getYear(this.maxDate)) ||
(this.minDate && year < this._dateAdapter.getYear(this.minDate))
) {
return false;
}
// enable if it reaches here and there's no filter defined
if (!this.dateFilter) {
return true;
}
const firstOfYear = this._dateAdapter.createDate(year, 0, 1);
// If any date in the year is enabled count the year as enabled.
for (
let date = firstOfYear;
this._dateAdapter.getYear(date) == year;
date = this._dateAdapter.addCalendarDays(date, 1)
) {
if (this.dateFilter(date)) {
return true;
}
}
return false;
}
/** Determines whether the user has the RTL layout direction. */
private _isRtl() {
return this._dir && this._dir.value === 'rtl';
}
/** Sets the currently-highlighted year based on a model value. */
private _setSelectedYear(value: DateRange<D> | D | null) {
this._selectedYear = null;
if (value instanceof DateRange) {
const displayValue = value.start || value.end;
if (displayValue) {
this._selectedYear = this._dateAdapter.getYear(displayValue);
}
} else if (value) {
this._selectedYear = this._dateAdapter.getYear(value);
}
}
}
export function isSameMultiYearView<D>(
dateAdapter: DateAdapter<D>,
date1: D,
date2: D,
minDate: D | null,
maxDate: D | null,
): boolean {
const year1 = dateAdapter.getYear(date1);
const year2 = dateAdapter.getYear(date2);
const startingYear = getStartingYear(dateAdapter, minDate, maxDate);
return (
Math.floor((year1 - startingYear) / yearsPerPage) ===
Math.floor((year2 - startingYear) / yearsPerPage)
);
}
/**
* When the multi-year view is first opened, the active year will be in view.
* So we compute how many years are between the active year and the *slot* where our
* "startingYear" will render when paged into view.
*/
export function getActiveOffset<D>(
dateAdapter: DateAdapter<D>,
activeDate: D,
minDate: D | null,
maxDate: D | null,
): number {
const activeYear = dateAdapter.getYear(activeDate);
return euclideanModulo(activeYear - getStartingYear(dateAdapter, minDate, maxDate), yearsPerPage);
}
/**
* We pick a "starting" year such that either the maximum year would be at the end
* or the minimum year would be at the beginning of a page.
*/
function getStartingYear<D>(
dateAdapter: DateAdapter<D>,
minDate: D | null,
maxDate: D | null,
): number {
let startingYear = 0;
if (maxDate) {
const maxYear = dateAdapter.getYear(maxDate);
startingYear = maxYear - yearsPerPage + 1;
} else if (minDate) {
startingYear = dateAdapter.getYear(minDate);
}
return startingYear;
}
/** Gets remainder that is non-negative, even if first number is negative */
function euclideanModulo(a: number, b: number): number {
return ((a % b) + b) % b;
}