/** * @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 {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewEncapsulation, NgZone, OnChanges, SimpleChanges, OnDestroy, AfterViewChecked, inject, afterNextRender, Injector, } from '@angular/core'; import {_IdGenerator} from '@angular/cdk/a11y'; import {NgClass} from '@angular/common'; import {_CdkPrivateStyleLoader} from '@angular/cdk/private'; import {_StructuralStylesLoader} from '@angular/material/core'; import {MatDatepickerIntl} from './datepicker-intl'; /** Extra CSS classes that can be associated with a calendar cell. */ export type MatCalendarCellCssClasses = string | string[] | Set | {[key: string]: any}; /** Function that can generate the extra classes that should be added to a calendar cell. */ export type MatCalendarCellClassFunction = ( date: D, view: 'month' | 'year' | 'multi-year', ) => MatCalendarCellCssClasses; let uniqueIdCounter = 0; /** * An internal class that represents the data corresponding to a single calendar cell. * @docs-private */ export class MatCalendarCell { readonly id = uniqueIdCounter++; constructor( public value: number, public displayValue: string, public ariaLabel: string, public enabled: boolean, public cssClasses: MatCalendarCellCssClasses = {}, public compareValue = value, public rawValue?: D, ) {} } /** Event emitted when a date inside the calendar is triggered as a result of a user action. */ export interface MatCalendarUserEvent { value: D; event: Event; } /** Event options that can be used to bind an active, capturing event. */ const activeCapturingEventOptions = normalizePassiveListenerOptions({ passive: false, capture: true, }); /** Event options that can be used to bind a passive, capturing event. */ const passiveCapturingEventOptions = normalizePassiveListenerOptions({ passive: true, capture: true, }); /** Event options that can be used to bind a passive, non-capturing event. */ const passiveEventOptions = normalizePassiveListenerOptions({passive: true}); /** * An internal component used to display calendar data in a table. * @docs-private */ @Component({ selector: '[mat-calendar-body]', templateUrl: 'calendar-body.html', styleUrl: 'calendar-body.css', host: { 'class': 'mat-calendar-body', }, exportAs: 'matCalendarBody', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgClass], }) export class MatCalendarBody implements OnChanges, OnDestroy, AfterViewChecked { private _elementRef = inject>(ElementRef); private _ngZone = inject(NgZone); private _platform = inject(Platform); private _intl = inject(MatDatepickerIntl); /** * Used to skip the next focus event when rendering the preview range. * We need a flag like this, because some browsers fire focus events asynchronously. */ private _skipNextFocus: boolean; /** * Used to focus the active cell after change detection has run. */ private _focusActiveCellAfterViewChecked = false; /** The label for the table. (e.g. "Jan 2017"). */ @Input() label: string; /** The cells to display in the table. */ @Input() rows: MatCalendarCell[][]; /** The value in the table that corresponds to today. */ @Input() todayValue: number; /** Start value of the selected date range. */ @Input() startValue: number; /** End value of the selected date range. */ @Input() endValue: number; /** The minimum number of free cells needed to fit the label in the first row. */ @Input() labelMinRequiredCells: number; /** The number of columns in the table. */ @Input() numCols: number = 7; /** The cell number of the active cell in the table. */ @Input() activeCell: number = 0; ngAfterViewChecked() { if (this._focusActiveCellAfterViewChecked) { this._focusActiveCell(); this._focusActiveCellAfterViewChecked = false; } } /** Whether a range is being selected. */ @Input() isRange: boolean = false; /** * The aspect ratio (width / height) to use for the cells in the table. This aspect ratio will be * maintained even as the table resizes. */ @Input() cellAspectRatio: number = 1; /** Start of the comparison range. */ @Input() comparisonStart: number | null; /** End of the comparison range. */ @Input() comparisonEnd: number | null; /** Start of the preview range. */ @Input() previewStart: number | null = null; /** End of the preview range. */ @Input() previewEnd: number | null = null; /** ARIA Accessible name of the `` */ @Input() startDateAccessibleName: string | null; /** ARIA Accessible name of the `` */ @Input() endDateAccessibleName: string | null; /** Emits when a new value is selected. */ @Output() readonly selectedValueChange = new EventEmitter>(); /** Emits when the preview has changed as a result of a user action. */ @Output() readonly previewChange = new EventEmitter< MatCalendarUserEvent >(); @Output() readonly activeDateChange = new EventEmitter>(); /** Emits the date at the possible start of a drag event. */ @Output() readonly dragStarted = new EventEmitter>(); /** Emits the date at the conclusion of a drag, or null if mouse was not released on a date. */ @Output() readonly dragEnded = new EventEmitter>(); /** The number of blank cells to put at the beginning for the first row. */ _firstRowOffset: number; /** Padding for the individual date cells. */ _cellPadding: string; /** Width of an individual cell. */ _cellWidth: string; /** ID for the start date label. */ _startDateLabelId: string; /** ID for the end date label. */ _endDateLabelId: string; /** ID for the comparison start date label. */ _comparisonStartDateLabelId: string; /** ID for the comparison end date label. */ _comparisonEndDateLabelId: string; private _didDragSinceMouseDown = false; private _injector = inject(Injector); comparisonDateAccessibleName = this._intl.comparisonDateLabel; /** * Tracking function for rows based on their identity. Ideally we would use some sort of * key on the row, but that would require a breaking change for the `rows` input. We don't * use the built-in identity tracking, because it logs warnings. */ _trackRow = (row: MatCalendarCell[]) => row; constructor(...args: unknown[]); constructor() { const idGenerator = inject(_IdGenerator); this._startDateLabelId = idGenerator.getId('mat-calendar-body-start-'); this._endDateLabelId = idGenerator.getId('mat-calendar-body-end-'); this._comparisonStartDateLabelId = idGenerator.getId('mat-calendar-body-comparison-start-'); this._comparisonEndDateLabelId = idGenerator.getId('mat-calendar-body-comparison-end-'); inject(_CdkPrivateStyleLoader).load(_StructuralStylesLoader); this._ngZone.runOutsideAngular(() => { const element = this._elementRef.nativeElement; // `touchmove` is active since we need to call `preventDefault`. element.addEventListener('touchmove', this._touchmoveHandler, activeCapturingEventOptions); element.addEventListener('mouseenter', this._enterHandler, passiveCapturingEventOptions); element.addEventListener('focus', this._enterHandler, passiveCapturingEventOptions); element.addEventListener('mouseleave', this._leaveHandler, passiveCapturingEventOptions); element.addEventListener('blur', this._leaveHandler, passiveCapturingEventOptions); element.addEventListener('mousedown', this._mousedownHandler, passiveEventOptions); element.addEventListener('touchstart', this._mousedownHandler, passiveEventOptions); if (this._platform.isBrowser) { window.addEventListener('mouseup', this._mouseupHandler); window.addEventListener('touchend', this._touchendHandler); } }); } /** Called when a cell is clicked. */ _cellClicked(cell: MatCalendarCell, event: MouseEvent): void { // Ignore "clicks" that are actually canceled drags (eg the user dragged // off and then went back to this cell to undo). if (this._didDragSinceMouseDown) { return; } if (cell.enabled) { this.selectedValueChange.emit({value: cell.value, event}); } } _emitActiveDateChange(cell: MatCalendarCell, event: FocusEvent): void { if (cell.enabled) { this.activeDateChange.emit({value: cell.value, event}); } } /** Returns whether a cell should be marked as selected. */ _isSelected(value: number) { return this.startValue === value || this.endValue === value; } ngOnChanges(changes: SimpleChanges) { const columnChanges = changes['numCols']; const {rows, numCols} = this; if (changes['rows'] || columnChanges) { this._firstRowOffset = rows && rows.length && rows[0].length ? numCols - rows[0].length : 0; } if (changes['cellAspectRatio'] || columnChanges || !this._cellPadding) { this._cellPadding = `${(50 * this.cellAspectRatio) / numCols}%`; } if (columnChanges || !this._cellWidth) { this._cellWidth = `${100 / numCols}%`; } } ngOnDestroy() { const element = this._elementRef.nativeElement; element.removeEventListener('touchmove', this._touchmoveHandler, activeCapturingEventOptions); element.removeEventListener('mouseenter', this._enterHandler, passiveCapturingEventOptions); element.removeEventListener('focus', this._enterHandler, passiveCapturingEventOptions); element.removeEventListener('mouseleave', this._leaveHandler, passiveCapturingEventOptions); element.removeEventListener('blur', this._leaveHandler, passiveCapturingEventOptions); element.removeEventListener('mousedown', this._mousedownHandler, passiveEventOptions); element.removeEventListener('touchstart', this._mousedownHandler, passiveEventOptions); if (this._platform.isBrowser) { window.removeEventListener('mouseup', this._mouseupHandler); window.removeEventListener('touchend', this._touchendHandler); } } /** Returns whether a cell is active. */ _isActiveCell(rowIndex: number, colIndex: number): boolean { let cellNumber = rowIndex * this.numCols + colIndex; // Account for the fact that the first row may not have as many cells. if (rowIndex) { cellNumber -= this._firstRowOffset; } return cellNumber == this.activeCell; } /** * Focuses the active cell after the microtask queue is empty. * * Adding a 0ms setTimeout seems to fix Voiceover losing focus when pressing PageUp/PageDown * (issue #24330). * * Determined a 0ms by gradually increasing duration from 0 and testing two use cases with screen * reader enabled: * * 1. Pressing PageUp/PageDown repeatedly with pausing between each key press. * 2. Pressing and holding the PageDown key with repeated keys enabled. * * Test 1 worked roughly 95-99% of the time with 0ms and got a little bit better as the duration * increased. Test 2 got slightly better until the duration was long enough to interfere with * repeated keys. If the repeated key speed was faster than the timeout duration, then pressing * and holding pagedown caused the entire page to scroll. * * Since repeated key speed can verify across machines, determined that any duration could * potentially interfere with repeated keys. 0ms would be best because it almost entirely * eliminates the focus being lost in Voiceover (#24330) without causing unintended side effects. * Adding delay also complicates writing tests. */ _focusActiveCell(movePreview = true) { afterNextRender( () => { setTimeout(() => { const activeCell: HTMLElement | null = this._elementRef.nativeElement.querySelector( '.mat-calendar-body-active', ); if (activeCell) { if (!movePreview) { this._skipNextFocus = true; } activeCell.focus(); } }); }, {injector: this._injector}, ); } /** Focuses the active cell after change detection has run and the microtask queue is empty. */ _scheduleFocusActiveCellAfterViewChecked() { this._focusActiveCellAfterViewChecked = true; } /** Gets whether a value is the start of the main range. */ _isRangeStart(value: number) { return isStart(value, this.startValue, this.endValue); } /** Gets whether a value is the end of the main range. */ _isRangeEnd(value: number) { return isEnd(value, this.startValue, this.endValue); } /** Gets whether a value is within the currently-selected range. */ _isInRange(value: number): boolean { return isInRange(value, this.startValue, this.endValue, this.isRange); } /** Gets whether a value is the start of the comparison range. */ _isComparisonStart(value: number) { return isStart(value, this.comparisonStart, this.comparisonEnd); } /** Whether the cell is a start bridge cell between the main and comparison ranges. */ _isComparisonBridgeStart(value: number, rowIndex: number, colIndex: number) { if (!this._isComparisonStart(value) || this._isRangeStart(value) || !this._isInRange(value)) { return false; } let previousCell: MatCalendarCell | undefined = this.rows[rowIndex][colIndex - 1]; if (!previousCell) { const previousRow = this.rows[rowIndex - 1]; previousCell = previousRow && previousRow[previousRow.length - 1]; } return previousCell && !this._isRangeEnd(previousCell.compareValue); } /** Whether the cell is an end bridge cell between the main and comparison ranges. */ _isComparisonBridgeEnd(value: number, rowIndex: number, colIndex: number) { if (!this._isComparisonEnd(value) || this._isRangeEnd(value) || !this._isInRange(value)) { return false; } let nextCell: MatCalendarCell | undefined = this.rows[rowIndex][colIndex + 1]; if (!nextCell) { const nextRow = this.rows[rowIndex + 1]; nextCell = nextRow && nextRow[0]; } return nextCell && !this._isRangeStart(nextCell.compareValue); } /** Gets whether a value is the end of the comparison range. */ _isComparisonEnd(value: number) { return isEnd(value, this.comparisonStart, this.comparisonEnd); } /** Gets whether a value is within the current comparison range. */ _isInComparisonRange(value: number) { return isInRange(value, this.comparisonStart, this.comparisonEnd, this.isRange); } /** * Gets whether a value is the same as the start and end of the comparison range. * For context, the functions that we use to determine whether something is the start/end of * a range don't allow for the start and end to be on the same day, because we'd have to use * much more specific CSS selectors to style them correctly in all scenarios. This is fine for * the regular range, because when it happens, the selected styles take over and still show where * the range would've been, however we don't have these selected styles for a comparison range. * This function is used to apply a class that serves the same purpose as the one for selected * dates, but it only applies in the context of a comparison range. */ _isComparisonIdentical(value: number) { // Note that we don't need to null check the start/end // here, because the `value` will always be defined. return this.comparisonStart === this.comparisonEnd && value === this.comparisonStart; } /** Gets whether a value is the start of the preview range. */ _isPreviewStart(value: number) { return isStart(value, this.previewStart, this.previewEnd); } /** Gets whether a value is the end of the preview range. */ _isPreviewEnd(value: number) { return isEnd(value, this.previewStart, this.previewEnd); } /** Gets whether a value is inside the preview range. */ _isInPreview(value: number) { return isInRange(value, this.previewStart, this.previewEnd, this.isRange); } /** Gets ids of aria descriptions for the start and end of a date range. */ _getDescribedby(value: number): string | null { if (!this.isRange) { return null; } if (this.startValue === value && this.endValue === value) { return `${this._startDateLabelId} ${this._endDateLabelId}`; } else if (this.startValue === value) { return this._startDateLabelId; } else if (this.endValue === value) { return this._endDateLabelId; } if (this.comparisonStart !== null && this.comparisonEnd !== null) { if (value === this.comparisonStart && value === this.comparisonEnd) { return `${this._comparisonStartDateLabelId} ${this._comparisonEndDateLabelId}`; } else if (value === this.comparisonStart) { return this._comparisonStartDateLabelId; } else if (value === this.comparisonEnd) { return this._comparisonEndDateLabelId; } } return null; } /** * Event handler for when the user enters an element * inside the calendar body (e.g. by hovering in or focus). */ private _enterHandler = (event: Event) => { if (this._skipNextFocus && event.type === 'focus') { this._skipNextFocus = false; return; } // We only need to hit the zone when we're selecting a range. if (event.target && this.isRange) { const cell = this._getCellFromElement(event.target as HTMLElement); if (cell) { this._ngZone.run(() => this.previewChange.emit({value: cell.enabled ? cell : null, event})); } } }; private _touchmoveHandler = (event: TouchEvent) => { if (!this.isRange) return; const target = getActualTouchTarget(event); const cell = target ? this._getCellFromElement(target as HTMLElement) : null; if (target !== event.target) { this._didDragSinceMouseDown = true; } // If the initial target of the touch is a date cell, prevent default so // that the move is not handled as a scroll. if (getCellElement(event.target as HTMLElement)) { event.preventDefault(); } this._ngZone.run(() => this.previewChange.emit({value: cell?.enabled ? cell : null, event})); }; /** * Event handler for when the user's pointer leaves an element * inside the calendar body (e.g. by hovering out or blurring). */ private _leaveHandler = (event: Event) => { // We only need to hit the zone when we're selecting a range. if (this.previewEnd !== null && this.isRange) { if (event.type !== 'blur') { this._didDragSinceMouseDown = true; } // Only reset the preview end value when leaving cells. This looks better, because // we have a gap between the cells and the rows and we don't want to remove the // range just for it to show up again when the user moves a few pixels to the side. if ( event.target && this._getCellFromElement(event.target as HTMLElement) && !( (event as MouseEvent).relatedTarget && this._getCellFromElement((event as MouseEvent).relatedTarget as HTMLElement) ) ) { this._ngZone.run(() => this.previewChange.emit({value: null, event})); } } }; /** * Triggered on mousedown or touchstart on a date cell. * Respsonsible for starting a drag sequence. */ private _mousedownHandler = (event: Event) => { if (!this.isRange) return; this._didDragSinceMouseDown = false; // Begin a drag if a cell within the current range was targeted. const cell = event.target && this._getCellFromElement(event.target as HTMLElement); if (!cell || !this._isInRange(cell.compareValue)) { return; } this._ngZone.run(() => { this.dragStarted.emit({ value: cell.rawValue, event, }); }); }; /** Triggered on mouseup anywhere. Respsonsible for ending a drag sequence. */ private _mouseupHandler = (event: Event) => { if (!this.isRange) return; const cellElement = getCellElement(event.target as HTMLElement); if (!cellElement) { // Mouseup happened outside of datepicker. Cancel drag. this._ngZone.run(() => { this.dragEnded.emit({value: null, event}); }); return; } if (cellElement.closest('.mat-calendar-body') !== this._elementRef.nativeElement) { // Mouseup happened inside a different month instance. // Allow it to handle the event. return; } this._ngZone.run(() => { const cell = this._getCellFromElement(cellElement); this.dragEnded.emit({value: cell?.rawValue ?? null, event}); }); }; /** Triggered on touchend anywhere. Respsonsible for ending a drag sequence. */ private _touchendHandler = (event: TouchEvent) => { const target = getActualTouchTarget(event); if (target) { this._mouseupHandler({target} as unknown as Event); } }; /** Finds the MatCalendarCell that corresponds to a DOM node. */ private _getCellFromElement(element: HTMLElement): MatCalendarCell | null { const cell = getCellElement(element); if (cell) { const row = cell.getAttribute('data-mat-row'); const col = cell.getAttribute('data-mat-col'); if (row && col) { return this.rows[parseInt(row)][parseInt(col)]; } } return null; } } /** Checks whether a node is a table cell element. */ function isTableCell(node: Node | undefined | null): node is HTMLTableCellElement { return node?.nodeName === 'TD'; } /** * Gets the date table cell element that is or contains the specified element. * Or returns null if element is not part of a date cell. */ function getCellElement(element: HTMLElement): HTMLElement | null { let cell: HTMLElement | undefined; if (isTableCell(element)) { cell = element; } else if (isTableCell(element.parentNode)) { cell = element.parentNode as HTMLElement; } else if (isTableCell(element.parentNode?.parentNode)) { cell = element.parentNode!.parentNode as HTMLElement; } return cell?.getAttribute('data-mat-row') != null ? cell : null; } /** Checks whether a value is the start of a range. */ function isStart(value: number, start: number | null, end: number | null): boolean { return end !== null && start !== end && value < end && value === start; } /** Checks whether a value is the end of a range. */ function isEnd(value: number, start: number | null, end: number | null): boolean { return start !== null && start !== end && value >= start && value === end; } /** Checks whether a value is inside of a range. */ function isInRange( value: number, start: number | null, end: number | null, rangeEnabled: boolean, ): boolean { return ( rangeEnabled && start !== null && end !== null && start !== end && value >= start && value <= end ); } /** * Extracts the element that actually corresponds to a touch event's location * (rather than the element that initiated the sequence of touch events). */ function getActualTouchTarget(event: TouchEvent): Element | null { const touchLocation = event.changedTouches[0]; return document.elementFromPoint(touchLocation.clientX, touchLocation.clientY); }