"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