"use client"; import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent as ReactPointerEvent } from "react"; import { useMotionValueEvent, useReducedMotion, useSpring } from "motion/react"; type TimeDialProps = { /** Read out by screen readers. */ label?: string; /** Time (controlled), "HH:MM" in 24 hours. */ value?: string; /** Time at start (uncontrolled), "HH:MM" in 24 hours. */ defaultValue?: string; /** Minutes moved by one drag step or arrow key. */ step?: number; /** Called with "HH:MM" in 24 hours, like a time input. */ onChange?: (time: string) => void; /** Form field name. Sends "HH:MM" in 24 hours. */ name?: string; /** Set the width here. Everything scales with it. */ className?: string; }; const CX = 180; const CY = 192; const DAY = 1440; const ROMAN = ["XII", "I", "II", "III", "IV", "V", "VI", "VII", "VIII", "IX", "X", "XI"]; const TICKS = Array.from({ length: 60 }, (_, i) => i); const parse = (time: string) => { const [h, m] = time.split(":").map(Number); return ((h * 60 + m) % DAY + DAY) % DAY; }; const pad = (n: number) => String(n).padStart(2, "0"); const to24 = (t: number) => `${pad(Math.floor(t / 60))}:${pad(t % 60)}`; const to12 = (t: number) => `${Math.floor(t / 60) % 12 || 12}:${pad(t % 60)}`; const half = (t: number) => (t < 720 ? "AM" : "PM"); const wrap = (d: number, span: number) => (d > span / 2 ? d - span : d < -span / 2 ? d + span : d); const handTransform = (angle: number) => `rotate(${angle.toFixed(2)} ${CX} ${CY})`; /* TimeDial: a brass pocket watch that is also a time field. Drag either hand; the minute hand carries the hour past twelve, and a painted sky turns behind the arch so the sun comes up and the moon comes out with the hours. */ export function TimeDial({ label = "Time", value, defaultValue = "10:10", step = 5, onChange, name, className = "w-full max-w-xs" }: TimeDialProps) { const reduce = useReducedMotion(); const id = useId().replace(/:/g, ""); const svg = useRef(null); const hourHand = useRef(null); const minuteHand = useRef(null); const hourGroup = useRef(null); const minuteGroup = useRef(null); const sky = useRef(null); const [inner, setInner] = useState(() => parse(defaultValue)); const t = value !== undefined ? parse(value) : inner; const latest = useRef(t); const shown = useSpring(t, reduce ? { stiffness: 10000, damping: 500 } : { stiffness: 260, damping: 19 }); useEffect(() => { latest.current = t; // Go the short way round, so 23:55 to 00:00 steps forward and does not unwind the day. const now = shown.get(); shown.set(now + wrap((((t - now) % DAY) + DAY) % DAY, DAY)); }, [t, shown]); useMotionValueEvent(shown, "change", (v) => { hourHand.current?.setAttribute("transform", handTransform(v * 0.5)); minuteHand.current?.setAttribute("transform", handTransform(v * 6)); sky.current?.setAttribute("transform", `rotate(${(v * 0.25 - 180).toFixed(2)} ${CX} 150)`); }); const set = (next: number) => { const fixed = ((next % DAY) + DAY) % DAY; latest.current = fixed; if (value === undefined) setInner(fixed); onChange?.(to24(fixed)); }; const grab = (e: ReactPointerEvent) => { if (e.button !== 0 || !svg.current) return; const box = svg.current.getBoundingClientRect(); const scale = 360 / box.width; const at = (x: number, y: number) => ({ dx: (x - box.left) * scale - CX, dy: (y - box.top) * scale - CY }); const start = at(e.clientX, e.clientY); const distance = Math.hypot(start.dx, start.dy); if (distance > 160) return; const hour = distance < 82; e.preventDefault(); (hour ? hourGroup : minuteGroup).current?.focus({ preventScroll: true }); const turn = (x: number, y: number) => { const { dx, dy } = at(x, y); const degrees = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360; const cur = latest.current; const d = hour ? wrap(Math.round(degrees / 30 - (cur % 60) / 60) - (Math.floor(cur / 60) % 12), 12) * 60 : wrap((Math.round(degrees / 6 / step) * step) % 60 - cur % 60, 60); if (d) set(cur + d); }; const move = (ev: PointerEvent) => turn(ev.clientX, ev.clientY); const stop = () => { window.removeEventListener("pointermove", move); window.removeEventListener("pointerup", stop); window.removeEventListener("pointercancel", stop); }; window.addEventListener("pointermove", move); window.addEventListener("pointerup", stop); window.addEventListener("pointercancel", stop); turn(e.clientX, e.clientY); }; const keys = (e: KeyboardEvent, by: number) => { const d = { ArrowRight: by, ArrowUp: by, ArrowLeft: -by, ArrowDown: -by, PageUp: 60, PageDown: -60 }[e.key]; if (!d) return; e.preventDefault(); set(t + d); }; const ring = "pointer-events-none opacity-0 group-focus-visible:opacity-100"; const handle = "group"; const text = `${to12(t)} ${half(t)}`; return (
{TICKS.map((i) => ( ))} {ROMAN.map((numeral, i) => ( {numeral} ))} {to12(t)} {half(t)} keys(e, 60)} style={{ outline: "none" }} className={handle} > keys(e, step)} style={{ outline: "none" }} className={handle} >
); }