import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react' import { createPortal } from 'react-dom' /* ─── Date helpers ────────────────────────────────────────────── */ 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 daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate() } /** Returns 0=Mon … 6=Sun for first day of month */ function firstWeekday(year, month) { const d = new Date(year, month, 1).getDay() // 0=Sun return (d + 6) % 7 // shift so 0=Mon } function toISO(date) { const y = date.getFullYear() const m = String(date.getMonth() + 1).padStart(2, '0') const d = String(date.getDate()).padStart(2, '0') return `${y}-${m}-${d}` } function fromISO(str) { if (!str) return null const [y, m, d] = str.split('-').map(Number) return new Date(y, m - 1, d) } function formatDisplay(isoStr) { if (!isoStr) return '' const d = fromISO(isoStr) if (!d) return '' return `${MONTH_NAMES[d.getMonth()].slice(0, 3)} ${d.getDate()}, ${d.getFullYear()}` } function isSameDay(a, b) { return a?.getFullYear() === b?.getFullYear() && a?.getMonth() === b?.getMonth() && a?.getDate() === b?.getDate() } /* ─── Calendar Grid ───────────────────────────────────────────── */ function CalendarGrid({ year, month, selected, onSelect, minDate, maxDate }) { const numDays = daysInMonth(year, month) const startWd = firstWeekday(year, month) const prevDays = daysInMonth(year, month - 1 < 0 ? 11 : month - 1) const cells = [] // Filler from previous month for (let i = startWd - 1; i >= 0; i--) { cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) }) } // Current month for (let d = 1; d <= numDays; d++) { cells.push({ day: d, current: true, date: new Date(year, month, d) }) } // Next month filler to fill 6 rows let next = 1 while (cells.length % 7 !== 0) { cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) }) } const selectedDate = fromISO(selected) const today = new Date() today.setHours(0, 0, 0, 0) return (
{/* Day-of-week headers */}
{DAY_ABBR.map((d) => (
{d}
))}
{/* Day cells */}
{cells.map((cell, i) => { const iso = toISO(cell.date) const isSelected = isSameDay(cell.date, selectedDate) const isToday = isSameDay(cell.date, today) const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate) return ( ) })}
) } /* ─── DatePicker ──────────────────────────────────────────────── */ /** * Nova DatePicker * * @prop {string} value - ISO date string 'YYYY-MM-DD' or '' * @prop {function} onChange - called with ISO string * @prop {string} label * @prop {string} placeholder * @prop {string} error * @prop {string} hint * @prop {boolean} required * @prop {boolean} clearable * @prop {string} minDate - ISO string lower bound * @prop {string} maxDate - ISO string upper bound */ export default function DatePicker({ value = '', onChange, label, placeholder = 'Pick a date', error, hint, required = false, clearable = false, minDate, maxDate, id, disabled = false, className = '', }) { const [open, setOpen] = useState(false) const [dropPos, setPos] = useState({ top: 0, left: 0, width: 240, openUp: false }) const today = new Date() const initVal = value ? fromISO(value) : today const [viewYear, setYear] = useState(initVal?.getFullYear() ?? today.getFullYear()) const [viewMonth, setMonth] = useState(initVal?.getMonth() ?? today.getMonth()) const triggerRef = useRef(null) const inputId = id ?? (label ? `dp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-picker') const measure = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() const height = 320 const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8 setPos({ top: openUp ? rect.top - height - 4 : rect.bottom + 4, left: rect.left, width: Math.max(rect.width, 280), openUp, }) }, []) const openPicker = () => { if (disabled) return if (value) { const d = fromISO(value) if (d) { setYear(d.getFullYear()); setMonth(d.getMonth()) } } measure() setOpen(true) } // close on outside click useEffect(() => { if (!open) return const handler = (e) => { if ( !triggerRef.current?.contains(e.target) && !document.getElementById(`dp-panel-${inputId}`)?.contains(e.target) ) setOpen(false) } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open, inputId]) useEffect(() => { if (!open) return const onScroll = (e) => { if (document.getElementById(`dp-panel-${inputId}`)?.contains(e.target)) return setOpen(false) } const onResize = () => setOpen(false) window.addEventListener('scroll', onScroll, true) window.addEventListener('resize', onResize) return () => { window.removeEventListener('scroll', onScroll, true) window.removeEventListener('resize', onResize) } }, [open, inputId]) const prevMonth = () => { if (viewMonth === 0) { setMonth(11); setYear((y) => y - 1) } else setMonth((m) => m - 1) } const nextMonth = () => { if (viewMonth === 11) { setMonth(0); setYear((y) => y + 1) } else setMonth((m) => m + 1) } const handleSelect = (iso) => { onChange?.(iso) setOpen(false) } const clearValue = (e) => { e.stopPropagation(); onChange?.('') } const triggerClass = [ 'relative flex items-center h-[42px] rounded-xl border px-3.5 gap-2 cursor-pointer w-full', 'bg-white/[0.06] text-sm transition-all duration-150', '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 ? 'opacity-50 cursor-not-allowed pointer-events-none' : '', className, ].join(' ') return (
{label && ( )}
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openPicker() } }} role="button" aria-label={label ?? placeholder} id={inputId} > {/* Calendar icon */} {value ? formatDisplay(value) : placeholder} {clearable && value && ( )}
{error &&

{error}

} {!error && hint &&

{hint}

} {open && createPortal(
{/* Month nav header */}
{MONTH_NAMES[viewMonth]} {viewYear}
{/* Today shortcut */}
, document.body, )}
) }