import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { CalendarDays, ChevronLeft } from 'lucide-react' import styles from './StepDatePicker.module.css' type Step = 'year' | 'month' | 'day' interface StepDatePickerProps { id?: string value: string onChange: (isoDate: string) => void disabled?: boolean placeholder?: string } const PANEL_WIDTH = 300 const MONTHS = [ 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', ] as const function parseIsoDate(value: string | null | undefined): { year: number month: number day: number } | null { if (!value?.trim()) return null const match = value.trim().match(/^(\d{4})-(\d{2})-(\d{2})/) if (!match) return null const year = Number(match[1]) const month = Number(match[2]) const day = Number(match[3]) if (!year || month < 1 || month > 12 || day < 1 || day > 31) return null const probe = new Date(year, month - 1, day) if ( probe.getFullYear() !== year || probe.getMonth() !== month - 1 || probe.getDate() !== day ) { return null } return { year, month, day } } function toIsoDate(year: number, month: number, day: number): string { return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}` } function formatDisplayDate(iso: string): string { const parsed = parseIsoDate(iso) if (!parsed) return '' return new Date(parsed.year, parsed.month - 1, parsed.day).toLocaleDateString( 'en-US', { month: 'short', day: 'numeric', year: 'numeric' }, ) } function daysInMonth(year: number, month: number): number { return new Date(year, month, 0).getDate() } function startWeekday(year: number, month: number): number { return new Date(year, month - 1, 1).getDay() } export function StepDatePicker({ id, value, onChange, disabled = false, placeholder = 'Select date', }: StepDatePickerProps) { const autoId = useId() const fieldId = id ?? autoId const rootRef = useRef(null) const panelRef = useRef(null) const [open, setOpen] = useState(false) const [step, setStep] = useState('year') const [panelPos, setPanelPos] = useState<{ top: number; left: number } | null>( null, ) const today = useMemo(() => { const now = new Date() return { year: now.getFullYear(), month: now.getMonth() + 1, day: now.getDate(), } }, []) const selected = parseIsoDate(value) const [draftYear, setDraftYear] = useState(selected?.year ?? today.year) const [draftMonth, setDraftMonth] = useState(selected?.month ?? today.month) const years = useMemo(() => { const start = today.year - 8 const end = today.year + 12 return Array.from({ length: end - start + 1 }, (_, i) => start + i) }, [today.year]) function updatePanelPosition() { const trigger = rootRef.current if (!trigger) return const rect = trigger.getBoundingClientRect() const width = Math.min(PANEL_WIDTH, window.innerWidth - 24) let left = rect.right - width if (left < 12) left = 12 if (left + width > window.innerWidth - 12) { left = Math.max(12, window.innerWidth - 12 - width) } setPanelPos({ top: rect.bottom + 4, left }) } useLayoutEffect(() => { if (!open) { setPanelPos(null) return } updatePanelPosition() const onReposition = () => updatePanelPosition() window.addEventListener('resize', onReposition) window.addEventListener('scroll', onReposition, true) return () => { window.removeEventListener('resize', onReposition) window.removeEventListener('scroll', onReposition, true) } }, [open]) useEffect(() => { if (!open) return const onPointerDown = (event: MouseEvent) => { const target = event.target as Node if (rootRef.current?.contains(target)) return if (panelRef.current?.contains(target)) return setOpen(false) } const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false) } document.addEventListener('mousedown', onPointerDown) document.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('mousedown', onPointerDown) document.removeEventListener('keydown', onKeyDown) } }, [open]) function openPicker() { if (disabled) return const initial = selected ?? today setDraftYear(initial.year) setDraftMonth(initial.month) setStep('year') setOpen(true) } function pickYear(year: number) { setDraftYear(year) setStep('month') } function pickMonth(month: number) { setDraftMonth(month) setStep('day') } function pickDay(day: number) { onChange(toIsoDate(draftYear, draftMonth, day)) setOpen(false) } const display = value ? formatDisplayDate(value) : '' const dayCount = daysInMonth(draftYear, draftMonth) const padStart = startWeekday(draftYear, draftMonth) const highlightDay = selected && selected.year === draftYear && selected.month === draftMonth ? selected.day : draftYear === today.year && draftMonth === today.month ? today.day : null const panel = open && panelPos ? createPortal(
{step !== 'year' ? ( ) : ( )}
Day
{step === 'year' ? (
{years.map((year) => ( ))}
) : null} {step === 'month' ? (
{MONTHS.map((label, index) => { const month = index + 1 return ( ) })}
) : null} {step === 'day' ? (
{['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'].map((d) => ( {d} ))}
{Array.from({ length: padStart }, (_, i) => ( ))} {Array.from({ length: dayCount }, (_, i) => { const day = i + 1 const isSelected = selected?.year === draftYear && selected?.month === draftMonth && selected?.day === day const isToday = draftYear === today.year && draftMonth === today.month && day === today.day return ( ) })}
) : null}
, document.body, ) : null return (
{panel}
) }