import React, { useCallback, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' /* ─── Date helpers (duplicated locally so component is self-contained) ─ */ 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() } function firstWeekday(year, month) { return (new Date(year, month, 1).getDay() + 6) % 7 } function toISO(date) { return `${date.getFullYear()}-${String(date.getMonth()+1).padStart(2,'0')}-${String(date.getDate()).padStart(2,'0')}` } function fromISO(str) { if (!str) return null const [y,m,d] = str.split('-').map(Number) return new Date(y, m-1, d) } function fmt(isoStr) { if (!isoStr) return '' const d = fromISO(isoStr) return d ? `${MONTH_NAMES[d.getMonth()].slice(0,3)} ${d.getDate()}, ${d.getFullYear()}` : '' } function isSameDay(a, b) { return !!a && !!b && a.getFullYear()===b.getFullYear() && a.getMonth()===b.getMonth() && a.getDate()===b.getDate() } /* ─── Single month grid ─────────────────────────────────────────── */ function MonthGrid({ year, month, start, end, hover, onHover, 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 = [] for (let i = startWd - 1; i >= 0; i--) cells.push({ day: prevDays - i, current: false, date: new Date(year, month - 1, prevDays - i) }) for (let d = 1; d <= numDays; d++) cells.push({ day: d, current: true, date: new Date(year, month, d) }) let next = 1 while (cells.length % 7 !== 0) cells.push({ day: next++, current: false, date: new Date(year, month + 1, next - 1) }) const startDate = fromISO(start) const endDate = fromISO(end) const hoverDate = hover ? fromISO(hover) : null const today = new Date(); today.setHours(0,0,0,0) // Range boundary to highlight (end could be hover while selecting) const rangeEnd = endDate ?? hoverDate return (
{DAY_ABBR.map((d) => (
{d}
))}
{cells.map((cell, i) => { const iso = toISO(cell.date) const isStart = isSameDay(cell.date, startDate) const isEnd = isSameDay(cell.date, endDate) const isToday = isSameDay(cell.date, today) const disabled = (minDate && iso < minDate) || (maxDate && iso > maxDate) // In range? let inRange = false if (startDate && rangeEnd) { const lo = startDate <= rangeEnd ? startDate : rangeEnd const hi = startDate <= rangeEnd ? rangeEnd : startDate inRange = cell.date > lo && cell.date < hi } const isEdge = isStart || isEnd return ( ) })}
) } /* ─── DateRangePicker ────────────────────────────────────────────── */ /** * Nova DateRangePicker * * @prop {string} start - ISO 'YYYY-MM-DD' * @prop {string} end - ISO 'YYYY-MM-DD' * @prop {function} onChange - called with { start, end } * @prop {string} label * @prop {string} placeholder * @prop {string} error * @prop {string} hint * @prop {boolean} required * @prop {boolean} clearable * @prop {string} minDate * @prop {string} maxDate */ export default function DateRangePicker({ start = '', end = '', onChange, label, placeholder = 'Select date range', 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: 480 }) const [hover, setHover] = useState('') // Selecting state: if we have a start but no end, next click sets end const [picking, setPicking] = useState(null) // null | 'start' | 'end' // View months: left and right panels const today = new Date() const [lYear, setLYear] = useState(today.getFullYear()) const [lMonth, setLMonth] = useState(today.getMonth() === 0 ? 11 : today.getMonth() - 1) // right panel = left + 1 const rYear = lMonth === 11 ? lYear + 1 : lYear const rMonth = lMonth === 11 ? 0 : lMonth + 1 const triggerRef = useRef(null) const inputId = id ?? (label ? `drp-${label.toLowerCase().replace(/\s+/g, '-')}` : 'date-range') const measure = useCallback(() => { if (!triggerRef.current) return const rect = triggerRef.current.getBoundingClientRect() const panelW = Math.max(rect.width, 480) const height = 340 const openUp = window.innerHeight - rect.bottom < height + 8 && rect.top > height + 8 setPos({ top: openUp ? rect.top - height - 4 : rect.bottom + 4, left: Math.min(rect.left, window.innerWidth - panelW - 8), width: panelW, }) }, []) const openPicker = () => { if (disabled) return; measure(); setOpen(true) } useEffect(() => { if (!open) return const handler = (e) => { if ( !triggerRef.current?.contains(e.target) && !document.getElementById(`drp-panel-${inputId}`)?.contains(e.target) ) { setOpen(false); setPicking(null) } } document.addEventListener('mousedown', handler) return () => document.removeEventListener('mousedown', handler) }, [open, inputId]) useEffect(() => { if (!open) return const onScroll = (e) => { if (document.getElementById(`drp-panel-${inputId}`)?.contains(e.target)) return setOpen(false); setPicking(null) } const onResize = () => { setOpen(false); setPicking(null) } window.addEventListener('scroll', onScroll, true) window.addEventListener('resize', onResize) return () => { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onResize) } }, [open, inputId]) const handleSelect = (iso) => { if (!start || picking === 'start' || (start && end)) { // Start fresh onChange?.({ start: iso, end: '' }) setPicking('end') setHover('') } else { // We have start, picking end const s = start < iso ? start : iso const e = start < iso ? iso : start onChange?.({ start: s, end: e }) setPicking(null) setOpen(false) } } const clearValue = (ev) => { ev.stopPropagation(); onChange?.({ start:'', end:'' }); setPicking(null) } const prevLeft = () => { if (lMonth === 0) { setLMonth(11); setLYear(y => y - 1) } else setLMonth(m => m - 1) } const nextLeft = () => { if (lMonth === 11) { setLMonth(0); setLYear(y => y + 1) } else setLMonth(m => m + 1) } const displayText = start ? `${fmt(start)} – ${end ? fmt(end) : '…'}` : '' 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 */} {displayText || placeholder} {clearable && (start || end) && ( )}
{error &&

{error}

} {!error && hint &&

{hint}

} {open && createPortal(
setHover('')} > {/* Header info */} {picking === 'end' && (
Now click an end date
)} {/* Two calendars */}
{/* Left month */}
{MONTH_NAMES[lMonth]} {lYear}
{/* Right month */}
{MONTH_NAMES[rMonth]} {rYear}
{/* Footer with preset shortcuts */}
{[ { label: 'Last 7 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-6*864e5)); onChange?.({start:s,end:e}); setOpen(false) } }, { label: 'Last 30 days', fn: () => { const e=toISO(new Date()); const s=toISO(new Date(Date.now()-29*864e5)); onChange?.({start:s,end:e}); setOpen(false) } }, { label: 'This month', fn: () => { const n=new Date(); const s=toISO(new Date(n.getFullYear(),n.getMonth(),1)); const e=toISO(new Date(n.getFullYear(),n.getMonth()+1,0)); onChange?.({start:s,end:e}); setOpen(false) } }, ].map((p) => ( ))}
, document.body, )}
) }