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 && (
)}
{error &&
{error}
}
{!error && hint &&
{hint}
}
{open && createPortal(
{MONTH_NAMES[viewMonth]} {viewYear}
Selected date
{draftDate ? formatDisplay(draftDate) : 'Pick a day'}
{mode !== 'date' ? (
) : null}
,
document.body,
)}
)
}