460 lines
14 KiB
TypeScript
460 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} 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;
|
|
}
|