"use client"; import * as React from "react"; import { AnimatePresence, motion } from "framer-motion"; import { IconCalendar, IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; import { cn } from "../lib/utils"; import { Button } from "./button"; import { Popover, PopoverContent, PopoverTrigger } from "./popover"; /** * A month calendar with full keyboard support: arrows move by day/week, * PageUp/PageDown by month, Home/End to the week's edges, Enter selects. * Dates are plain `YYYY-MM-DD` strings, so there are no time-zone traps. */ function toIso(date: Date): string { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function fromIso(value: string): Date { const [year, month, day] = value.split("-").map(Number); return new Date(year ?? 1970, (month ?? 1) - 1, day ?? 1); } function addDays(date: Date, days: number): Date { const next = new Date(date); next.setDate(next.getDate() + days); return next; } function addMonths(date: Date, months: number): Date { const next = new Date(date.getFullYear(), date.getMonth() + months, 1); const lastDay = new Date(next.getFullYear(), next.getMonth() + 1, 0).getDate(); next.setDate(Math.min(date.getDate(), lastDay)); return next; } export function Calendar({ value, onValueChange, min, max, weekStartsOn = 1, className, }: { value: string | null; onValueChange: (value: string) => void; min?: string; max?: string; weekStartsOn?: 0 | 1; className?: string; }) { const today = toIso(new Date()); const [focused, setFocused] = React.useState(() => fromIso(value ?? today)); const [direction, setDirection] = React.useState(0); // Controlled: when the parent changes `value` (another record selected), // show that date. Keyboard moves within the grid don't change `value`, // so they are not undone. const [syncedValue, setSyncedValue] = React.useState(value); if (value !== syncedValue) { setSyncedValue(value); if (value && value !== toIso(focused)) setFocused(fromIso(value)); } // A stable wrapper: during a month transition the outgoing grid unmounts // after the incoming one mounts, so a ref on the grid itself would be // cleared while the new month is showing. const gridRef = React.useRef(null); const month = focused.getMonth(); const year = focused.getFullYear(); const first = new Date(year, month, 1); const offset = (first.getDay() - weekStartsOn + 7) % 7; const start = addDays(first, -offset); const days = Array.from({ length: 42 }, (_, index) => addDays(start, index)); const weekdays = Array.from({ length: 7 }, (_, index) => new Intl.DateTimeFormat(undefined, { weekday: "narrow" }).format(addDays(start, index)), ); const title = new Intl.DateTimeFormat(undefined, { month: "long", year: "numeric" }).format(first); const disabled = (iso: string) => (min !== undefined && iso < min) || (max !== undefined && iso > max); // Focus follows the keyboard in the same commit, so a key pressed right // after an arrow acts on the new day (a frame later it would act on the old one). const pendingFocus = React.useRef(false); React.useLayoutEffect(() => { if (!pendingFocus.current) return; pendingFocus.current = false; gridRef.current ?.querySelector(`[data-month="${year}-${month}"] [data-date="${toIso(focused)}"]`) ?.focus(); }, [focused]); const moveTo = (next: Date, moveFocus = true) => { if (next.getMonth() !== month || next.getFullYear() !== year) { setDirection(next > focused ? 1 : -1); } pendingFocus.current = moveFocus; setFocused(next); }; const dayLabel = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric", year: "numeric" }); return (

{title}

{weekdays.map((weekday, index) => ( ))}
{days.map((day) => { const iso = toIso(day); const inMonth = day.getMonth() === month; const selected = iso === value; const isToday = iso === today; const isFocused = iso === toIso(focused); const off = disabled(iso); return ( ); })}
); } /** A button that opens a calendar; shows the chosen date. */ export function DatePicker({ value, onValueChange, placeholder = "Pick a date", min, max, className, }: { value: string | null; onValueChange: (value: string | null) => void; placeholder?: string; min?: string; max?: string; className?: string; }) { const [open, setOpen] = React.useState(false); const label = value ? new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }).format(fromIso(value)) : placeholder; return ( { onValueChange(next); setOpen(false); }} /> {value ? (
) : null}
); }