{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "chrono-select", "type": "registry:ui", "title": "Chrono Select", "description": "Inline date picker — click to expand, spring-animated calendar grid, today shortcut, click-outside dismiss.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/chrono-select.tsx", "content": "\"use client\";\n\nimport { useState, useMemo, useCallback, useRef, useEffect } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Chrono Select — inline date picker dropdown.\n *\n * Click to expand, calendar grid, today shortcut,\n * click-outside dismiss. Spring animations.\n * A single breathing card that opens and closes.\n */\n\n/* ── constants ── */\nconst MO = [\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];\nconst DA = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\n\n/* ── date math ── */\nfunction dim(y: number, m: number) {\n return new Date(y, m + 1, 0).getDate();\n}\nfunction soff(y: number, m: number) {\n return (new Date(y, m, 1).getDay() + 6) % 7;\n}\nfunction pad(n: number) {\n return String(n).padStart(2, \"0\");\n}\nfunction toKey(y: number, m: number, d: number) {\n return `${y}-${pad(m + 1)}-${pad(d)}`;\n}\nfunction todayKey() {\n const n = new Date();\n return toKey(n.getFullYear(), n.getMonth(), n.getDate());\n}\nfunction parseKey(k: string) {\n const [y, m, d] = k.split(\"-\").map(Number);\n return { y, m: m - 1, d };\n}\nfunction displayDate(k: string) {\n const { y, m, d } = parseKey(k);\n return `${MO[m].slice(0, 3)} ${d}, ${y}`;\n}\n\n/* ── sound ── */\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\nfunction tick() {\n try {\n if (!_ctx) _ctx = new AudioContext();\n if (!_buf) {\n const len = Math.round(_ctx.sampleRate * 0.003);\n _buf = _ctx.createBuffer(1, len, _ctx.sampleRate);\n const ch = _buf.getChannelData(0);\n for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, 4) * 0.12;\n }\n }\n const s = _ctx.createBufferSource();\n s.buffer = _buf;\n s.connect(_ctx.destination);\n s.start();\n } catch {}\n}\n\n/* ── types ── */\ninterface ChronoSelectProps {\n /** Selected date as \"YYYY-MM-DD\" */\n value?: string;\n /** Fires with \"YYYY-MM-DD\" or null */\n onChange?: (date: string | null) => void;\n /** Trigger placeholder when nothing selected */\n placeholder?: string;\n /** Enable soft tick on interactions */\n sound?: boolean;\n}\n\n/* ── component ── */\nexport function ChronoSelect({\n value,\n onChange,\n placeholder = \"Pick a date\",\n sound = true,\n}: ChronoSelectProps) {\n const [open, setOpen] = useState(false);\n const [selected, setSelected] = useState(value ?? null);\n const [hovDay, setHovDay] = useState(null);\n const [dir, setDir] = useState(0);\n\n /* derive initial month/year from value */\n const init = selected ? parseKey(selected) : null;\n const [month, setMonth] = useState(init?.m ?? new Date().getMonth());\n const [year, setYear] = useState(init?.y ?? new Date().getFullYear());\n\n const wrapRef = useRef(null);\n const lastTick = useRef(0);\n\n const play = useCallback(() => {\n if (!sound) return;\n const now = Date.now();\n if (now - lastTick.current < 80) return;\n lastTick.current = now;\n tick();\n }, [sound]);\n\n /* sync external value */\n useEffect(() => {\n if (value && value !== selected) {\n setSelected(value);\n const p = parseKey(value);\n setMonth(p.m);\n setYear(p.y);\n }\n }, [value]); // eslint-disable-line react-hooks/exhaustive-deps\n\n /* click-outside */\n useEffect(() => {\n if (!open) return;\n const handler = (e: MouseEvent) => {\n if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {\n setOpen(false);\n }\n };\n document.addEventListener(\"mousedown\", handler);\n return () => document.removeEventListener(\"mousedown\", handler);\n }, [open]);\n\n /* escape key */\n useEffect(() => {\n if (!open) return;\n const handler = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"keydown\", handler);\n return () => document.removeEventListener(\"keydown\", handler);\n }, [open]);\n\n const today = todayKey();\n const days = dim(year, month);\n const offset = soff(year, month);\n\n const weeks = useMemo(() => {\n const rows: (number | null)[][] = [];\n let row: (number | null)[] = Array(offset).fill(null);\n for (let d = 1; d <= days; d++) {\n row.push(d);\n if (row.length === 7) {\n rows.push(row);\n row = [];\n }\n }\n if (row.length) {\n while (row.length < 7) row.push(null);\n rows.push(row);\n }\n return rows;\n }, [year, month, days, offset]);\n\n const nav = (delta: number) => {\n setDir(delta);\n let m = month + delta;\n let y = year;\n if (m < 0) {\n m = 11;\n y--;\n }\n if (m > 11) {\n m = 0;\n y++;\n }\n setMonth(m);\n setYear(y);\n play();\n };\n\n const pick = (d: number) => {\n const key = toKey(year, month, d);\n setSelected(key);\n onChange?.(key);\n play();\n setTimeout(() => setOpen(false), 180);\n };\n\n const goToday = () => {\n const n = new Date();\n setDir(0);\n setYear(n.getFullYear());\n setMonth(n.getMonth());\n pick(n.getDate());\n };\n\n /* cell size */\n const CELL = 36;\n\n return (\n
\n {/* ── single breathing card ── */}\n \n {/* ── trigger ── */}\n {\n setOpen((o) => !o);\n play();\n }}\n whileTap={{ scale: 0.985 }}\n className=\"flex w-full items-center justify-between bg-transparent\"\n style={{\n padding: \"11px 16px\",\n border: \"none\",\n cursor: \"pointer\",\n }}\n >\n \n {selected ? displayDate(selected) : placeholder}\n \n \n ▾\n \n \n\n {/* ── expanding calendar ── */}\n \n {open && (\n \n {/* hairline separator */}\n
\n\n
\n {/* ── month / year nav ── */}\n
\n nav(-1)}\n whileTap={{ scale: 0.82 }}\n className=\"flex h-[26px] w-[26px] items-center justify-center rounded-[7px] bg-neutral-100 text-[14px] font-light text-neutral-400 transition-colors hover:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-600 dark:hover:text-neutral-400\"\n style={{ border: \"none\", cursor: \"pointer\" }}\n >\n ‹\n \n\n \n 0 ? 6 : -6, opacity: 0 }}\n animate={{ y: 0, opacity: 1 }}\n exit={{ y: dir > 0 ? -6 : 6, opacity: 0 }}\n transition={{\n type: \"spring\",\n damping: 24,\n stiffness: 300,\n }}\n className=\"text-[13px] tracking-[-0.01em] text-neutral-600 dark:text-neutral-400\"\n style={{ fontWeight: 520 }}\n >\n {MO[month]} {year}\n \n \n\n nav(1)}\n whileTap={{ scale: 0.82 }}\n className=\"flex h-[26px] w-[26px] items-center justify-center rounded-[7px] bg-neutral-100 text-[14px] font-light text-neutral-400 transition-colors hover:text-neutral-600 dark:bg-neutral-800 dark:text-neutral-600 dark:hover:text-neutral-400\"\n style={{ border: \"none\", cursor: \"pointer\" }}\n >\n ›\n \n
\n\n {/* ── day-of-week headers ── */}\n \n {DA.map((d) => (\n \n {d}\n
\n ))}\n
\n\n {/* ── calendar grid ── */}\n \n 0 ? 16 : -16, opacity: 0 }}\n animate={{ x: 0, opacity: 1 }}\n exit={{ x: dir > 0 ? -16 : 16, opacity: 0 }}\n transition={{\n type: \"spring\",\n damping: 26,\n stiffness: 300,\n }}\n >\n {weeks.map((week, wi) => (\n \n {week.map((d, ci) => {\n if (d === null) return
;\n\n const key = toKey(year, month, d);\n const isSel = key === selected;\n const isToday = key === today;\n const isHov = d === hovDay;\n\n return (\n pick(d)}\n onMouseEnter={() => setHovDay(d)}\n onMouseLeave={() => setHovDay(null)}\n animate={{\n y: isSel ? -2 : isHov ? -1 : 0,\n }}\n whileTap={{ scale: 0.88 }}\n transition={{\n type: \"spring\",\n damping: 22,\n stiffness: 320,\n }}\n className={cn(\n \"relative flex items-center justify-center rounded-lg text-[13px] transition-colors duration-100\",\n isSel\n ? \"bg-neutral-900 text-white dark:bg-neutral-100 dark:text-neutral-950\"\n : isHov\n ? \"bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300\"\n : isToday\n ? \"text-neutral-800 dark:text-neutral-200\"\n : \"text-neutral-400 dark:text-neutral-500\",\n )}\n style={{\n width: CELL,\n height: CELL,\n border: \"none\",\n cursor: \"pointer\",\n fontWeight: isSel ? 600 : isToday ? 550 : 400,\n }}\n >\n {d}\n {isToday && !isSel && (\n \n )}\n \n );\n })}\n
\n ))}\n \n
\n\n {/* ── today shortcut ── */}\n
\n \n Today\n \n
\n
\n \n )}\n \n \n \n );\n}\n", "type": "registry:ui", "target": "components/ruixen/chrono-select.tsx" } ] }