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(
,
document.body,
)}
)
}