import React, { useCallback, useEffect, useRef, useState, } from 'react' import { createPortal } from 'react-dom' const MONTH_NAMES = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ] const DAY_ABBR = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] function pad(value) { return String(value).padStart(2, '0') } function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() } function firstWeekday(year, month) { const day = new Date(year, month, 1).getDay() return (day + 6) % 7 } function toISODate(date) { return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` } function parseDatePart(value) { if (!value) return null const [year, month, day] = value.split('-').map(Number) if (!year || !month || !day) return null return new Date(year, month - 1, day) } function splitDateTime(value) { if (!value) { return { date: '', time: '' } } const [date = '', time = ''] = String(value).split('T') return { date, time: time.slice(0, 5), } } function mergeDateTime(date, time) { if (!date) return '' return `${date}T${time || '00:00'}` } function maxDateValue(a, b) { if (!a) return b || '' if (!b) return a || '' return a > b ? a : b } function minDateValue(a, b) { if (!a) return b || '' if (!b) return a || '' return a < b ? a : b } function clampTimeToBounds(date, time, minDateTime, maxDateTime) { const nextTime = time || '00:00' const minParts = splitDateTime(minDateTime) const maxParts = splitDateTime(maxDateTime) if (date && minParts.date === date && minParts.time && nextTime < minParts.time) { return minParts.time } if (date && maxParts.date === date && maxParts.time && nextTime > maxParts.time) { return maxParts.time } return nextTime } function formatDisplay(value) { if (!value) return '' const { date, time } = splitDateTime(value) const parsed = parseDatePart(date) if (!parsed) return '' return `${MONTH_NAMES[parsed.getMonth()].slice(0, 3)} ${parsed.getDate()}, ${parsed.getFullYear()}${time ? ` at ${time}` : ''}` } function isSameDay(a, b) { return a?.getFullYear() === b?.getFullYear() && a?.getMonth() === b?.getMonth() && a?.getDate() === b?.getDate() } function CalendarGrid({ year, month, selectedDate, onSelect, minDate, maxDate }) { const count = daysInMonth(year, month) const start = firstWeekday(year, month) const prevMonth = month - 1 < 0 ? 11 : month - 1 const prevYear = month - 1 < 0 ? year - 1 : year const prevCount = daysInMonth(prevYear, prevMonth) const cells = [] for (let index = start - 1; index >= 0; index -= 1) { cells.push({ day: prevCount - index, current: false, date: new Date(prevYear, prevMonth, prevCount - index), }) } for (let day = 1; day <= count; day += 1) { cells.push({ day, current: true, date: new Date(year, month, day) }) } let nextDay = 1 while (cells.length % 7 !== 0) { cells.push({ day: nextDay, current: false, date: new Date(year, month + 1, nextDay) }) nextDay += 1 } const today = new Date() today.setHours(0, 0, 0, 0) return (
{DAY_ABBR.map((day) => (
{day}
))}
{cells.map((cell, index) => { const iso = toISODate(cell.date) const selected = isSameDay(cell.date, selectedDate) const todayCell = isSameDay(cell.date, today) const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate) return ( ) })}
) } export default function DateTimePicker({ value = '', onChange, label, placeholder, error, hint, required = false, clearable = false, id, disabled = false, mode = 'datetime', minDate, maxDate, minDateTime, maxDateTime, className = '', }) { const today = new Date() const initial = splitDateTime(value) const initialDate = parseDatePart(initial.date) || today const [open, setOpen] = useState(false) const [dropPos, setDropPos] = useState({ top: 0, left: 0, width: 320 }) const [viewYear, setViewYear] = useState(initialDate.getFullYear()) const [viewMonth, setViewMonth] = useState(initialDate.getMonth()) const [draftDate, setDraftDate] = useState(initial.date) // default to current local time (HH:MM), clamped to min/max bounds when present const nowTime = `${pad(today.getHours())}:${pad(today.getMinutes())}` const defaultDraftTime = (function () { const baseDate = initial.date || toISODate(initialDate) const candidate = initial.time || nowTime return clampTimeToBounds(baseDate, candidate, minDateTime, maxDateTime) })() const [draftTime, setDraftTime] = useState(defaultDraftTime) const effectivePlaceholder = placeholder || (mode === 'date' ? 'Pick a date' : 'Pick a date and time') const triggerRef = useRef(null) const inputId = id ?? (label ? `dtp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-time-picker') const panelId = `dtp-panel-${inputId}` useEffect(() => { const next = splitDateTime(value) setDraftDate(next.date) // prefer explicit time, otherwise use current time clamped to bounds for the chosen date const fallbackTime = (() => { const candidate = next.time || `${pad(new Date().getHours())}:${pad(new Date().getMinutes())}` const dateForClamp = next.date || toISODate(initialDate) return clampTimeToBounds(dateForClamp, candidate, minDateTime, maxDateTime) })() setDraftTime(next.time || fallbackTime) const nextDate = parseDatePart(next.date) if (nextDate) { setViewYear(nextDate.getFullYear()) setViewMonth(nextDate.getMonth()) } }, [value]) const measure = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() const panelWidth = Math.max(rect.width, 320) const panelHeight = 420 const openUp = window.innerHeight - rect.bottom < panelHeight + 8 && rect.top > panelHeight + 8 setDropPos({ top: openUp ? rect.top - panelHeight - 4 : rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - panelWidth - 8), width: panelWidth, }) }, []) const openPicker = useCallback(() => { if (disabled) return measure() setOpen(true) }, [disabled, measure]) useEffect(() => { if (!open) return undefined const handleMouseDown = (event) => { if (!triggerRef.current?.contains(event.target) && !document.getElementById(panelId)?.contains(event.target)) { setOpen(false) } } document.addEventListener('mousedown', handleMouseDown) return () => document.removeEventListener('mousedown', handleMouseDown) }, [open, panelId]) useEffect(() => { if (!open) return undefined const handleScroll = (event) => { if (document.getElementById(panelId)?.contains(event.target)) return setOpen(false) } const handleResize = () => setOpen(false) window.addEventListener('scroll', handleScroll, true) window.addEventListener('resize', handleResize) return () => { window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('resize', handleResize) } }, [open, panelId]) const applyValue = useCallback((date, time) => { if (!date) { onChange?.('') return } onChange?.(mode === 'date' ? date : mergeDateTime(date, time)) }, [mode, onChange]) const handleDateSelect = (nextDate) => { const nextTime = clampTimeToBounds(nextDate, draftTime, minDateTime, maxDateTime) setDraftDate(nextDate) setDraftTime(nextTime) applyValue(nextDate, nextTime) } const handleTimeChange = (event) => { const nextTime = clampTimeToBounds(draftDate, event.target.value, minDateTime, maxDateTime) setDraftTime(nextTime) applyValue(draftDate, nextTime) } const clearValue = (event) => { event.stopPropagation() const now = `${pad(new Date().getHours())}:${pad(new Date().getMinutes())}` setDraftDate('') setDraftTime(now) onChange?.('') } const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11) setViewYear((current) => current - 1) return } setViewMonth((current) => current - 1) } const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0) setViewYear((current) => current + 1) return } setViewMonth((current) => current + 1) } const triggerClass = [ 'relative flex h-[42px] w-full cursor-pointer items-center gap-2 rounded-xl border px-3.5 text-sm transition-all duration-150', 'bg-white/[0.06] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-0', error ? 'border-red-500/60 focus-visible:ring-red-500/40' : open ? 'border-accent/50 ring-2 ring-accent/40' : 'border-white/12 hover:border-white/22', disabled ? 'pointer-events-none cursor-not-allowed opacity-50' : '', className, ].join(' ') const selectedDate = parseDatePart(draftDate) const minDateTimeParts = splitDateTime(minDateTime) const maxDateTimeParts = splitDateTime(maxDateTime) const effectiveMinDate = maxDateValue(minDate, minDateTimeParts.date) const effectiveMaxDate = minDateValue(maxDate, maxDateTimeParts.date) const minTime = draftDate && draftDate === minDateTimeParts.date ? minDateTimeParts.time || undefined : undefined const maxTime = draftDate && draftDate === maxDateTimeParts.date ? maxDateTimeParts.time || undefined : undefined return (
{label && ( )}
{ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() openPicker() } }} > {value ? formatDisplay(value) : effectivePlaceholder} {clearable && value && ( )}
{error &&

{error}

} {!error && hint &&

{hint}

} {open && createPortal(
{MONTH_NAMES[viewMonth]} {viewYear}
Selected date
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
{mode !== 'date' ? ( ) : null}
, document.body, )}
) }