{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "scrub-datetime", "type": "registry:ui", "title": "Scrub Datetime", "description": "An inline date-time picker where the text IS the interface — drag any segment horizontally to scrub, scroll to increment, click AM/PM to toggle.", "files": [ { "path": "registry/ruixenui/scrub-datetime.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useEffect } from \"react\";\n\n/**\n * Scrub Datetime — Rauno Freiberg craft.\n *\n * The text IS the interface. No chrome, no popover, no calendar grid.\n * Every segment of the date-time string is independently scrubable —\n * hover to reveal the affordance (ew-resize cursor, subtle underline),\n * drag horizontally to change value, scroll to increment.\n * Click AM/PM to toggle.\n * Soft mechanical tick on each step.\n * Day auto-clamps when month or year changes (leap-year safe).\n * Supports controlled (value) and uncontrolled (defaultValue) modes.\n */\n\n/* ── Constants ── */\n\nconst PX_PER_STEP = 18;\nconst SCROLL_THRESHOLD = 45;\n\nconst MONTHS = [\n \"January\",\n \"February\",\n \"March\",\n \"April\",\n \"May\",\n \"June\",\n \"July\",\n \"August\",\n \"September\",\n \"October\",\n \"November\",\n \"December\",\n];\n\n/* ── Types ── */\n\ninterface ScrubDatetimeProps {\n value?: Date;\n defaultValue?: Date;\n onChange?: (date: Date) => void;\n sound?: boolean;\n minYear?: number;\n maxYear?: number;\n}\n\n/* ── Audio — soft mechanical tick ── */\n\nlet _ctx: AudioContext | null = null;\nlet _clickBuf: AudioBuffer | null = null;\n\nfunction audioCtx() {\n if (!_ctx) {\n _ctx = new (window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext)();\n }\n if (_ctx.state === \"suspended\") _ctx.resume();\n return _ctx;\n}\n\nfunction clickBuffer(ac: AudioContext): AudioBuffer {\n if (_clickBuf && _clickBuf.sampleRate === ac.sampleRate) return _clickBuf;\n\n const rate = ac.sampleRate;\n const len = Math.floor(rate * 0.003); // 3ms — lighter than drum\n const buf = ac.createBuffer(1, len, rate);\n const ch = buf.getChannelData(0);\n\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n\n _clickBuf = buf;\n return buf;\n}\n\nfunction playTick(lastTime: React.MutableRefObject) {\n const now = performance.now();\n if (now - lastTime.current < 20) return;\n lastTime.current = now;\n\n try {\n const ac = audioCtx();\n const buf = clickBuffer(ac);\n\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n\n src.buffer = buf;\n src.playbackRate.value = 1.1;\n gain.gain.value = 0.07;\n\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent fallback */\n }\n}\n\n/* ── Helpers ── */\n\nfunction clamp(v: number, lo: number, hi: number) {\n return Math.max(lo, Math.min(hi, v));\n}\n\nfunction maxDays(month: number, year: number) {\n return new Date(year, month + 1, 0).getDate();\n}\n\n/* ── Segment — scrubable inline value ── */\n\nfunction Segment({\n display,\n value,\n min,\n max,\n onChange,\n sound,\n lastSoundTime,\n}: {\n display: string;\n value: number;\n min: number;\n max: number;\n onChange: (v: number) => void;\n sound: boolean;\n lastSoundTime: React.MutableRefObject;\n}) {\n const [hovered, setHovered] = useState(false);\n const [dragging, setDragging] = useState(false);\n const dragRef = useRef({ startX: 0, startVal: 0 });\n const scrollAccum = useRef(0);\n const elRef = useRef(null);\n const prevVal = useRef(value);\n\n const onDown = useCallback(\n (e: React.PointerEvent) => {\n e.preventDefault();\n e.currentTarget.setPointerCapture(e.pointerId);\n dragRef.current = { startX: e.clientX, startVal: value };\n prevVal.current = value;\n setDragging(true);\n },\n [value],\n );\n\n const onMove = useCallback(\n (e: React.PointerEvent) => {\n if (!dragging) return;\n const dx = e.clientX - dragRef.current.startX;\n const steps = Math.round(dx / PX_PER_STEP);\n const next = clamp(dragRef.current.startVal + steps, min, max);\n if (next !== prevVal.current) {\n if (sound) playTick(lastSoundTime);\n prevVal.current = next;\n onChange(next);\n }\n },\n [dragging, min, max, sound, lastSoundTime, onChange],\n );\n\n const onUp = useCallback(() => setDragging(false), []);\n\n useEffect(() => {\n const el = elRef.current;\n if (!el) return;\n const handler = (e: WheelEvent) => {\n e.preventDefault();\n scrollAccum.current += e.deltaY;\n if (Math.abs(scrollAccum.current) >= SCROLL_THRESHOLD) {\n const dir = Math.sign(scrollAccum.current);\n scrollAccum.current = 0;\n const next = clamp(value + dir, min, max);\n if (next !== value) {\n if (sound) playTick(lastSoundTime);\n onChange(next);\n }\n }\n };\n el.addEventListener(\"wheel\", handler, { passive: false });\n return () => el.removeEventListener(\"wheel\", handler);\n }, [value, min, max, sound, lastSoundTime, onChange]);\n\n useEffect(() => {\n prevVal.current = value;\n }, [value]);\n\n const alpha = dragging ? 0.95 : hovered ? 0.75 : 0.5;\n\n return (\n setHovered(true)}\n onPointerLeave={() => setHovered(false)}\n >\n {display}\n \n );\n}\n\n/* ── Period Toggle — click to flip AM ↔ PM ── */\n\nfunction PeriodToggle({\n isPM,\n onToggle,\n sound,\n lastSoundTime,\n}: {\n isPM: boolean;\n onToggle: () => void;\n sound: boolean;\n lastSoundTime: React.MutableRefObject;\n}) {\n const [hovered, setHovered] = useState(false);\n\n return (\n {\n if (sound) playTick(lastSoundTime);\n onToggle();\n }}\n onPointerEnter={() => setHovered(true)}\n onPointerLeave={() => setHovered(false)}\n >\n {isPM ? \"PM\" : \"AM\"}\n \n );\n}\n\n/* ── Static separator ── */\n\nfunction Sep({ children }: { children: string }) {\n return (\n \n {children}\n \n );\n}\n\n/* ── Component ── */\n\nexport function ScrubDatetime({\n value: controlledValue,\n defaultValue,\n onChange,\n sound = true,\n minYear = 2015,\n maxYear = 2035,\n}: ScrubDatetimeProps) {\n const [internal, setInternal] = useState(() => defaultValue ?? new Date());\n const isControlled = controlledValue !== undefined;\n const date = isControlled ? controlledValue : internal;\n const lastSoundTime = useRef(0);\n\n const update = useCallback(\n (d: Date) => {\n if (!isControlled) setInternal(d);\n onChange?.(d);\n },\n [isControlled, onChange],\n );\n\n /* Extract parts */\n const month = date.getMonth();\n const day = date.getDate();\n const year = date.getFullYear();\n const hours24 = date.getHours();\n const minute = date.getMinutes();\n const isPM = hours24 >= 12;\n const hour12 = hours24 % 12 || 12;\n const dayMax = maxDays(month, year);\n\n /* Setters — safe against month-overflow */\n const setMonth = useCallback(\n (m: number) => {\n const d = new Date(date);\n d.setDate(1);\n d.setMonth(m);\n d.setDate(Math.min(day, maxDays(m, year)));\n update(d);\n },\n [date, day, year, update],\n );\n\n const setDay = useCallback(\n (v: number) => {\n const d = new Date(date);\n d.setDate(v);\n update(d);\n },\n [date, update],\n );\n\n const setYear = useCallback(\n (y: number) => {\n const d = new Date(date);\n d.setDate(1);\n d.setFullYear(y);\n d.setDate(Math.min(day, maxDays(month, y)));\n update(d);\n },\n [date, day, month, update],\n );\n\n const setHour = useCallback(\n (h: number) => {\n const d = new Date(date);\n d.setHours(isPM ? (h === 12 ? 12 : h + 12) : h === 12 ? 0 : h);\n update(d);\n },\n [date, isPM, update],\n );\n\n const setMinute = useCallback(\n (m: number) => {\n const d = new Date(date);\n d.setMinutes(m);\n update(d);\n },\n [date, update],\n );\n\n const togglePeriod = useCallback(() => {\n const d = new Date(date);\n d.setHours(hours24 >= 12 ? hours24 - 12 : hours24 + 12);\n update(d);\n }, [date, hours24, update]);\n\n return (\n
\n {/* Date line */}\n \n \n \n \n , \n \n
\n\n {/* Time line */}\n \n \n :\n \n \n \n \n \n );\n}\n\nexport default ScrubDatetime;\n", "type": "registry:ui", "target": "components/ruixen/scrub-datetime.tsx" } ] }