import { clsx } from 'clsx'; import { useEffect, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import Body from '../body'; import { Input } from '../inputs/Input'; import { SelectInput, SelectInputOptionContent, type SelectInputProps, } from '../inputs/SelectInput'; import { DateMode, MonthFormat, Size, SizeLarge, SizeMedium, SizeSmall, Typography, } from '../common'; import { MDY, YMD, getMonthNames, isDateValid, isMonthAndYearFormat } from '../common/dateUtils'; import { useFieldLabelRef, useInputAttributes } from '../inputs/contexts'; import messages from './DateInput.messages'; import { convertToLocalMidnight } from './utils'; export interface DateInputProps { /** @deprecated Use `Field` wrapper or the `aria-labelledby` attribute instead. */ 'aria-label'?: string; 'aria-labelledby'?: string; /** @default false */ disabled?: boolean; /** @default 'md' */ size?: SizeSmall | SizeMedium | SizeLarge; value?: Date | string; onChange: (value: string | null) => void; onFocus?: React.FocusEventHandler; onBlur?: React.FocusEventHandler; dayLabel?: string; dayAutoComplete?: string; monthLabel?: string; yearLabel?: string; yearAutoComplete?: string; /** @default 'long' */ monthFormat?: `${MonthFormat}`; /** @default 'day-month-year' */ mode?: `${DateMode}`; placeholders?: { day?: string; month?: string; year?: string; }; id?: string; /** @default {} */ selectProps?: Partial>; } /** * To be passed to SelectInput's parentId prop for correct blur handling. */ const DATE_INPUT_PARENT_ID = 'dateInput'; const DateInput = ({ 'aria-labelledby': ariaLabelledByProp, 'aria-label': ariaLabel, disabled = false, size = Size.MEDIUM, value, dayLabel, dayAutoComplete, monthLabel, yearLabel, yearAutoComplete, monthFormat = MonthFormat.LONG, mode = DateMode.DAY_MONTH_YEAR, onChange, onFocus, onBlur, placeholders, id: idProp, selectProps = {}, }: DateInputProps) => { const inputAttributes = useInputAttributes({ nonLabelable: true }); const fieldLabelRef = useFieldLabelRef(); const dayRef = useRef(null); const monthRef = useRef(null); const yearRef = useRef(null); const id = idProp ?? inputAttributes.id; const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes['aria-labelledby']; const { locale, formatMessage } = useIntl(); const getDateObject = (): Date | undefined => { if (value && isDateValid(value)) { return typeof value === 'string' ? convertToLocalMidnight(value) : value; } return undefined; }; const getInitialDate = (unit: 'year' | 'month' | 'day'): number | null => { if (value && isDateValid(value)) { const dateObject = getDateObject(); if (typeof value === 'string' && isMonthAndYearFormat(value) && unit === 'day') { return null; } if (dateObject !== undefined) { switch (unit) { case 'year': return dateObject.getFullYear(); case 'month': return dateObject.getMonth(); case 'day': return dateObject.getDate(); default: return null; } } } return null; }; const [day, setDay] = useState(() => getInitialDate('day')); const [displayDay, setDisplayDay] = useState(day?.toString()); const [month, setMonth] = useState(() => getInitialDate('month')); const [year, setYear] = useState(() => getInitialDate('year')); const [displayYear, setDisplayYear] = useState(year?.toString()); const [lastBroadcastedValue, setLastBroadcastedValue] = useState( getDateObject, ); const monthNames = getMonthNames(locale, monthFormat); const monthYearOnly = mode === DateMode.MONTH_YEAR; const monthBeforeDay = MDY.has(locale); const yearFirst = YMD.has(locale); dayLabel ||= formatMessage(messages.dayLabel); monthLabel ||= formatMessage(messages.monthLabel); yearLabel ||= formatMessage(messages.yearLabel); placeholders = { day: placeholders?.day || formatMessage(messages.dayPlaceholder), month: placeholders?.month || formatMessage(messages.monthLabel), year: placeholders?.year || formatMessage(messages.yearPlaceholder), }; useEffect(() => { const labelRef = fieldLabelRef?.current; if (labelRef) { const handleLabelClick = () => { // Not the best way to do this, but we're forced to recreate the native Label-click behavior if (monthYearOnly || monthBeforeDay) { monthRef.current?.click(); } else if (yearFirst) { yearRef.current?.focus(); } else { dayRef.current?.focus(); } }; labelRef.addEventListener('click', handleLabelClick); return () => { labelRef?.removeEventListener('click', handleLabelClick); }; } }, [fieldLabelRef, id, monthBeforeDay, monthYearOnly, yearFirst]); const getDateAsString = (date: Date) => { if (!isDateValid(date)) { return ''; } switch (mode) { case DateMode.MONTH_YEAR: return [date.getFullYear(), `0${date.getMonth() + 1}`.slice(-2)].join('-'); case DateMode.DAY_MONTH_YEAR: default: return [ date.getFullYear(), `0${date.getMonth() + 1}`.slice(-2), `0${date.getDate()}`.slice(-2), ].join('-'); } }; const getSelectElement = () => { return ( ); }; const isDayValid = (newDay: number, newMonth: number, newYear: number) => { const maxDay = new Date(newYear, newMonth + 1, 0).getDate(); return newDay <= maxDay; }; const handleInternalValue = (newDay = day, newMonth = month, newYear = year) => { if (newDay == null || newDay === 0 || newMonth == null || newYear == null || newYear === 0) { broadcastNewValue(null); return; } if (!isDayValid(newDay, newMonth, newYear)) { broadcastNewValue(null); return; } const dateValue = new Date(newYear, newMonth, newDay); if (newYear < 100) { dateValue.setFullYear(newYear); } if (!isDateValid(dateValue)) { broadcastNewValue(null); return; } if (mode === DateMode.MONTH_YEAR) { if (newMonth !== month || newYear !== year) { broadcastNewValue(dateValue); } } else if (newDay !== day || newMonth !== month || newYear !== year) { broadcastNewValue(dateValue); } }; const handleDayChange = (event: React.ChangeEvent) => { const newDayString = event.target.value.replace(/\D/g, ''); const newDayNumber = Number.parseInt(newDayString, 10); setDay(newDayNumber); setDisplayDay(newDayString); handleInternalValue(newDayNumber, month, year); }; const handleMonthChange = (selectedMonth: number | null) => { if (selectedMonth === null) { setMonth(null); handleInternalValue(day, null, year); return; } setMonth(selectedMonth); handleInternalValue(day, selectedMonth, year); }; const handleYearChange = (event: React.ChangeEvent) => { const newYearString = event.target.value.replace(/\D/g, ''); const newYearNumber = Number.parseInt(newYearString, 10); if (newYearString.length >= 4 && newYearString.length <= 6) { setYear(newYearNumber); setDisplayYear(newYearString); handleInternalValue(day, month, newYearNumber); } else { setYear(null); setDisplayYear(newYearString); handleInternalValue(day, month, null); } }; const broadcastNewValue = (newValue: Date | null) => { if (newValue !== lastBroadcastedValue) { setLastBroadcastedValue(newValue); onChange(newValue != null ? getDateAsString(newValue) : null); } }; const monthWidth = clsx({ 'col-sm-8 tw-date--month': monthYearOnly, 'col-sm-5 tw-date--month': !monthYearOnly, }); const getMonth = () => { return
{getSelectElement()}
; }; const getDay = () => { return (
); }; const getYear = () => { return (
); }; return (
shouldPropagateOnFocus(event) ? onFocus?.(event) : event.stopPropagation() } onBlur={(event) => (shouldPropagateOnBlur(event) ? onBlur?.(event) : event.stopPropagation())} >
{(() => { if (monthYearOnly) { return ( <> {!yearFirst && getMonth()} {getYear()} {yearFirst && getMonth()} ); } if (monthBeforeDay) { return ( <> {getMonth()} {getDay()} {getYear()} ); } if (yearFirst) { return ( <> {getYear()} {getMonth()} {getDay()} ); } return ( <> {getDay()} {getMonth()} {getYear()} ); })()}
); }; // Should only propagate if the relatedTarget is not part of this DateInput component. function shouldPropagateOnFocus({ target, relatedTarget, }: Pick) { const blurredElementParent = target.closest('[data-wds-dateinput]'); const focusedElementParent = relatedTarget?.closest('[data-wds-dateinput]'); return blurredElementParent !== focusedElementParent; } // Should only propagate if the focus-gaining element is not part // of this DateInput component or the (dropdown) of the month select. function shouldPropagateOnBlur({ target, relatedTarget, }: Pick) { const blurredElementParent = target.closest('[data-wds-dateinput]'); const focusedElementParent = relatedTarget?.closest('[data-wds-dateinput]'); return ( blurredElementParent !== focusedElementParent && !target?.closest(`[data-wds-parent="${DATE_INPUT_PARENT_ID}"]`) && !relatedTarget?.closest(`[data-wds-parent="${DATE_INPUT_PARENT_ID}"]`) ); } export default DateInput;