mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-09 16:04:41 +00:00
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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user