"use client"; import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight, Clock3, } from "lucide-react"; import { motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, useId, useLayoutEffect, useRef, useState, } from "react"; import { Button } from "@/components/motion/button/base"; import { DAY, iso, monthOf, parse, shiftMonth, } from "@/components/motion/date-range-picker/date-utils"; import { MorphPopover, MorphPopoverContent, MorphPopoverTrigger, } from "@/components/motion/popover-morph"; import { WheelPicker } from "@/components/motion/wheel-picker"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type DateTimePickerClassNames = { trigger?: string; content?: string; calendar?: string; day?: string; selectedDay?: string; time?: string; confirmButton?: string; }; export interface DateTimePickerProps { /** Local wall-clock time, YYYY-MM-DDTHH:mm. No timezone conversion is applied. */ value?: string; defaultValue?: string; /** Called only when Confirm is pressed. Dismissing discards the draft. */ onValueChange?: (value: string) => void; label?: string; disabled?: boolean; name?: string; className?: string; classNames?: DateTimePickerClassNames; } const pad = (value: number) => String(value).padStart(2, "0"); const HOURS = Array.from({ length: 12 }, (_, index) => pad(index + 1)); const MINUTES = Array.from({ length: 60 }, (_, index) => pad(index)); const dateFormat = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC", }); const monthFormat = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC", }); const dayFormat = new Intl.DateTimeFormat("en-US", { dateStyle: "full", timeZone: "UTC", }); const focusClass = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"; function localNow() { const now = new Date(); return `${String(now.getFullYear()).padStart(4, "0")}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}`; } function validate(value: string) { if (!/^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d$/.test(value)) throw new Error( "DateTimePicker expects local time in YYYY-MM-DDTHH:mm format.", ); parse(value.slice(0, 10)); return value; } function timeLabel(value: string) { const hour = Number(value.slice(11, 13)); return `${pad(hour % 12 || 12)}:${value.slice(14)} ${hour < 12 ? "AM" : "PM"}`; } /** Calendar and time-wheel popover with an explicit draft/confirm boundary. */ export function DateTimePicker({ value: controlledValue, defaultValue = "", onValueChange, label = "Choose date and time", disabled = false, name, className, classNames, }: DateTimePickerProps) { const [internal, setInternal] = useState(defaultValue); const value = controlledValue ?? internal; if (value) validate(value); const [open, setOpen] = useState(false); const [draft, setDraft] = useState(""); const [session, setSession] = useState(0); const trigger = useRef(null); const changeOpen = (next: boolean) => { if (disabled) return; if (next) { setDraft(value || localNow()); setSession((current) => current + 1); } setOpen(next); }; return ( {name && ( )} content .querySelector('[data-calendar-day][tabindex="0"]') ?.focus({ preventScroll: true }) } > {draft && ( { if (controlledValue === undefined) setInternal(next); onValueChange?.(next); setOpen(false); trigger.current?.focus({ preventScroll: true }); }} /> )} ); } function DateTimePanel({ initialValue, onConfirm, classNames, }: { initialValue: string; onConfirm: (value: string) => void; classNames?: DateTimePickerClassNames; }) { const [draft, setDraft] = useState(initialValue); const [cursor, setCursor] = useState(initialValue.slice(0, 10)); const reduce = useReducedMotion() ?? false; const id = useId(); const calendar = useRef(null); const moveFocus = useRef(true); const date = draft.slice(0, 10); const month = monthOf(cursor); const hour = Number(draft.slice(11, 13)); const period = hour < 12 ? "AM" : "PM"; const first = parse(month); const start = first - ((new Date(first).getUTCDay() + 6) % 7) * DAY; const daysInView = Math.ceil((parse(shiftMonth(month, 1)) - start) / DAY / 7) * 7; const days = Array.from({ length: daysInView }, (_, index) => iso(start + index * DAY), ); useLayoutEffect(() => { if (!moveFocus.current) return; calendar.current ?.querySelector(`[data-calendar-day="${cursor}"]`) ?.focus({ preventScroll: true }); moveFocus.current = false; }, [cursor]); const setHour = (next: number) => setDraft( (current) => `${current.slice(0, 11)}${pad(next)}${current.slice(13)}`, ); const navigate = (event: KeyboardEvent) => { const offsets: Record = { ArrowLeft: -1, ArrowRight: 1, ArrowUp: -7, ArrowDown: 7, }; const key = event.key; if ( !(key in offsets) && !["Home", "End", "PageUp", "PageDown"].includes(key) ) return; event.preventDefault(); moveFocus.current = true; setCursor((current) => { if (key === "PageUp" || key === "PageDown") return shiftMonth( current, (key === "PageUp" ? -1 : 1) * (event.shiftKey ? 12 : 1), ); const weekday = (new Date(parse(current)).getUTCDay() + 6) % 7; return iso( parse(current) + (key === "Home" ? -weekday : key === "End" ? 6 - weekday : offsets[key]) * DAY, ); }); }; return (

{monthFormat.format(first)}

Use arrow keys for days, Home and End for the week, Page Up and Page Down for months, or hold Shift for years. Enter selects a date. Confirm saves the date and time; Escape discards changes.

{["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"].map((day) => ( {day} ))} {Array.from({ length: daysInView / 7 }, (_, week) => ( {days.slice(week * 7, week * 7 + 7).map((day) => ( // biome-ignore lint/a11y/useFocusableInteractive: Calendar cells delegate focus to the contained day button. { moveFocus.current = true; setCursor(day); setDraft((current) => `${day}${current.slice(10)}`); }} whileTap={reduce ? undefined : { scale: 0.92 }} transition={SPRING_PRESS} className={cn( "relative mx-auto flex size-8 items-center justify-center rounded-xl text-xs tabular-nums transition-colors", focusClass, day === date ? "bg-foreground font-medium text-background shadow-sm" : "hover:bg-muted", day !== date && monthOf(day) !== month && "text-muted-foreground/40", classNames?.day, day === date && classNames?.selectedDay, )} > {Number(day.slice(8))} ))} ))}

Time

{reduce ? ( ) : ( setHour((Number(next) % 12) + (period === "PM" ? 12 : 0)) } itemHeight={32} className="flex-1 rounded-lg border-0 bg-transparent text-base" /> )} {reduce ? ( ) : ( setDraft((current) => `${current.slice(0, 14)}${next}`) } itemHeight={32} className="flex-1 rounded-lg border-0 bg-transparent text-base" /> )}
{["AM", "PM"].map((next) => ( ))}
Local time ยท{" "} {new Intl.DateTimeFormat("en-US", { timeZoneName: "shortOffset" }) .formatToParts(new Date(draft)) .find((part) => part.type === "timeZoneName") ?.value.replace("GMT", "UTC")}
); }