import { useCallback, useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { Calendar as CalIcon, ChevronLeft, ChevronRight, Clock } from "lucide-react"; import { addDays, addMonths, isSameDay, isToday, monthGrid, startOfDay, toLocalDateOnly } from "@/lib/dates"; import { dateInputPlaceholder, formatClock, formatDateInput, formatMonthYear, formatTimeInput, formatWeekday, parseDateInput, parseTimeInput, timeInputPlaceholder, } from "@/lib/datetime"; import { dateTimeKey, useSettings } from "@/store/settings"; import { anchorFromEl, Popover, type Anchor } from "./popover"; /* * Date and time fields that follow the user's configured format. * * Browsers render in their own locale and ignore the * page's, so a German user on an English browser gets mm/dd/yyyy no matter * what the app says. These replace those controls: a text box in the * configured order (see lib/datetime) plus a calendar or time-list popover. * Values in and out keep the native ISO shapes, so they drop straight into * the places the native inputs used to sit. */ /* ------------------------------------------------------------------ */ /* Calendar grid */ /* ------------------------------------------------------------------ */ function CalendarGrid({ selected, onPick, onClose }: { selected: Date | null; onPick: (d: Date) => void; onClose: () => void }) { const weekStart = useSettings((s) => s.settings.weekStart); const [focus, setFocus] = useState(() => startOfDay(selected ?? new Date())); const [anchor, setAnchor] = useState(() => startOfDay(selected ?? new Date())); const gridRef = useRef(null); const grid = useMemo(() => monthGrid(anchor, weekStart), [anchor, weekStart]); const dow = useMemo(() => grid.slice(0, 7).map((d) => formatWeekday(d, "narrow")), [grid]); const move = (to: Date) => { setFocus(to); if (to.getMonth() !== anchor.getMonth() || to.getFullYear() !== anchor.getFullYear()) setAnchor(startOfDay(to)); }; const onKey = (e: KeyboardEvent) => { const keys: Record Date> = { ArrowLeft: () => addDays(focus, -1), ArrowRight: () => addDays(focus, 1), ArrowUp: () => addDays(focus, -7), ArrowDown: () => addDays(focus, 7), PageUp: () => addMonths(focus, -1), PageDown: () => addMonths(focus, 1), Home: () => addDays(focus, -((focus.getDay() - weekStart + 7) % 7)), End: () => addDays(focus, 6 - ((focus.getDay() - weekStart + 7) % 7)), }; const next = keys[e.key]; if (next) { e.preventDefault(); move(next()); return; } if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onPick(focus); } }; // Keep DOM focus on the focused day so screen readers follow the cursor. useEffect(() => { gridRef.current?.querySelector('button[tabindex="0"]')?.focus(); }, [focus]); return (
{formatMonthYear(anchor)}
{grid.map((d) => { const focused = isSameDay(d, focus); return ( ); })}
); } /* ------------------------------------------------------------------ */ /* Time list */ /* ------------------------------------------------------------------ */ const STEP_MINUTES = 30; function TimeList({ selected, onPick }: { selected: Date | null; onPick: (hours: number, minutes: number) => void }) { const listRef = useRef(null); const slots = useMemo(() => { const out: Date[] = []; const base = new Date(2000, 0, 1); for (let m = 0; m < 24 * 60; m += STEP_MINUTES) out.push(new Date(base.getTime() + m * 60_000)); return out; }, []); const currentSlot = selected ? Math.round((selected.getHours() * 60 + selected.getMinutes()) / STEP_MINUTES) : -1; useEffect(() => { listRef.current?.querySelector(".dp-time.selected, .dp-time.near")?.scrollIntoView({ block: "center" }); }, []); return (
{slots.map((t, i) => ( ))}
); } /* ------------------------------------------------------------------ */ /* Fields */ /* ------------------------------------------------------------------ */ interface FieldProps { /** "YYYY-MM-DD" for DateField, "YYYY-MM-DDTHH:MM" for DateTimeField; "" when empty. */ value: string; onChange: (value: string) => void; className?: string; disabled?: boolean; required?: boolean; "aria-label"?: string; id?: string; } function pad(n: number): string { return String(n).padStart(2, "0"); } function toIsoDateTime(d: Date): string { return `${toLocalDateOnly(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`; } /** Shared text-box behaviour: type freely, commit on blur or Enter, revert what won't parse. */ function useTextField(value: string, display: (v: string) => string, commit: (text: string) => boolean) { const [text, setText] = useState(() => display(value)); const [editing, setEditing] = useState(false); const key = useSettings((s) => dateTimeKey(s.settings)); useEffect(() => { if (!editing) setText(display(value)); // `key` re-renders the text when the user changes the date format. }, [value, editing, key]); // eslint-disable-line react-hooks/exhaustive-deps const onBlur = () => { setEditing(false); if (!commit(text)) setText(display(value)); }; return { text, setText, setEditing, onBlur }; } export function DateField({ value, onChange, className, disabled, required, id, ...rest }: FieldProps) { const [anchor, setAnchor] = useState(null); const inputRef = useRef(null); const display = useCallback((v: string) => { if (!v) return ""; const d = new Date(`${v}T00:00:00`); return Number.isNaN(d.getTime()) ? "" : formatDateInput(d); }, []); const commit = useCallback((text: string) => { if (!text.trim()) { onChange(""); return true; } const d = parseDateInput(text); if (!d) return false; onChange(toLocalDateOnly(d)); return true; }, [onChange]); const field = useTextField(value, display, commit); const selected = value ? new Date(`${value}T00:00:00`) : null; const open = () => setAnchor(anchorFromEl(inputRef.current?.parentElement ?? inputRef.current)); return ( { field.setEditing(true); field.setText(e.target.value); }} onBlur={field.onBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); } }} /> {anchor && ( { setAnchor(null); inputRef.current?.focus(); }} role="dialog" className="dp-pop" closeOnClick={false} ariaLabel="Choose a date"> { setAnchor(null); inputRef.current?.focus(); }} onPick={(d) => { onChange(toLocalDateOnly(d)); setAnchor(null); inputRef.current?.focus(); }} /> )} ); } export function DateTimeField({ value, onChange, className, disabled, required, id, ...rest }: FieldProps) { const [anchor, setAnchor] = useState(null); const dateRef = useRef(null); const timeRef = useRef(null); const current = value ? new Date(value) : null; const valid = current && !Number.isNaN(current.getTime()) ? current : null; const setParts = (d: Date) => onChange(toIsoDateTime(d)); const dateDisplay = useCallback((v: string) => (v ? formatDateInput(new Date(v)) : ""), []); const dateCommit = useCallback((text: string) => { if (!text.trim()) { onChange(""); return true; } const d = parseDateInput(text); if (!d) return false; const keep = valid ?? new Date(); d.setHours(keep.getHours(), keep.getMinutes(), 0, 0); setParts(d); return true; }, [onChange, value]); // eslint-disable-line react-hooks/exhaustive-deps const timeDisplay = useCallback((v: string) => (v ? formatTimeInput(new Date(v)) : ""), []); const timeCommit = useCallback((text: string) => { if (!text.trim()) return Boolean(!value); const t = parseTimeInput(text); if (!t) return false; const d = new Date(valid ?? new Date()); d.setHours(t.hours, t.minutes, 0, 0); setParts(d); return true; }, [onChange, value]); // eslint-disable-line react-hooks/exhaustive-deps const dateField = useTextField(value, dateDisplay, dateCommit); const timeField = useTextField(value, timeDisplay, timeCommit); const open = () => setAnchor(anchorFromEl(dateRef.current?.parentElement?.parentElement ?? dateRef.current)); const close = () => { setAnchor(null); dateRef.current?.focus(); }; return ( { dateField.setEditing(true); dateField.setText(e.target.value); }} onBlur={dateField.onBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } if (e.key === "ArrowDown" && !anchor) { e.preventDefault(); open(); } }} /> { timeField.setEditing(true); timeField.setText(e.target.value); }} onBlur={timeField.onBlur} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); (e.target as HTMLInputElement).blur(); } }} /> {anchor && (
{ const keep = valid ?? new Date(); d.setHours(keep.getHours(), keep.getMinutes(), 0, 0); setParts(d); }} /> { const d = new Date(valid ?? new Date()); d.setHours(h, m, 0, 0); setParts(d); close(); }} />
)}
); }