import { CalendarDaysIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon, } from '@heroicons/react/24/solid'; import { createPortal } from 'react-dom'; import { type ClipboardEvent, type ChangeEvent, type ChangeEventHandler, type FocusEvent, type FocusEventHandler, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type Ref, useCallback, useEffect, useMemo, useRef, useState, } from 'react'; import type { ComponentSize } from './types'; type DatePickerKind = 'date' | 'time' | 'date-time'; type Layout = 'stacked' | 'inline'; type PopupPlacement = 'top' | 'bottom'; type SegmentKind = 'day' | 'month' | 'year' | 'hour' | 'minute'; type FormatToken = 'dd' | 'mm' | 'yyyy' | 'HH'; type PickerValue = { date: Date; hour: number; minute: number; }; type PopupPosition = { top: number; left: number; minWidth: number; placement: PopupPlacement; }; type RawFormatPart = | { type: 'literal'; value: string; } | { type: 'token'; token: FormatToken; }; type FormatPart = | { type: 'literal'; value: string; start: number; end: number; } | { type: 'segment'; kind: SegmentKind; token: FormatToken; length: number; start: number; end: number; segmentIndex: number; }; type FormatSegment = Extract; type FormatConfig = { type: DatePickerKind; format: string; parts: FormatPart[]; segments: FormatSegment[]; totalLength: number; literalChars: Set; }; const HOURS = Array.from({ length: 24 }, (_, idx) => idx); const MINUTES = Array.from({ length: 60 }, (_, idx) => idx); const YEAR_WINDOW = 50; const POPUP_GAP = 6; const POPUP_MARGIN = 8; const SEGMENT_EDIT_TIMEOUT_MS = 1500; const DEFAULT_FORMAT: Record = { date: 'yyyy/mm/dd', time: 'HH:mm', 'date-time': 'yyyy/mm/dd HH:mm', }; export type DatePickerProps = { label?: string; placeholder?: string; type: DatePickerKind; size?: ComponentSize; width?: ComponentSize; layout?: Layout; value: string; format?: string; min?: string; max?: string; name?: string; onChange?: ChangeEventHandler; onBlur?: FocusEventHandler; inputRef?: Ref; disabled?: boolean; required?: boolean; error?: string; rightIcon?: ReactNode; className?: string; inputClassName?: string; }; function pad2(value: number): string { return String(value).padStart(2, '0'); } function pad4(value: number): string { return String(value).padStart(4, '0'); } function clampNumber(value: number, minValue: number, maxValue: number): number { return Math.min(maxValue, Math.max(minValue, value)); } function createDateAtLocalMidnight(year: number, monthIndex: number, day: number): Date { const candidate = new Date(0); candidate.setHours(0, 0, 0, 0); candidate.setFullYear(year, monthIndex, day); return candidate; } function createDateTimeFromPickerValue(value: PickerValue): Date { const candidate = createDateAtLocalMidnight( value.date.getFullYear(), value.date.getMonth(), value.date.getDate(), ); candidate.setHours(value.hour, value.minute, 0, 0); return candidate; } function startOfDay(value: Date): Date { const candidate = new Date(value); candidate.setHours(0, 0, 0, 0); return candidate; } function startOfMonth(value: Date): Date { const candidate = startOfDay(value); candidate.setDate(1); return candidate; } function isSameDay(left: Date, right: Date): boolean { return ( left.getFullYear() === right.getFullYear() && left.getMonth() === right.getMonth() && left.getDate() === right.getDate() ); } function createValidatedDate(year: number, month: number, day: number): Date | null { const candidate = createDateAtLocalMidnight(year, month - 1, day); if ( candidate.getFullYear() !== year || candidate.getMonth() !== month - 1 || candidate.getDate() !== day ) { return null; } return candidate; } function daysInMonth(year: number, month: number): number { return createDateAtLocalMidnight(year, month, 0).getDate(); } function clonePickerValue(value: PickerValue): PickerValue { return { date: startOfDay(value.date), hour: value.hour, minute: value.minute, }; } function resolveLocale(): string { if (typeof navigator === 'undefined') { return 'en-US'; } return navigator.languages?.[0] ?? navigator.language ?? 'en-US'; } function resolveWeekStart(locale: string): number { if (typeof Intl === 'undefined' || typeof Intl.Locale !== 'function') { return 0; } try { const localeInfo = new Intl.Locale(locale) as Intl.Locale & { weekInfo?: { firstDay?: number }; }; const firstDay = localeInfo.weekInfo?.firstDay; if (typeof firstDay !== 'number') { return 0; } if (firstDay === 7) { return 0; } if (firstDay >= 1 && firstDay <= 6) { return firstDay; } } catch { return 0; } return 0; } function buildMonthGrid(viewMonth: Date, weekStart: number): Date[] { const monthStart = startOfMonth(viewMonth); const dayOffset = (monthStart.getDay() - weekStart + 7) % 7; const gridStart = createDateAtLocalMidnight( monthStart.getFullYear(), monthStart.getMonth(), monthStart.getDate() - dayOffset, ); return Array.from({ length: 42 }, (_, index) => { return createDateAtLocalMidnight( gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + index, ); }); } function joinClassNames(...parts: Array): string { return parts.filter(Boolean).join(' ').trim(); } function assignRef(ref: Ref | undefined, node: HTMLInputElement | null): void { if (!ref) { return; } if (typeof ref === 'function') { ref(node); return; } (ref as { current: HTMLInputElement | null }).current = node; } function tokenizeFormat(format: string): RawFormatPart[] { const result: RawFormatPart[] = []; let pointer = 0; let literalBuffer = ''; const flushLiteral = () => { if (literalBuffer.length > 0) { result.push({ type: 'literal', value: literalBuffer }); literalBuffer = ''; } }; while (pointer < format.length) { if (format.startsWith('yyyy', pointer)) { flushLiteral(); result.push({ type: 'token', token: 'yyyy' }); pointer += 4; continue; } if (format.startsWith('dd', pointer)) { flushLiteral(); result.push({ type: 'token', token: 'dd' }); pointer += 2; continue; } if (format.startsWith('HH', pointer)) { flushLiteral(); result.push({ type: 'token', token: 'HH' }); pointer += 2; continue; } if (format.startsWith('mm', pointer)) { flushLiteral(); result.push({ type: 'token', token: 'mm' }); pointer += 2; continue; } literalBuffer += format[pointer]; pointer += 1; } flushLiteral(); return result; } function buildFormatConfigOrNull(type: DatePickerKind, format: string): FormatConfig | null { const rawParts = tokenizeFormat(format); if (rawParts.length === 0) { return null; } const parts: FormatPart[] = []; const literalChars = new Set(); const counts: Record = { day: 0, month: 0, year: 0, hour: 0, minute: 0, }; let hourSeen = false; let segmentIndex = 0; let offset = 0; for (const part of rawParts) { if (part.type === 'literal') { for (const char of part.value) { literalChars.add(char); } parts.push({ type: 'literal', value: part.value, start: offset, end: offset + part.value.length, }); offset += part.value.length; continue; } let kind: SegmentKind; if (part.token === 'dd') { kind = 'day'; } else if (part.token === 'yyyy') { kind = 'year'; } else if (part.token === 'HH') { kind = 'hour'; hourSeen = true; } else if (type === 'date') { kind = 'month'; } else if (type === 'time') { kind = 'minute'; } else { kind = hourSeen ? 'minute' : 'month'; } counts[kind] += 1; const length = part.token.length; parts.push({ type: 'segment', kind, token: part.token, length, start: offset, end: offset + length, segmentIndex, }); segmentIndex += 1; offset += length; } const isValid = (type === 'date' && counts.day === 1 && counts.month === 1 && counts.year === 1 && counts.hour === 0 && counts.minute === 0) || (type === 'time' && counts.day === 0 && counts.month === 0 && counts.year === 0 && counts.hour === 1 && counts.minute === 1) || (type === 'date-time' && counts.day === 1 && counts.month === 1 && counts.year === 1 && counts.hour === 1 && counts.minute === 1); if (!isValid) { return null; } const segments = parts.filter((part): part is FormatSegment => part.type === 'segment'); /* c8 ignore start -- validated token counts always yield at least one segment. */ if (segments.length === 0) { return null; } /* c8 ignore stop */ return { type, format, parts, segments, totalLength: offset, literalChars, }; } function buildFormatConfig(type: DatePickerKind, requestedFormat?: string): FormatConfig { if (requestedFormat != null && requestedFormat.trim() !== '') { const explicit = buildFormatConfigOrNull(type, requestedFormat.trim()); if (explicit) { return explicit; } } const fallback = buildFormatConfigOrNull(type, DEFAULT_FORMAT[type]); /* c8 ignore start -- static defaults are valid for all supported picker types. */ if (!fallback) { throw new Error('Failed to initialize DatePicker format configuration.'); } /* c8 ignore stop */ return fallback; } function parsePickerValueWithFormat(rawValue: string, config: FormatConfig): PickerValue | null { const value = rawValue.trim(); if (value.length !== config.totalLength) { return null; } let year: number | null = null; let month: number | null = null; let day: number | null = null; let hour: number | null = null; let minute: number | null = null; for (const part of config.parts) { const chunk = value.slice(part.start, part.end); if (part.type === 'literal') { if (chunk !== part.value) { return null; } continue; } if (!/^\d+$/.test(chunk)) { return null; } const numeric = Number(chunk); /* c8 ignore start -- numeric chunks are finite after /^\d+$/ validation. */ if (!Number.isFinite(numeric)) { return null; } /* c8 ignore stop */ if (part.kind === 'year') { year = numeric; } else if (part.kind === 'month') { month = numeric; } else if (part.kind === 'day') { day = numeric; } else if (part.kind === 'hour') { hour = numeric; } else { minute = numeric; } } if (config.type !== 'time') { /* c8 ignore start -- date/month/year segments are guaranteed for non-time validated formats. */ if (year == null || month == null || day == null) { return null; } /* c8 ignore stop */ /* c8 ignore start -- 'yyyy' token bounds parsed year to 0..9999. */ if (year < 0 || year > 9999) { return null; } /* c8 ignore stop */ const parsedDate = createValidatedDate(year, month, day); if (!parsedDate) { return null; } if (config.type === 'date') { return { date: parsedDate, hour: 0, minute: 0, }; } /* c8 ignore start -- date-time format validation guarantees hour/minute segments. */ if (hour == null || minute == null) { return null; } /* c8 ignore stop */ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { return null; } return { date: parsedDate, hour, minute, }; } /* c8 ignore start -- time format validation guarantees hour/minute segments. */ if (hour == null || minute == null) { return null; } /* c8 ignore stop */ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { return null; } return { date: startOfDay(new Date()), hour, minute, }; } function formatPickerValueWithFormat(value: PickerValue, config: FormatConfig): string { const fields: Record = { day: pad2(value.date.getDate()), month: pad2(value.date.getMonth() + 1), year: pad4(value.date.getFullYear()), hour: pad2(value.hour), minute: pad2(value.minute), }; return config.parts .map((part) => { if (part.type === 'literal') { return part.value; } return fields[part.kind]; }) .join(''); } function comparePickerValue(left: PickerValue, right: PickerValue, type: DatePickerKind): number { if (type === 'time') { const leftTotal = left.hour * 60 + left.minute; const rightTotal = right.hour * 60 + right.minute; return leftTotal - rightTotal; } if (type === 'date') { return startOfDay(left.date).getTime() - startOfDay(right.date).getTime(); } const leftDateTime = createDateTimeFromPickerValue(left).getTime(); const rightDateTime = createDateTimeFromPickerValue(right).getTime(); return leftDateTime - rightDateTime; } function normalizeRange( minValue: PickerValue | null, maxValue: PickerValue | null, type: DatePickerKind, ): { minValue: PickerValue | null; maxValue: PickerValue | null } { if (minValue == null || maxValue == null) { return { minValue, maxValue, }; } if (comparePickerValue(minValue, maxValue, type) <= 0) { return { minValue, maxValue }; } return { minValue: maxValue, maxValue: minValue, }; } function clampPickerToRange( candidate: PickerValue, minValue: PickerValue | null, maxValue: PickerValue | null, type: DatePickerKind, ): PickerValue { if (minValue && comparePickerValue(candidate, minValue, type) < 0) { return clonePickerValue(minValue); } if (maxValue && comparePickerValue(candidate, maxValue, type) > 0) { return clonePickerValue(maxValue); } return clonePickerValue(candidate); } function isWithinRange( candidate: PickerValue, minValue: PickerValue | null, maxValue: PickerValue | null, type: DatePickerKind, ): boolean { if (minValue && comparePickerValue(candidate, minValue, type) < 0) { return false; } if (maxValue && comparePickerValue(candidate, maxValue, type) > 0) { return false; } return true; } function applySegmentDigits( baseValue: PickerValue, kind: SegmentKind, digits: string, ): PickerValue { const parsedDigits = Number(digits); if (!Number.isFinite(parsedDigits)) { return clonePickerValue(baseValue); } let year = baseValue.date.getFullYear(); let month = baseValue.date.getMonth() + 1; let day = baseValue.date.getDate(); let hour = baseValue.hour; let minute = baseValue.minute; if (kind === 'year') { year = clampNumber(parsedDigits, 0, 9999); } else if (kind === 'month') { month = clampNumber(parsedDigits, 1, 12); } else if (kind === 'day') { const maxDay = daysInMonth(year, month); day = clampNumber(parsedDigits, 1, maxDay); } else if (kind === 'hour') { hour = clampNumber(parsedDigits, 0, 23); } else { minute = clampNumber(parsedDigits, 0, 59); } const maxDayForCurrentMonth = daysInMonth(year, month); day = clampNumber(day, 1, maxDayForCurrentMonth); const nextDate = createValidatedDate(year, month, day) ?? createDateAtLocalMidnight(year, month - 1, day); return { date: nextDate, hour, minute, }; } function findSegmentIndexByCaret(segments: FormatSegment[], caretPosition: number | null): number { if (segments.length === 0) { return 0; } if (caretPosition == null) { return 0; } for (const segment of segments) { if (caretPosition >= segment.start && caretPosition <= segment.end) { return segment.segmentIndex; } if (caretPosition < segment.start) { return segment.segmentIndex; } } return segments[segments.length - 1].segmentIndex; } function isDateSelectableForRange( dateValue: Date, type: DatePickerKind, minValue: PickerValue | null, maxValue: PickerValue | null, ): boolean { if (type === 'date') { return isWithinRange( { date: startOfDay(dateValue), hour: 0, minute: 0, }, minValue, maxValue, type, ); } const dayStart: PickerValue = { date: startOfDay(dateValue), hour: 0, minute: 0, }; const dayEnd: PickerValue = { date: startOfDay(dateValue), hour: 23, minute: 59, }; if (minValue && comparePickerValue(dayEnd, minValue, type) < 0) { return false; } if (maxValue && comparePickerValue(dayStart, maxValue, type) > 0) { return false; } return true; } function isHourSelectableForRange( dateValue: Date, hour: number, type: DatePickerKind, minValue: PickerValue | null, maxValue: PickerValue | null, ): boolean { const candidateStart: PickerValue = { date: startOfDay(dateValue), hour, minute: 0, }; const candidateEnd: PickerValue = { date: startOfDay(dateValue), hour, minute: 59, }; if (minValue && comparePickerValue(candidateEnd, minValue, type) < 0) { return false; } if (maxValue && comparePickerValue(candidateStart, maxValue, type) > 0) { return false; } return true; } // eslint-disable-next-line react-refresh/only-export-components -- test-only export of pure helpers. export const __datePickerTestUtils = { pad2, pad4, clampNumber, createDateAtLocalMidnight, createDateTimeFromPickerValue, startOfDay, startOfMonth, isSameDay, createValidatedDate, daysInMonth, clonePickerValue, resolveLocale, resolveWeekStart, buildMonthGrid, joinClassNames, assignRef, tokenizeFormat, buildFormatConfigOrNull, buildFormatConfig, parsePickerValueWithFormat, formatPickerValueWithFormat, comparePickerValue, normalizeRange, clampPickerToRange, isWithinRange, applySegmentDigits, findSegmentIndexByCaret, isDateSelectableForRange, isHourSelectableForRange, } as const; export function DatePicker({ label, placeholder = '', type, size = 'md', width = 'md', layout = 'stacked', value, format, min, max, name, onChange, onBlur, inputRef, disabled = false, required = false, error, rightIcon, className = '', inputClassName = '', }: Readonly) { const internalInputRef = useRef(null); const inputWrapperRef = useRef(null); const popupRef = useRef(null); const changeHandledRef = useRef(false); const bufferedDigitsRef = useRef<{ segmentIndex: number; digits: string; timestamp: number; } | null>(null); const activeSegmentIndexRef = useRef(0); const pendingSelectionTimerRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date())); const [activeChooser, setActiveChooser] = useState<'month' | 'year' | null>(null); const [popupPosition, setPopupPosition] = useState(null); const closePicker = useCallback(() => { setIsOpen(false); setActiveChooser(null); setPopupPosition(null); }, []); const locale = useMemo(() => resolveLocale(), []); const weekStart = useMemo(() => resolveWeekStart(locale), [locale]); const formatConfig = useMemo(() => buildFormatConfig(type, format), [type, format]); const fallbackValue = useMemo(() => { const today = startOfDay(new Date()); return { date: today, hour: 0, minute: 0, }; }, []); const parsedValue = useMemo( () => parsePickerValueWithFormat(value, formatConfig), [formatConfig, value], ); const selectedValue = parsedValue ?? fallbackValue; const parsedMinValue = useMemo(() => { if (min == null || min.trim() === '') { return null; } return parsePickerValueWithFormat(min, formatConfig); }, [formatConfig, min]); const parsedMaxValue = useMemo(() => { if (max == null || max.trim() === '') { return null; } return parsePickerValueWithFormat(max, formatConfig); }, [formatConfig, max]); const { minValue: normalizedMinValue, maxValue: normalizedMaxValue } = useMemo(() => { return normalizeRange(parsedMinValue, parsedMaxValue, type); }, [parsedMaxValue, parsedMinValue, type]); const clampedSelectedValue = useMemo(() => { return clampPickerToRange(selectedValue, normalizedMinValue, normalizedMaxValue, type); }, [normalizedMaxValue, normalizedMinValue, selectedValue, type]); const selectedDate = clampedSelectedValue.date; const selectedHour = clampedSelectedValue.hour; const selectedMinute = clampedSelectedValue.minute; const displayValue = useMemo(() => { return formatPickerValueWithFormat(clampedSelectedValue, formatConfig); }, [clampedSelectedValue, formatConfig]); const containerWidthClass = { sm: 'max-w-xs', md: 'max-w-sm', lg: 'max-w-md', full: 'max-w-none', }[width]; const inputSizeClass = { sm: 'h-8 !text-xs', md: 'h-10 text-sm', lg: 'h-12 text-sm', full: 'h-10 text-sm', }[size]; const wrapperClass = layout === 'inline' ? 'inline-flex w-auto items-center gap-2' : 'block w-full gap-1'; const labelClass = layout === 'inline' ? 'text-xs ui-body-secondary' : ''; const inputWrapperClass = layout === 'inline' ? 'relative' : 'relative mt-1'; const defaultRightIcon = type === 'time' ? (