/
githubmirror
/
components
Обзор
Документация
Войти
/
githubmirror
/
components
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
src/material/datepicker/month-view.ts
624 строки
21 KB
Kristiyan Kostadinov
refactor(material/datepicker): remove explicit change detection
09 май 2026, 06:37
09 май 2026, 06:37
69ebfa4
Код
Авторство
О чём код?
/** * @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, ESCAPE, hasModifierKey, } from '@angular/cdk/keycodes'; import { AfterContentInit, ChangeDetectorRef, Component, EventEmitter, Input, Output, ViewEncapsulation, ViewChild, OnDestroy, SimpleChanges, OnChanges, inject, signal, } from '@angular/core'; import {DateAdapter, MAT_DATE_FORMATS, MatDateFormats} from '../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'; import { MatDateRangeSelectionStrategy, MAT_DATE_RANGE_SELECTION_STRATEGY, } from './date-range-selection-strategy'; import {_CdkPrivateStyleLoader, _VisuallyHiddenLoader} from '@angular/cdk/private'; const DAYS_PER_WEEK = 7; let uniqueIdCounter = 0; /** * An internal component used to display a single month in the datepicker. * @docs-private */ @Component({ selector: 'mat-month-view', templateUrl: 'month-view.html', exportAs: 'matMonthView', encapsulation: ViewEncapsulation.None, imports: [MatCalendarBody], }) export class MatMonthView<D> implements AfterContentInit, OnChanges, 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 _rangeStrategy = inject<MatDateRangeSelectionStrategy<D>>( MAT_DATE_RANGE_SELECTION_STRATEGY, {optional: true}, ); private _rerenderSubscription = Subscription.EMPTY; /** Flag used to filter out space/enter keyup events that originated outside of the view. */ private _selectionKeyPressed: boolean = false; /** * The date to display in this month view (everything other than the month and year is ignored). */ @Input() get activeDate(): D { return this._activeDate; } set activeDate(value: D) { const 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._hasSameMonthAndYear(oldActiveDate, 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._setRanges(this._selected); } private _selected: DateRange<D> | D | null = 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 = 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 = null; /** Function used to filter which dates are selectable. */ @Input() dateFilter: ((date: D) => boolean) | null | undefined; /** Function that can be used to add custom CSS classes to dates. */ @Input() dateClass!: MatCalendarCellClassFunction<D>; /** Start of the comparison range. */ @Input() comparisonStart: D | null = null; /** End of the comparison range. */ @Input() comparisonEnd: D | null = null; /** ARIA Accessible name of the `<input matStartDate/>` */ @Input() startDateAccessibleName: string | null = null; /** ARIA Accessible name of the `<input matEndDate/>` */ @Input() endDateAccessibleName: string | null = null; /** Origin of active drag, or null when dragging is not active. */ @Input() activeDrag: MatCalendarUserEvent<D> | null = null; /** Emits when a new date is selected. */ @Output() readonly selectedChange: EventEmitter<D | null> = new EventEmitter<D | null>(); /** Emits when any date is selected. */ @Output() readonly _userSelection: EventEmitter<MatCalendarUserEvent<D | null>> = new EventEmitter<MatCalendarUserEvent<D | null>>(); /** Emits when the user initiates a date range drag via mouse or touch. */ @Output() readonly dragStarted = new EventEmitter<MatCalendarUserEvent<D>>(); /** * Emits when the user completes or cancels a date range drag. * Emits null when the drag was canceled or the newly selected date range if completed. */ @Output() readonly dragEnded = new EventEmitter<MatCalendarUserEvent<DateRange<D> | null>>(); /** Emits when any date is activated. */ @Output() readonly activeDateChange: EventEmitter<D> = new EventEmitter<D>(); /** The body of calendar table */ @ViewChild(MatCalendarBody) _matCalendarBody!: MatCalendarBody; /** The label for this month (e.g. "January 2017"). */ _monthLabel = signal(''); /** Grid of calendar cells representing the dates of the month. */ _weeks = signal<MatCalendarCell[][]>([]); /** The number of blank cells in the first row before the 1st of the month. */ _firstWeekOffset = signal(0); /** Start value of the currently-shown date range. */ _rangeStart = signal<number | null>(null); /** End value of the currently-shown date range. */ _rangeEnd = signal<number | null>(null); /** Start value of the currently-shown comparison date range. */ _comparisonRangeStart = signal<number | null>(null); /** End value of the currently-shown comparison date range. */ _comparisonRangeEnd = signal<number | null>(null); /** Start of the preview range. */ _previewStart = signal<number | null>(null); /** End of the preview range. */ _previewEnd = signal<number | null>(null); /** Whether the user is currently selecting a range of dates. */ _isRange = signal(false); /** The date of the month that today falls on. Null if today is in another month. */ _todayDate = signal<number | null>(null); /** The names of the weekdays. */ _weekdays = signal<{long: string; narrow: string; id: number}[]>([]); constructor() { inject(_CdkPrivateStyleLoader).load(_VisuallyHiddenLoader); 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()); } ngOnChanges(changes: SimpleChanges<this>) { const comparisonChange = changes['comparisonStart'] || changes['comparisonEnd']; if (comparisonChange && !comparisonChange.firstChange) { this._setRanges(this.selected); } if (changes['activeDrag'] && !this.activeDrag) { this._clearPreview(); } } ngOnDestroy() { this._rerenderSubscription.unsubscribe(); } /** Handles when a new date is selected. */ _dateSelected(event: MatCalendarUserEvent<number>) { const date = event.value; const selectedDate = this._getDateFromDayOfMonth(date); let rangeStartDate: number | null; let rangeEndDate: number | null; if (this._selected instanceof DateRange) { rangeStartDate = this._getDateInCurrentMonth(this._selected.start); rangeEndDate = this._getDateInCurrentMonth(this._selected.end); } else { rangeStartDate = rangeEndDate = this._getDateInCurrentMonth(this._selected); } if (rangeStartDate !== date || rangeEndDate !== date) { this.selectedChange.emit(selectedDate); } this._userSelection.emit({value: selectedDate, event: event.event}); this._clearPreview(); this._changeDetectorRef.markForCheck(); } /** * 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._getDateFromDayOfMonth(month); if (this._dateAdapter.compareDate(oldActiveDate, this.activeDate)) { this.activeDateChange.emit(this._activeDate); } } /** Handles keydown events on the calendar body when calendar is in month 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.addCalendarDays(this._activeDate, isRtl ? 1 : -1); break; case RIGHT_ARROW: this.activeDate = this._dateAdapter.addCalendarDays(this._activeDate, isRtl ? -1 : 1); break; case UP_ARROW: this.activeDate = this._dateAdapter.addCalendarDays(this._activeDate, -7); break; case DOWN_ARROW: this.activeDate = this._dateAdapter.addCalendarDays(this._activeDate, 7); break; case HOME: this.activeDate = this._dateAdapter.addCalendarDays( this._activeDate, 1 - this._dateAdapter.getDate(this._activeDate), ); break; case END: this.activeDate = this._dateAdapter.addCalendarDays( this._activeDate, this._dateAdapter.getNumDaysInMonth(this._activeDate) - this._dateAdapter.getDate(this._activeDate), ); break; case PAGE_UP: this.activeDate = event.altKey ? this._dateAdapter.addCalendarYears(this._activeDate, -1) : this._dateAdapter.addCalendarMonths(this._activeDate, -1); break; case PAGE_DOWN: this.activeDate = event.altKey ? this._dateAdapter.addCalendarYears(this._activeDate, 1) : this._dateAdapter.addCalendarMonths(this._activeDate, 1); break; case ENTER: case SPACE: this._selectionKeyPressed = true; if (this._canSelect(this._activeDate)) { // Prevent unexpected default actions such as form submission. // 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). event.preventDefault(); } return; case ESCAPE: // Abort the current range selection if the user presses escape mid-selection. if (this._previewEnd() != null && !hasModifierKey(event)) { this._clearPreview(); // If a drag is in progress, cancel the drag without changing the // current selection. if (this.activeDrag) { this.dragEnded.emit({value: null, event}); } else { this.selectedChange.emit(null); this._userSelection.emit({value: null, event}); } event.preventDefault(); event.stopPropagation(); // Prevents the overlay from closing. } return; 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 month view. */ _handleCalendarBodyKeyup(event: KeyboardEvent): void { if (event.keyCode === SPACE || event.keyCode === ENTER) { if (this._selectionKeyPressed && this._canSelect(this._activeDate)) { this._dateSelected({value: this._dateAdapter.getDate(this._activeDate), event}); } this._selectionKeyPressed = false; } } /** Initializes this month view. */ _init() { this._setRanges(this.selected); this._todayDate.set(this._getCellCompareValue(this._dateAdapter.today())); this._monthLabel.set( this._dateFormats.display.monthLabel ? this._dateAdapter.format(this.activeDate, this._dateFormats.display.monthLabel) : this._dateAdapter .getMonthNames('short') [this._dateAdapter.getMonth(this.activeDate)].toLocaleUpperCase(), ); let firstOfMonth = this._dateAdapter.createDate( this._dateAdapter.getYear(this.activeDate), this._dateAdapter.getMonth(this.activeDate), 1, ); this._firstWeekOffset.set( (DAYS_PER_WEEK + this._dateAdapter.getDayOfWeek(firstOfMonth) - this._dateAdapter.getFirstDayOfWeek()) % DAYS_PER_WEEK, ); this._initWeekdays(); this._createWeekCells(); this._changeDetectorRef.markForCheck(); } /** Focuses the active cell after the microtask queue is empty. */ _focusActiveCell(movePreview?: boolean) { this._matCalendarBody._focusActiveCell(movePreview); } /** Focuses the active cell after change detection has run and the microtask queue is empty. */ _focusActiveCellAfterViewChecked() { this._matCalendarBody._scheduleFocusActiveCellAfterViewChecked(); } /** Called when the user has activated a new cell and the preview needs to be updated. */ _previewChanged({event, value: cell}: MatCalendarUserEvent<MatCalendarCell<D> | null>) { if (this._rangeStrategy) { // We can assume that this will be a range, because preview // events aren't fired for single date selections. const value = cell ? cell.rawValue! : null; const previewRange = this._rangeStrategy.createPreview( value, this.selected as DateRange<D>, event, ); this._previewStart.set(this._getCellCompareValue(previewRange.start)); this._previewEnd.set(this._getCellCompareValue(previewRange.end)); if (this.activeDrag && value) { const dragRange = this._rangeStrategy.createDrag?.( this.activeDrag.value, this.selected as DateRange<D>, value, event, ); if (dragRange) { this._previewStart.set(this._getCellCompareValue(dragRange.start)); this._previewEnd.set(this._getCellCompareValue(dragRange.end)); } } } } /** * Called when the user has ended a drag. If the drag/drop was successful, * computes and emits the new range selection. */ protected _dragEnded(event: MatCalendarUserEvent<D | null>) { if (!this.activeDrag) return; if (event.value) { // Propagate drag effect const dragDropResult = this._rangeStrategy?.createDrag?.( this.activeDrag.value, this.selected as DateRange<D>, event.value, event.event, ); this.dragEnded.emit({value: dragDropResult ?? null, event: event.event}); } else { this.dragEnded.emit({value: null, event: event.event}); } } /** * Takes a day of the month and returns a new date in the same month and year as the currently * active date. The returned date will have the same day of the month as the argument date. */ private _getDateFromDayOfMonth(dayOfMonth: number): D { return this._dateAdapter.createDate( this._dateAdapter.getYear(this.activeDate), this._dateAdapter.getMonth(this.activeDate), dayOfMonth, ); } /** Initializes the weekdays. */ private _initWeekdays() { const firstDayOfWeek = this._dateAdapter.getFirstDayOfWeek(); const narrowWeekdays = this._dateAdapter.getDayOfWeekNames('narrow'); const longWeekdays = this._dateAdapter.getDayOfWeekNames('long'); // Rotate the labels for days of the week based on the configured first day of the week. const weekdays = longWeekdays.map((long, i) => { return {long, narrow: narrowWeekdays[i], id: uniqueIdCounter++}; }); this._weekdays.set(weekdays.slice(firstDayOfWeek).concat(weekdays.slice(0, firstDayOfWeek))); } /** Creates MatCalendarCells for the dates in this month. */ private _createWeekCells() { const daysInMonth = this._dateAdapter.getNumDaysInMonth(this.activeDate); const dateNames = this._dateAdapter.getDateNames(); const weeks: MatCalendarCell[][] = [[]]; for (let i = 0, cell = this._firstWeekOffset(); i < daysInMonth; i++, cell++) { if (cell == DAYS_PER_WEEK) { weeks.push([]); cell = 0; } const date = this._dateAdapter.createDate( this._dateAdapter.getYear(this.activeDate), this._dateAdapter.getMonth(this.activeDate), i + 1, ); const enabled = this._shouldEnableDate(date); const ariaLabel = this._dateAdapter.format(date, this._dateFormats.display.dateA11yLabel); const cellClasses = this.dateClass ? this.dateClass(date, 'month') : undefined; weeks[weeks.length - 1].push( new MatCalendarCell<D>( i + 1, dateNames[i], ariaLabel, enabled, cellClasses, this._getCellCompareValue(date)!, date, ), ); } this._weeks.set(weeks); } /** Date filter for the month */ private _shouldEnableDate(date: D): boolean { return ( !!date && (!this.minDate || this._dateAdapter.compareDate(date, this.minDate) >= 0) && (!this.maxDate || this._dateAdapter.compareDate(date, this.maxDate) <= 0) && (!this.dateFilter || this.dateFilter(date)) ); } /** * Gets the date in this month that the given Date falls on. * Returns null if the given Date is in another month. */ private _getDateInCurrentMonth(date: D | null): number | null { return date && this._hasSameMonthAndYear(date, this.activeDate) ? this._dateAdapter.getDate(date) : null; } /** Checks whether the 2 dates are non-null and fall within the same month of the same year. */ private _hasSameMonthAndYear(d1: D | null, d2: D | null): boolean { return !!( d1 && d2 && this._dateAdapter.getMonth(d1) == this._dateAdapter.getMonth(d2) && this._dateAdapter.getYear(d1) == this._dateAdapter.getYear(d2) ); } /** Gets the value that will be used to one cell to another. */ private _getCellCompareValue(date: D | null): number | null { if (date) { // We use the time since the Unix epoch to compare dates in this view, rather than the // cell values, because we need to support ranges that span across multiple months/years. const year = this._dateAdapter.getYear(date); const month = this._dateAdapter.getMonth(date); const day = this._dateAdapter.getDate(date); return new Date(year, month, day).getTime(); } return null; } /** Determines whether the user has the RTL layout direction. */ private _isRtl() { return this._dir && this._dir.value === 'rtl'; } /** Sets the current range based on a model value. */ private _setRanges(selectedValue: DateRange<D> | D | null) { if (selectedValue instanceof DateRange) { this._rangeStart.set(this._getCellCompareValue(selectedValue.start)); this._rangeEnd.set(this._getCellCompareValue(selectedValue.end)); this._isRange.set(true); } else { this._rangeStart.set(this._getCellCompareValue(selectedValue)); this._rangeEnd.set(this._rangeStart()); this._isRange.set(false); } this._comparisonRangeStart.set(this._getCellCompareValue(this.comparisonStart)); this._comparisonRangeEnd.set(this._getCellCompareValue(this.comparisonEnd)); } /** Gets whether a date can be selected in the month view. */ private _canSelect(date: D) { return !this.dateFilter || this.dateFilter(date); } /** Clears out preview state. */ private _clearPreview() { this._previewStart.set(null); this._previewEnd.set(null); } }