/** * @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 implements AfterContentInit, OnDestroy { private _changeDetectorRef = inject(ChangeDetectorRef); _dateAdapter = inject>(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 | null { return this._selected; } set selected(value: DateRange | 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 | 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; /** Emits when a new year is selected. */ @Output() readonly selectedChange: EventEmitter = new EventEmitter(); /** Emits the selected year. This doesn't imply a change on the selected date */ @Output() readonly yearSelected: EventEmitter = new EventEmitter(); /** Emits when any date is activated. */ @Output() readonly activeDateChange: EventEmitter = new EventEmitter(); /** 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) { 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) { 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 | 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( dateAdapter: DateAdapter, 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( dateAdapter: DateAdapter, 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( dateAdapter: DateAdapter, 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; }