import { clsx } from "clsx"; import React, { Component, createRef } from "react"; import { type DateFilterOptionsWithDisabled, addYears, getStartOfYear, getYear, getYearsPeriod, isDayDisabled, isDayExcluded, isSameDay, isSameYear, isSpaceKeyDown, isYearDisabled, isYearInRange, newDate, setYear, subYears, KeyType, } from "./date_utils"; const VERTICAL_NAVIGATION_OFFSET = 3; interface YearProps extends Pick< DateFilterOptionsWithDisabled, | "minDate" | "maxDate" | "excludeDates" | "includeDates" | "filterDate" | "disabled" > { clearSelectingDate?: VoidFunction; date?: Date; disabledKeyboardNavigation?: boolean; onDayClick?: ( date: Date, event: | React.MouseEvent | React.KeyboardEvent, ) => void; preSelection?: Date | null; setPreSelection?: (date?: Date | null) => void; selectsMultiple?: boolean; selectedDates?: Date[]; selected?: Date | null; inline?: boolean; usePointerEvent?: boolean; onYearMouseEnter: ( event: React.MouseEvent, year: number, ) => void; onYearMouseLeave: ( event: React.MouseEvent, year: number, ) => void; selectingDate?: Date; renderYearContent?: (year: number) => React.ReactNode; selectsEnd?: boolean; selectsStart?: boolean; selectsRange?: boolean; startDate?: Date | null; endDate?: Date | null; yearItemNumber?: number; handleOnKeyDown?: React.KeyboardEventHandler; yearClassName?: (date: Date) => string; } /** * `Year` is a component that represents a year in a date picker. * * @class * @param {YearProps} props - The properties that define the `Year` component. * @property {VoidFunction} [props.clearSelectingDate] - Function to clear the selected date. * @property {Date} [props.date] - The currently selected date. * @property {boolean} [props.disabledKeyboardNavigation] - If true, keyboard navigation is disabled. * @property {Date} [props.endDate] - The end date in a range selection. * @property {(date: Date) => void} props.onDayClick - Function to handle day click events. * @property {Date} props.preSelection - The date that is currently in focus. * @property {(date: Date) => void} props.setPreSelection - Function to set the pre-selected date. * @property {{ [key: string]: any }} props.selected - The selected date(s). * @property {boolean} props.inline - If true, the date picker is displayed inline. * @property {Date} props.maxDate - The maximum selectable date. * @property {Date} props.minDate - The minimum selectable date. * @property {boolean} props.usePointerEvent - If true, pointer events are used instead of mouse events. * @property {(date: Date) => void} props.onYearMouseEnter - Function to handle mouse enter events on a year. * @property {(date: Date) => void} props.onYearMouseLeave - Function to handle mouse leave events on a year. */ export default class Year extends Component { constructor(props: YearProps) { super(props); } YEAR_REFS = [...Array(this.props.yearItemNumber)].map(() => createRef(), ); isDisabled = (date: Date) => isDayDisabled(date, { minDate: this.props.minDate, maxDate: this.props.maxDate, excludeDates: this.props.excludeDates, includeDates: this.props.includeDates, filterDate: this.props.filterDate, }); isExcluded = (date: Date) => isDayExcluded(date, { excludeDates: this.props.excludeDates, }); selectingDate = () => this.props.selectingDate ?? this.props.preSelection; updateFocusOnPaginate = (refIndex: number) => { const waitForReRender = () => { this.YEAR_REFS[refIndex]?.current?.focus(); }; window.requestAnimationFrame(waitForReRender); }; handleYearClick = ( day: Date, event: | React.MouseEvent | React.KeyboardEvent, ) => { if (this.props.onDayClick) { this.props.onDayClick(day, event); } }; handleYearNavigation = (newYear: number, newDate: Date) => { const { date, yearItemNumber } = this.props; if (date === undefined || yearItemNumber === undefined) { return; } const { startPeriod } = getYearsPeriod(date, yearItemNumber); if (this.isDisabled(newDate) || this.isExcluded(newDate)) { return; } this.props.setPreSelection?.(newDate); if (newYear - startPeriod < 0) { this.updateFocusOnPaginate(yearItemNumber - (startPeriod - newYear)); } else if (newYear - startPeriod >= yearItemNumber) { this.updateFocusOnPaginate( Math.abs(yearItemNumber - (newYear - startPeriod)), ); } else this.YEAR_REFS[newYear - startPeriod]?.current?.focus(); }; isSameDay = (y: Date, other: Date) => isSameDay(y, other); isCurrentYear = (y: number) => y === getYear(newDate()); isRangeStart = (y: number) => this.props.startDate && this.props.endDate && isSameYear(setYear(newDate(), y), this.props.startDate); isRangeEnd = (y: number) => this.props.startDate && this.props.endDate && isSameYear(setYear(newDate(), y), this.props.endDate); isInRange = (y: number) => isYearInRange(y, this.props.startDate, this.props.endDate); isInSelectingRange = (y: number) => { const { selectsStart, selectsEnd, selectsRange, startDate, endDate } = this.props; if ( !(selectsStart || selectsEnd || selectsRange) || !this.selectingDate() ) { return false; } if (selectsStart && endDate) { return isYearInRange(y, this.selectingDate(), endDate); } if (selectsEnd && startDate) { return isYearInRange(y, startDate, this.selectingDate()); } if (selectsRange && startDate && !endDate) { return isYearInRange(y, startDate, this.selectingDate()); } return false; }; isSelectingRangeStart = (y: number) => { if (!this.isInSelectingRange(y)) { return false; } const { startDate, selectsStart } = this.props; const _year = setYear(newDate(), y); if (selectsStart) { return isSameYear(_year, this.selectingDate() ?? null); } return isSameYear(_year, startDate ?? null); }; isSelectingRangeEnd = (y: number) => { if (!this.isInSelectingRange(y)) { return false; } const { endDate, selectsEnd, selectsRange } = this.props; const _year = setYear(newDate(), y); if (selectsEnd || selectsRange) { return isSameYear(_year, this.selectingDate() ?? null); } return isSameYear(_year, endDate ?? null); }; isKeyboardSelected = (y: number) => { if ( this.props.disabledKeyboardNavigation || this.props.date === undefined || this.props.preSelection == null ) { return; } const { minDate, maxDate, excludeDates, includeDates, filterDate, selected, } = this.props; const date = getStartOfYear(setYear(this.props.date, y)); const isDisabled = (minDate || maxDate || excludeDates || includeDates || filterDate) && isYearDisabled(y, this.props); const isSelectedDay = !!selected && isSameDay(date, getStartOfYear(selected)); const isKeyboardSelectedDay = isSameDay( date, getStartOfYear(this.props.preSelection), ); return ( !this.props.inline && !isSelectedDay && isKeyboardSelectedDay && !isDisabled ); }; isSelectedYear = (year: number) => { const { selectsMultiple, selected, selectedDates } = this.props; if (selectsMultiple) { return selectedDates?.some((date) => year === getYear(date)); } return !!selected && year === getYear(selected); }; onYearClick = ( event: | React.MouseEvent | React.KeyboardEvent, y: number, ) => { const { date } = this.props; if (date === undefined) { return; } this.handleYearClick(getStartOfYear(setYear(date, y)), event); }; onYearKeyDown = (event: React.KeyboardEvent, y: number) => { const { key } = event; const { date, yearItemNumber, handleOnKeyDown } = this.props; if (key !== KeyType.Tab) { // preventDefault on tab event blocks focus change event.preventDefault(); } if (!this.props.disabledKeyboardNavigation) { switch (key) { case KeyType.Enter: if (this.props.selected == null) { break; } this.onYearClick(event, y); this.props.setPreSelection?.(this.props.selected); break; case KeyType.ArrowRight: if (this.props.preSelection == null) { break; } this.handleYearNavigation( y + 1, addYears(this.props.preSelection, 1), ); break; case KeyType.ArrowLeft: if (this.props.preSelection == null) { break; } this.handleYearNavigation( y - 1, subYears(this.props.preSelection, 1), ); break; case KeyType.ArrowUp: { if ( date === undefined || yearItemNumber === undefined || this.props.preSelection == null ) { break; } const { startPeriod } = getYearsPeriod(date, yearItemNumber); let offset = VERTICAL_NAVIGATION_OFFSET; let newYear = y - offset; if (newYear < startPeriod) { const leftOverOffset = yearItemNumber % offset; if (y >= startPeriod && y < startPeriod + leftOverOffset) { offset = leftOverOffset; } else { offset += leftOverOffset; } newYear = y - offset; } this.handleYearNavigation( newYear, subYears(this.props.preSelection, offset), ); break; } case KeyType.ArrowDown: { if ( date === undefined || yearItemNumber === undefined || this.props.preSelection == null ) { break; } const { endPeriod } = getYearsPeriod(date, yearItemNumber); let offset = VERTICAL_NAVIGATION_OFFSET; let newYear = y + offset; if (newYear > endPeriod) { const leftOverOffset = yearItemNumber % offset; if (y <= endPeriod && y > endPeriod - leftOverOffset) { offset = leftOverOffset; } else { offset += leftOverOffset; } newYear = y + offset; } this.handleYearNavigation( newYear, addYears(this.props.preSelection, offset), ); break; } } } handleOnKeyDown && handleOnKeyDown(event); }; getYearClassNames = (y: number) => { const { date, disabled, minDate, maxDate, excludeDates, includeDates, filterDate, yearClassName, } = this.props; return clsx( "react-datepicker__year-text", `react-datepicker__year-${y}`, date ? yearClassName?.(setYear(date, y)) : undefined, { "react-datepicker__year-text--selected": this.isSelectedYear(y), "react-datepicker__year-text--disabled": (minDate || maxDate || excludeDates || includeDates || filterDate || disabled) && isYearDisabled(y, this.props), "react-datepicker__year-text--keyboard-selected": this.isKeyboardSelected(y), "react-datepicker__year-text--range-start": this.isRangeStart(y), "react-datepicker__year-text--range-end": this.isRangeEnd(y), "react-datepicker__year-text--in-range": this.isInRange(y), "react-datepicker__year-text--in-selecting-range": this.isInSelectingRange(y), "react-datepicker__year-text--selecting-range-start": this.isSelectingRangeStart(y), "react-datepicker__year-text--selecting-range-end": this.isSelectingRangeEnd(y), "react-datepicker__year-text--today": this.isCurrentYear(y), }, ); }; getYearTabIndex = (y: number) => { if ( this.props.disabledKeyboardNavigation || this.props.preSelection == null ) { return "-1"; } const preSelected = getYear(this.props.preSelection); const isPreSelectedYearDisabled = isYearDisabled(y, this.props); return y === preSelected && !isPreSelectedYearDisabled ? "0" : "-1"; }; getYearContent = (y: number) => { return this.props.renderYearContent ? this.props.renderYearContent(y) : y; }; render() { const yearsList = []; const { date, yearItemNumber, onYearMouseEnter, onYearMouseLeave } = this.props; if (date === undefined) { return null; } const { startPeriod, endPeriod } = getYearsPeriod(date, yearItemNumber); for (let y = startPeriod; y <= endPeriod; y++) { yearsList.push(
{ this.onYearClick(event, y); }} onKeyDown={(event) => { if (isSpaceKeyDown(event)) { event.preventDefault(); event.key = KeyType.Enter; } this.onYearKeyDown(event, y); }} tabIndex={Number(this.getYearTabIndex(y))} className={this.getYearClassNames(y)} onMouseEnter={ !this.props.usePointerEvent ? (event) => onYearMouseEnter(event, y) : undefined } onPointerEnter={ this.props.usePointerEvent ? (event) => onYearMouseEnter(event, y) : undefined } onMouseLeave={ !this.props.usePointerEvent ? (event) => onYearMouseLeave(event, y) : undefined } onPointerLeave={ this.props.usePointerEvent ? (event) => onYearMouseLeave(event, y) : undefined } key={y} aria-current={this.isCurrentYear(y) ? "date" : undefined} > {this.getYearContent(y)}
, ); } return (
{yearsList}
); } }