"use client"; /** * DatePicker — a calendar popover for picking a single date, no external * deps. Trigger button shows the formatted date (or a placeholder); * clicking opens a month grid with prev/next navigation. Value in/out is * an ISO "YYYY-MM-DD" string ("" = no date). `max`/`min` (also ISO) bound * the selectable range — out-of-range days render disabled. * * The panel is portaled to with fixed positioning and collision * handling: it opens below the trigger, flips above when there isn't room, * and clamps to the viewport horizontally. Position is measured in event * handlers (open click + scroll/resize) — never synchronously inside an * effect — so opening can't trigger React's cascading-render warning. */ import { useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { CalendarDays, ChevronLeft, ChevronRight, X } from "lucide-react"; import { cn } from "@/lib/utils"; const WEEKDAYS = ["Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"]; const PANEL_W = 288; // matches w-72 const PANEL_H = 332; // approximate; used only for the flip decision const GAP = 6; const MARGIN = 8; function pad2(n: number) { return String(n).padStart(2, "0"); } function toISO(y: number, m0: number, d: number) { return `${y}-${pad2(m0 + 1)}-${pad2(d)}`; } /** Parse "YYYY-MM-DD" → {y, m0 (0-based), d}, or null. */ function parseISO(s: string): { y: number; m0: number; d: number } | null { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(s); if (!m) return null; return { y: Number(m[1]), m0: Number(m[2]) - 1, d: Number(m[3]) }; } function prettyDate(s: string): string { const p = parseISO(s); if (!p) return ""; return new Date(p.y, p.m0, p.d).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric", }); } export function DatePicker({ value, onChange, max, min, id, ariaLabel, placeholder = "Select a date", }: { value: string; onChange: (value: string) => void; max?: string; min?: string; id?: string; ariaLabel?: string; placeholder?: string; }) { const [open, setOpen] = useState(false); // Either `top` (opens below, anchored to the trigger's bottom) or // `bottom` (opens above, anchored just over the trigger's top) is set — // anchoring the relevant edge keeps the panel flush to the trigger // regardless of its actual height. `maxHeight` clamps it to the // available space (scrolls if it ever can't fit). const [pos, setPos] = useState<{ left: number; top?: number; bottom?: number; maxHeight: number; } | null>(null); const triggerRef = useRef(null); const panelRef = useRef(null); const today = new Date(); const todayISO = toISO(today.getFullYear(), today.getMonth(), today.getDate()); const initial = parseISO(value) ?? { y: today.getFullYear(), m0: today.getMonth(), d: today.getDate(), }; const [view, setView] = useState<{ y: number; m0: number }>({ y: initial.y, m0: initial.m0, }); // Measure the trigger and place the panel flush to it — below by // default, flipped above only when below lacks room and above has more. // Called from event handlers only (open click + scroll/resize). function recompute() { const btn = triggerRef.current; if (!btn) return; const rect = btn.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; let left = rect.left; if (left + PANEL_W > vw - MARGIN) left = vw - MARGIN - PANEL_W; if (left < MARGIN) left = MARGIN; const spaceBelow = vh - rect.bottom - GAP - MARGIN; const spaceAbove = rect.top - GAP - MARGIN; if (spaceBelow >= PANEL_H || spaceBelow >= spaceAbove) { // Below: top edge anchored just under the trigger. setPos({ left, top: rect.bottom + GAP, maxHeight: Math.max(180, spaceBelow) }); } else { // Above: bottom edge anchored just over the trigger (grows upward), // so it stays adjacent no matter how tall the calendar is. setPos({ left, bottom: vh - rect.top + GAP, maxHeight: Math.max(180, spaceAbove) }); } } // Outside-click + Escape + reposition on scroll/resize, while open. useEffect(() => { if (!open) return; function onPointer(e: PointerEvent) { if (triggerRef.current?.contains(e.target as Node)) return; if (panelRef.current?.contains(e.target as Node)) return; setOpen(false); } function onKey(e: KeyboardEvent) { if (e.key === "Escape") setOpen(false); } document.addEventListener("pointerdown", onPointer); document.addEventListener("keydown", onKey); window.addEventListener("resize", recompute); window.addEventListener("scroll", recompute, true); return () => { document.removeEventListener("pointerdown", onPointer); document.removeEventListener("keydown", onKey); window.removeEventListener("resize", recompute); window.removeEventListener("scroll", recompute, true); }; }, [open]); function openMenu() { const p = parseISO(value); if (p) setView({ y: p.y, m0: p.m0 }); recompute(); setOpen(true); } function toggle() { if (open) setOpen(false); else openMenu(); } function shiftMonth(delta: number) { setView((v) => { const d = new Date(v.y, v.m0 + delta, 1); return { y: d.getFullYear(), m0: d.getMonth() }; }); } function pick(day: number) { onChange(toISO(view.y, view.m0, day)); setOpen(false); } const monthLabel = new Date(view.y, view.m0, 1).toLocaleDateString(undefined, { month: "long", year: "numeric", }); const startWeekday = new Date(view.y, view.m0, 1).getDay(); const daysInMonth = new Date(view.y, view.m0 + 1, 0).getDate(); const cells: (number | null)[] = [ ...Array(startWeekday).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1), ]; function disabled(day: number) { const iso = toISO(view.y, view.m0, day); if (max && iso > max) return true; if (min && iso < min) return true; return false; } return ( <> {open && pos && typeof document !== "undefined" ? createPortal(
{monthLabel}
{WEEKDAYS.map((w) => (
{w}
))}
{cells.map((day, i) => { if (day === null) return
; const iso = toISO(view.y, view.m0, day); const isSelected = iso === value; const isToday = iso === todayISO; const isDisabled = disabled(day); return ( ); })}
, document.body, ) : null} ); }