feat: add themed DatePicker, TimePicker, and DateTimePicker primitives so the dashboard never falls back to native browser date/time controls

This commit is contained in:
Matthew Meszaros
2026-06-14 09:52:49 +02:00
parent e24c75a7db
commit 736bb3d0c0
3 changed files with 227 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
// DatePicker — the house date field: a themed trigger + the portaled Calendar
// popover, replacing the native <input type="date"> (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<HTMLDivElement>(null);
const selected = parseISODate(value);
// Close on outside click. BUBBLE phase (not capture): the Calendar panel is
// portaled to <body> 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 (
<div ref={wrapRef} className={cn("relative inline-flex", className)}>
<button
type="button"
disabled={disabled}
onClick={() => setOpen((o) => !o)}
className={cn(
"h-7 w-full px-2 rounded-md border bg-white inline-flex items-center gap-1.5 text-[12px] transition-colors disabled:opacity-60 disabled:cursor-not-allowed",
open ? "border-sky-400 ring-2 ring-sky-100" : "border-slate-200 hover:border-slate-300",
)}
>
<CalendarIcon className="w-3 h-3 text-slate-400 shrink-0" />
<span className={cn("truncate flex-1 text-left", selected ? "text-slate-900" : "text-slate-400")}>
{selected ? format(selected, display) : placeholder}
</span>
{clearable && value && (
<span
role="button"
tabIndex={-1}
aria-label="Clear date"
onClick={(e) => {
e.stopPropagation();
onChange("");
}}
className="text-slate-400 hover:text-slate-700 shrink-0"
>
<XIcon className="w-3 h-3" />
</span>
)}
</button>
<Calendar
date={selected}
active={open}
close={() => setOpen(false)}
onSubmit={(d) => onChange(d ? format(d, "yyyy-MM-dd") : "")}
/>
</div>
);
}
export default DatePicker;
+62
View File
@@ -0,0 +1,62 @@
// DateTimePicker — the house datetime field, replacing the native
// <input type="datetime-local">. 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 (
<div className={cn("inline-flex items-center gap-1.5", className)}>
<DatePicker value={date} onChange={setDate} placeholder={datePlaceholder} clearable={false} disabled={disabled} />
<TimePicker value={time} onChange={setTime} stepMinutes={stepMinutes} disabled={disabled} placeholder="Time" />
</div>
);
}
export default DateTimePicker;
+65
View File
@@ -0,0 +1,65 @@
// TimePicker — the house time field, replacing the native <input type="time">.
// 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 (
<SelectMenu
value={value}
onChange={onChange}
options={options}
placeholder={placeholder}
className={className}
fullWidth={fullWidth}
minWidth={minWidth ?? 140}
disabled={disabled}
/>
);
}
export default TimePicker;