From 736bb3d0c0445fa327aa70ac8d56ea8dc0a119c2 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Sun, 14 Jun 2026 09:52:49 +0200 Subject: [PATCH] feat: add themed DatePicker, TimePicker, and DateTimePicker primitives so the dashboard never falls back to native browser date/time controls --- web/src/components/ui/DatePicker.tsx | 100 +++++++++++++++++++++++ web/src/components/ui/DateTimePicker.tsx | 62 ++++++++++++++ web/src/components/ui/TimePicker.tsx | 65 +++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 web/src/components/ui/DatePicker.tsx create mode 100644 web/src/components/ui/DateTimePicker.tsx create mode 100644 web/src/components/ui/TimePicker.tsx diff --git a/web/src/components/ui/DatePicker.tsx b/web/src/components/ui/DatePicker.tsx new file mode 100644 index 00000000..44993228 --- /dev/null +++ b/web/src/components/ui/DatePicker.tsx @@ -0,0 +1,100 @@ +// DatePicker — the house date field: a themed trigger + the portaled Calendar +// popover, replacing the native (and its browser-chrome +// calendar) everywhere in the dashboard. Value is a "yyyy-MM-dd" string (the +// same shape the native input used) so it's a drop-in for existing filters. + +import React from "react"; +import { format, parse, isValid } from "date-fns"; +import { CalendarIcon, XIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import Calendar from "@/components/app/Calendar"; + +// Parse "yyyy-MM-dd" as a LOCAL date (avoids the UTC shift new Date("yyyy-MM-dd") +// introduces, which can land the picker a day off in negative-offset timezones). +function parseISODate(v: string): Date | null { + if (!v) return null; + const d = parse(v, "yyyy-MM-dd", new Date()); + return isValid(d) ? d : null; +} + +export function DatePicker({ + value, + onChange, + placeholder = "Any date", + className, + clearable = true, + disabled = false, + display = "MMM d, yyyy", +}: { + /** "yyyy-MM-dd", or "" when unset. */ + value: string; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + clearable?: boolean; + disabled?: boolean; + /** date-fns format for the trigger label. */ + display?: string; +}) { + const [open, setOpen] = React.useState(false); + const wrapRef = React.useRef(null); + const selected = parseISODate(value); + + // Close on outside click. BUBBLE phase (not capture): the Calendar panel is + // portaled to and stops mousedown bubbling, so its own clicks (month + // nav, day select) never reach here and don't dismiss it mid-interaction. + React.useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent | TouchEvent) => { + if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("touchstart", onDown); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("touchstart", onDown); + }; + }, [open]); + + return ( +
+ + setOpen(false)} + onSubmit={(d) => onChange(d ? format(d, "yyyy-MM-dd") : "")} + /> +
+ ); +} + +export default DatePicker; diff --git a/web/src/components/ui/DateTimePicker.tsx b/web/src/components/ui/DateTimePicker.tsx new file mode 100644 index 00000000..3c51b3a7 --- /dev/null +++ b/web/src/components/ui/DateTimePicker.tsx @@ -0,0 +1,62 @@ +// DateTimePicker — the house datetime field, replacing the native +// . It composes the themed DatePicker + TimePicker; +// value is a local "yyyy-MM-ddTHH:mm" string (the exact shape the native input +// produced/consumed), so callers that convert local->ISO keep working unchanged. + +import React from "react"; + +import { cn } from "@/lib/utils"; +import { DatePicker } from "@/components/ui/DatePicker"; +import { TimePicker } from "@/components/ui/TimePicker"; + +function split(v: string): { date: string; time: string } { + if (!v) return { date: "", time: "" }; + const [d, t] = v.split("T"); + return { date: d ?? "", time: (t ?? "").slice(0, 5) }; +} + +function todayISO(): string { + const d = new Date(); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +export function DateTimePicker({ + value, + onChange, + className, + stepMinutes = 30, + defaultTime = "09:00", + disabled = false, + datePlaceholder = "Pick a date", +}: { + /** "yyyy-MM-ddTHH:mm" (local), or "" when unset. */ + value: string; + onChange: (value: string) => void; + className?: string; + stepMinutes?: number; + defaultTime?: string; + disabled?: boolean; + datePlaceholder?: string; +}) { + const { date, time } = split(value); + + const setDate = (d: string) => { + if (!d) { + onChange(""); + return; + } + onChange(`${d}T${time || defaultTime}`); + }; + const setTime = (t: string) => { + onChange(`${date || todayISO()}T${t}`); + }; + + return ( +
+ + +
+ ); +} + +export default DateTimePicker; diff --git a/web/src/components/ui/TimePicker.tsx b/web/src/components/ui/TimePicker.tsx new file mode 100644 index 00000000..c69c3d67 --- /dev/null +++ b/web/src/components/ui/TimePicker.tsx @@ -0,0 +1,65 @@ +// TimePicker — the house time field, replacing the native . +// Built on the themed SelectMenu (no OS dropdown chrome). Value is "HH:mm" (24h), +// the same shape the native input used, so it's a drop-in. + +import React from "react"; +import { SelectMenu } from "@/components/ui/select-menu"; + +// "14:30" -> "2:30 PM" +function fmt12(hhmm: string): string { + const [h, m] = hhmm.split(":").map(Number); + if (Number.isNaN(h) || Number.isNaN(m)) return hhmm; + const period = h < 12 ? "AM" : "PM"; + const h12 = h % 12 === 0 ? 12 : h % 12; + return `${h12}:${String(m).padStart(2, "0")} ${period}`; +} + +export function TimePicker({ + value, + onChange, + stepMinutes = 30, + placeholder = "Any time", + className, + fullWidth = false, + minWidth, + disabled = false, +}: { + /** "HH:mm" (24h), or "" when unset. */ + value: string; + onChange: (value: string) => void; + stepMinutes?: number; + placeholder?: string; + className?: string; + fullWidth?: boolean; + minWidth?: number; + disabled?: boolean; +}) { + const options = React.useMemo(() => { + const out: { value: string; label: string }[] = []; + for (let mins = 0; mins < 24 * 60; mins += stepMinutes) { + const v = `${String(Math.floor(mins / 60)).padStart(2, "0")}:${String(mins % 60).padStart(2, "0")}`; + out.push({ value: v, label: fmt12(v) }); + } + // Keep an off-grid current value (e.g. "09:15" with a 30-min step) selectable. + if (value && !out.some((o) => o.value === value)) { + out.push({ value, label: fmt12(value) }); + out.sort((a, b) => a.value.localeCompare(b.value)); + } + return out; + }, [stepMinutes, value]); + + return ( + + ); +} + +export default TimePicker;