import { clsx } from "clsx"; import React, { Component, createRef } from "react"; import { getDay, getMonth, getDate, newDate, isSameDay, isDayDisabled, isDayExcluded, isDayInRange, isEqual, isBefore, isAfter, getDayOfWeekCode, getStartOfWeek, formatDate, type DateFilterOptionsWithDisabled, type DateNumberType, type Locale, type HolidaysMap, KeyType, } from "./date_utils"; interface DayProps extends Pick< DateFilterOptionsWithDisabled, | "minDate" | "maxDate" | "excludeDates" | "excludeDateIntervals" | "includeDateIntervals" | "includeDates" | "filterDate" | "disabled" > { ariaLabelPrefixWhenEnabled?: string; ariaLabelPrefixWhenDisabled?: string; disabledKeyboardNavigation?: boolean; day: Date; dayClassName?: (date: Date) => string; highlightDates?: Map; holidays?: HolidaysMap; inline?: boolean; shouldFocusDayInline?: boolean; month: number; onClick?: React.MouseEventHandler; onMouseEnter?: React.MouseEventHandler; handleOnKeyDown?: React.KeyboardEventHandler; usePointerEvent?: boolean; preSelection?: Date | null; selected?: Date | null; selectingDate?: Date; selectsEnd?: boolean; selectsStart?: boolean; selectsRange?: boolean; showWeekPicker?: boolean; showWeekNumber?: boolean; selectsDisabledDaysInRange?: boolean; selectsMultiple?: boolean; selectedDates?: Date[]; startDate?: Date | null; endDate?: Date | null; renderDayContents?: (day: number, date: Date) => React.ReactNode; containerRef?: React.RefObject; calendarStartDay?: DateNumberType; locale?: Locale; monthShowsDuplicateDaysEnd?: boolean; monthShowsDuplicateDaysStart?: boolean; swapRange?: boolean; } /** * `Day` is a React component that represents a single day in a date picker. * It handles the rendering and interaction of a day. * * @prop ariaLabelPrefixWhenEnabled - Aria label prefix when the day is enabled. * @prop ariaLabelPrefixWhenDisabled - Aria label prefix when the day is disabled. * @prop disabledKeyboardNavigation - Whether keyboard navigation is disabled. * @prop day - The day to be displayed. * @prop dayClassName - Function to customize the CSS class of the day. * @prop endDate - The end date in a range. * @prop highlightDates - Map of dates to be highlighted. * @prop holidays - Map of holiday dates. * @prop inline - Whether the date picker is inline. * @prop shouldFocusDayInline - Whether the day should be focused when date picker is inline. * @prop month - The month the day belongs to. * @prop onClick - Click event handler. * @prop onMouseEnter - Mouse enter event handler. * @prop handleOnKeyDown - Key down event handler. * @prop usePointerEvent - Whether to use pointer events. * @prop preSelection - The date that is currently selected. * @prop selected - The selected date. * @prop selectingDate - The date currently being selected. * @prop selectsEnd - Whether the day can be the end date in a range. * @prop selectsStart - Whether the day can be the start date in a range. * @prop selectsRange - Whether the day can be in a range. * @prop showWeekPicker - Whether to show week picker. * @prop showWeekNumber - Whether to show week numbers. * @prop selectsDisabledDaysInRange - Whether to select disabled days in a range. * @prop selectsMultiple - Whether to allow multiple date selection. * @prop selectedDates - Array of selected dates. * @prop startDate - The start date in a range. * @prop renderDayContents - Function to customize the rendering of the day's contents. * @prop containerRef - Ref for the container. * @prop excludeDates - Array of dates to be excluded. * @prop calendarStartDay - The start day of the week. * @prop locale - The locale object. * @prop monthShowsDuplicateDaysEnd - Whether to show duplicate days at the end of the month. * @prop monthShowsDuplicateDaysStart - Whether to show duplicate days at the start of the month. * @prop includeDates - Array of dates to be included. * @prop includeDateIntervals - Array of date intervals to be included. * @prop minDate - The minimum date that can be selected. * @prop maxDate - The maximum date that can be selected. * * @example * ```tsx * import React from 'react'; * import Day from './day'; * * function MyComponent() { * const handleDayClick = (event) => { * console.log('Day clicked', event); * }; * * const handleDayMouseEnter = (event) => { * console.log('Mouse entered day', event); * }; * * const renderDayContents = (date) => { * return
{date.getDate()}
; * }; * * return ( * * ); * } * * export default MyComponent; * ``` */ export default class Day extends Component { componentDidMount() { this.handleFocusDay(); } componentDidUpdate() { this.handleFocusDay(); } dayEl = createRef(); handleClick: DayProps["onClick"] = (event) => { if (!this.isDisabled() && this.props.onClick) { this.props.onClick(event); } }; handleMouseEnter: DayProps["onMouseEnter"] = (event) => { if (!this.isDisabled() && this.props.onMouseEnter) { this.props.onMouseEnter(event); } }; handleOnKeyDown: React.KeyboardEventHandler = (event) => { const eventKey = event.key; if (eventKey === KeyType.Space) { event.preventDefault(); event.key = KeyType.Enter; } this.props.handleOnKeyDown?.(event); }; isSameDay = (other: Date | null | undefined) => isSameDay(this.props.day, other); isKeyboardSelected = () => { if (this.props.disabledKeyboardNavigation) { return false; } const isSelectedDate = this.props.selectsMultiple ? this.props.selectedDates?.some((date) => this.isSameDayOrWeek(date)) : this.isSameDayOrWeek(this.props.selected); const isDisabled = this.props.preSelection && this.isDisabled(this.props.preSelection); return ( !isSelectedDate && this.isSameDayOrWeek(this.props.preSelection) && !isDisabled ); }; isDisabled = (day = this.props.day) => // Almost all props previously were passed as this.props w/o proper typing with prop-types // after the migration to TS i made it explicit isDayDisabled(day, { minDate: this.props.minDate, maxDate: this.props.maxDate, excludeDates: this.props.excludeDates, excludeDateIntervals: this.props.excludeDateIntervals, includeDateIntervals: this.props.includeDateIntervals, includeDates: this.props.includeDates, filterDate: this.props.filterDate, disabled: this.props.disabled, }); isExcluded = () => // Almost all props previously were passed as this.props w/o proper typing with prop-types // after the migration to TS i made it explicit isDayExcluded(this.props.day, { excludeDates: this.props.excludeDates, excludeDateIntervals: this.props.excludeDateIntervals, }); isStartOfWeek = () => isSameDay( this.props.day, getStartOfWeek( this.props.day, this.props.locale, this.props.calendarStartDay, ), ); isSameWeek = (other?: Date | null) => this.props.showWeekPicker && isSameDay( other, getStartOfWeek( this.props.day, this.props.locale, this.props.calendarStartDay, ), ); isSameDayOrWeek = (other?: Date | null) => this.isSameDay(other) || this.isSameWeek(other); getHighLightedClass = () => { const { day, highlightDates } = this.props; if (!highlightDates) { return false; } // Looking for className in the Map of {'day string, 'className'} const dayStr = formatDate(day, "MM.dd.yyyy"); return highlightDates.get(dayStr); }; // Function to return the array containing className associated to the date getHolidaysClass = () => { const { day, holidays } = this.props; if (!holidays) { // For type consistency no other reasons return [undefined]; } const dayStr = formatDate(day, "MM.dd.yyyy"); // Looking for className in the Map of {day string: {className, holidayName}} if (holidays.has(dayStr)) { return [holidays.get(dayStr)?.className]; } // For type consistency no other reasons return [undefined]; }; isInRange = () => { const { day, startDate, endDate } = this.props; if (!startDate || !endDate) { return false; } return isDayInRange(day, startDate, endDate); }; isInSelectingRange = () => { const { day, selectsStart, selectsEnd, selectsRange, selectsDisabledDaysInRange, startDate, swapRange, endDate, } = this.props; const selectingDate = this.props.selectingDate ?? this.props.preSelection; // Don't highlight days outside the current month if (this.isAfterMonth() || this.isBeforeMonth()) { return false; } if ( !(selectsStart || selectsEnd || selectsRange) || !selectingDate || (!selectsDisabledDaysInRange && this.isDisabled()) ) { return false; } if ( selectsStart && endDate && (isBefore(selectingDate, endDate) || isEqual(selectingDate, endDate)) ) { return isDayInRange(day, selectingDate, endDate); } if ( selectsEnd && startDate && (isAfter(selectingDate, startDate) || isEqual(selectingDate, startDate)) ) { return isDayInRange(day, startDate, selectingDate); } if (selectsRange && startDate && !endDate) { if (isEqual(selectingDate, startDate)) { return isDayInRange(day, startDate, selectingDate); } if (isAfter(selectingDate, startDate)) { return isDayInRange(day, startDate, selectingDate); } if (swapRange && isBefore(selectingDate, startDate)) { return isDayInRange(day, selectingDate, startDate); } } return false; }; isSelectingRangeStart = () => { if (!this.isInSelectingRange()) { return false; } const { day, startDate, selectsStart, swapRange, selectsRange } = this.props; const selectingDate = this.props.selectingDate ?? this.props.preSelection; if (selectsStart) { return isSameDay(day, selectingDate); } if (selectsRange && swapRange && startDate && selectingDate) { return isSameDay( day, isBefore(selectingDate, startDate) ? selectingDate : startDate, ); } return isSameDay(day, startDate); }; isSelectingRangeEnd = () => { if (!this.isInSelectingRange()) { return false; } const { day, endDate, selectsEnd, selectsRange, swapRange, startDate } = this.props; const selectingDate = this.props.selectingDate ?? this.props.preSelection; if (selectsEnd) { return isSameDay(day, selectingDate); } if (selectsRange && swapRange && startDate && selectingDate) { return isSameDay( day, isBefore(selectingDate, startDate) ? startDate : selectingDate, ); } if (selectsRange) { return isSameDay(day, selectingDate); } return isSameDay(day, endDate); }; isRangeStart = () => { const { day, startDate, endDate } = this.props; if (!startDate || !endDate) { return false; } return isSameDay(startDate, day); }; isRangeEnd = () => { const { day, startDate, endDate } = this.props; if (!startDate || !endDate) { return false; } return isSameDay(endDate, day); }; isWeekend = () => { const weekday = getDay(this.props.day); return weekday === 0 || weekday === 6; }; isAfterMonth = () => { return ( this.props.month !== undefined && (this.props.month + 1) % 12 === getMonth(this.props.day) ); }; isBeforeMonth = () => { return ( this.props.month !== undefined && (getMonth(this.props.day) + 1) % 12 === this.props.month ); }; isCurrentDay = () => this.isSameDay(newDate()); isSelected = () => { if (this.props.selectsMultiple) { return this.props.selectedDates?.some((date) => this.isSameDayOrWeek(date), ); } return this.isSameDayOrWeek(this.props.selected); }; getClassNames = (date: Date) => { const dayClassName = this.props.dayClassName ? this.props.dayClassName(date) : undefined; return clsx( "react-datepicker__day", dayClassName, "react-datepicker__day--" + getDayOfWeekCode(this.props.day), { "react-datepicker__day--disabled": this.isDisabled(), "react-datepicker__day--excluded": this.isExcluded(), "react-datepicker__day--selected": this.isSelected(), "react-datepicker__day--keyboard-selected": this.isKeyboardSelected(), "react-datepicker__day--range-start": this.isRangeStart(), "react-datepicker__day--range-end": this.isRangeEnd(), "react-datepicker__day--in-range": this.isInRange(), "react-datepicker__day--in-selecting-range": this.isInSelectingRange(), "react-datepicker__day--selecting-range-start": this.isSelectingRangeStart(), "react-datepicker__day--selecting-range-end": this.isSelectingRangeEnd(), "react-datepicker__day--today": this.isCurrentDay(), "react-datepicker__day--weekend": this.isWeekend(), "react-datepicker__day--outside-month": this.isAfterMonth() || this.isBeforeMonth(), }, this.getHighLightedClass(), this.getHolidaysClass(), ); }; getAriaLabel = () => { const { day, ariaLabelPrefixWhenEnabled = "Choose", ariaLabelPrefixWhenDisabled = "Not available", } = this.props; const prefix = this.isDisabled() || this.isExcluded() ? ariaLabelPrefixWhenDisabled : ariaLabelPrefixWhenEnabled; return `${prefix} ${formatDate(day, "PPPP", this.props.locale)}`; }; // A function to return the holiday's name as title's content getTitle = () => { const { day, holidays = new Map(), excludeDates } = this.props; const compareDt = formatDate(day, "MM.dd.yyyy"); const titles = []; if (holidays.has(compareDt)) { titles.push(...holidays.get(compareDt).holidayNames); } if (this.isExcluded()) { titles.push( excludeDates ?.filter((excludeDate) => { if (excludeDate instanceof Date) { return isSameDay(excludeDate, day); } return isSameDay(excludeDate?.date, day); }) .map((excludeDate) => { if (excludeDate instanceof Date) { return undefined; } return excludeDate?.message; }), ); } // I'm not sure that this is a right output, but all tests are green return titles.join(", "); }; getTabIndex = () => { const selectedDay = this.props.selected; const preSelectionDay = this.props.preSelection; const tabIndex = !( this.props.showWeekPicker && (this.props.showWeekNumber || !this.isStartOfWeek()) ) && (this.isKeyboardSelected() || (this.isSameDay(selectedDay) && isSameDay(preSelectionDay, selectedDay))) ? 0 : -1; return tabIndex; }; // various cases when we need to apply focus to the preselected day // focus the day on mount/update so that keyboard navigation works while cycling through months with up or down keys (not for prev and next month buttons) // prevent focus for these activeElement cases so we don't pull focus from the input as the calendar opens handleFocusDay = () => { // only do this while the input isn't focused // otherwise, typing/backspacing the date manually may steal focus away from the input this.shouldFocusDay() && this.dayEl.current?.focus({ preventScroll: true }); }; private shouldFocusDay() { let shouldFocusDay = false; if (this.getTabIndex() === 0 && this.isSameDay(this.props.preSelection)) { // there is currently no activeElement and not inline if (!document.activeElement || document.activeElement === document.body) { shouldFocusDay = true; } // inline version: // do not focus on initial render to prevent autoFocus issue // focus after month has changed via keyboard if (this.props.inline && !this.props.shouldFocusDayInline) { shouldFocusDay = false; } if (this.isDayActiveElement()) { shouldFocusDay = true; } if (this.isDuplicateDay()) { shouldFocusDay = false; } } return shouldFocusDay; } // the activeElement is in the container, and it is another instance of Day private isDayActiveElement() { return ( this.props.containerRef?.current?.contains(document.activeElement) && document.activeElement?.classList.contains("react-datepicker__day") ); } private isDuplicateDay() { return ( //day is one of the non rendered duplicate days (this.props.monthShowsDuplicateDaysEnd && this.isAfterMonth()) || (this.props.monthShowsDuplicateDaysStart && this.isBeforeMonth()) ); } renderDayContents = () => { if (this.props.monthShowsDuplicateDaysEnd && this.isAfterMonth()) return null; if (this.props.monthShowsDuplicateDaysStart && this.isBeforeMonth()) return null; return this.props.renderDayContents ? this.props.renderDayContents(getDate(this.props.day), this.props.day) : getDate(this.props.day); }; render = () => ( // TODO: Use