/** * DatePicker - a date field with a dropdown month calendar. * * A trigger button (Select-styled) shows the chosen date (DD.MM.YYYY) or a * placeholder plus a calendar icon. Opening reveals a month grid: prev/next * chevrons step the month, a Monday-first week, today ringed, the selected day * filled with the accent. Picking a day commits it and closes; a click outside * or Escape closes. Keyboard inside the grid: arrows move day-by-day (crossing * month edges), PageUp/PageDown change month, Enter selects, Escape closes. * * `value` accepts a Date or an ISO "YYYY-MM-DD" string; `onChange` emits an ISO * "YYYY-MM-DD" string. Built on plain JS Date - no third-party date library. * * All colours from the theme; sizes/radii from tokens; both themes. */ import { useEffect, useId, useMemo, useRef, useState } from "react"; import type { CSSProperties, KeyboardEvent } from "react"; import { FS, FW, R, SP } from "@rukin/tokens"; import { useTheme } from "./theme"; import { Icon } from "./Icon"; export type DatePickerProps = { value?: Date | string; onChange?: (iso: string) => void; placeholder?: string; disabled?: boolean; width?: number | string; id?: string; style?: CSSProperties; }; const MONTHS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; const pad = (n: number) => String(n).padStart(2, "0"); const toISO = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; const fmt = (d: Date) => `${pad(d.getDate())}.${pad(d.getMonth() + 1)}.${d.getFullYear()}`; const dayOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()); const addDays = (d: Date, n: number) => new Date(d.getFullYear(), d.getMonth(), d.getDate() + n); const sameDay = (a: Date, b: Date) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); function parseValue(v?: Date | string): Date | null { if (!v) return null; if (v instanceof Date) return isNaN(v.getTime()) ? null : dayOf(v); const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(v); if (m) return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); const d = new Date(v); return isNaN(d.getTime()) ? null : dayOf(d); } /** 42 dates (6 weeks) starting from the Monday on/before the 1st of the month. */ function monthGrid(year: number, month: number): Date[] { const first = new Date(year, month, 1); const mondayIdx = (first.getDay() + 6) % 7; // Mon=0 … Sun=6 const start = addDays(first, -mondayIdx); return Array.from({ length: 42 }, (_, i) => addDays(start, i)); } export function DatePicker({ value, onChange, placeholder = "Pick a date", disabled, width, id, style }: DatePickerProps) { const { C } = useTheme(); const autoId = useId(); const baseId = id ?? autoId; const rootRef = useRef(null); const gridRef = useRef(null); const selected = useMemo(() => parseValue(value), [value]); const today = useMemo(() => dayOf(new Date()), []); const [open, setOpen] = useState(false); // The month currently shown, as {year, month}. const [view, setView] = useState(() => { const base = selected ?? today; return { year: base.getFullYear(), month: base.getMonth() }; }); // The keyboard-focused day (roving focus target inside the grid). const [focus, setFocus] = useState(selected ?? today); // On open, sync view + focus to the selection (or today) and move focus in. useEffect(() => { if (!open) return; const base = selected ?? today; setView({ year: base.getFullYear(), month: base.getMonth() }); setFocus(base); }, [open, selected, today]); // Move DOM focus to the focused day's button whenever it changes while open. useEffect(() => { if (!open) return; const el = gridRef.current?.querySelector(`[data-iso="${toISO(focus)}"]`); el?.focus(); }, [focus, open]); // Close on outside click. useEffect(() => { if (!open) return; const onDoc = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const shiftMonth = (delta: number) => setView((v) => { const d = new Date(v.year, v.month + delta, 1); return { year: d.getFullYear(), month: d.getMonth() }; }); const commit = (d: Date) => { onChange?.(toISO(d)); setOpen(false); }; const moveFocus = (next: Date) => { if (next.getFullYear() !== view.year || next.getMonth() !== view.month) { setView({ year: next.getFullYear(), month: next.getMonth() }); } setFocus(next); }; const onGridKeyDown = (e: KeyboardEvent) => { switch (e.key) { case "ArrowLeft": e.preventDefault(); moveFocus(addDays(focus, -1)); break; case "ArrowRight": e.preventDefault(); moveFocus(addDays(focus, 1)); break; case "ArrowUp": e.preventDefault(); moveFocus(addDays(focus, -7)); break; case "ArrowDown": e.preventDefault(); moveFocus(addDays(focus, 7)); break; case "PageUp": e.preventDefault(); moveFocus(new Date(focus.getFullYear(), focus.getMonth() - 1, focus.getDate())); break; case "PageDown": e.preventDefault(); moveFocus(new Date(focus.getFullYear(), focus.getMonth() + 1, focus.getDate())); break; case "Enter": case " ": e.preventDefault(); commit(focus); break; case "Escape": e.preventDefault(); setOpen(false); break; } }; const grid = monthGrid(view.year, view.month); const trigger: CSSProperties = { width: "100%", boxSizing: "border-box", display: "inline-flex", alignItems: "center", justifyContent: "space-between", gap: SP[2], border: `1.5px solid ${open ? C.accent : disabled ? `${C.muted}55` : C.hairline}`, borderRadius: R.md, padding: "12px 14px", fontSize: FS.base, fontWeight: FW.medium, fontFamily: "inherit", background: disabled ? C.canvas : C.card, color: selected ? (disabled ? C.muted : C.ink) : C.muted, cursor: disabled ? "not-allowed" : "pointer", boxShadow: open ? `0 0 0 3px ${C.accentSoft}` : "none", transition: "border-color .2s, box-shadow .2s", }; return (
{open && (
{/* month header */}
shiftMonth(-1)} icon="chevron-left" /> {MONTHS[view.month] ?? ""} {view.year} shiftMonth(1)} icon="chevron-right" />
{/* weekday row */}
{WEEKDAYS.map((w) => ( {w} ))}
{/* day grid */}
{grid.map((d) => { const outside = d.getMonth() !== view.month; const isSel = selected ? sameDay(d, selected) : false; const isToday = sameDay(d, today); const isFocus = sameDay(d, focus); return ( ); })}
)}
); } function NavBtn({ C, label, onClick, icon, }: { C: Record; label: string; onClick: () => void; icon: "chevron-left" | "chevron-right"; }) { return ( ); }