{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar-scheduler", "type": "registry:ui", "title": "Calendar Scheduler", "description": "Week-strip scheduler — horizontal day ribbon, vertical time ruler, one-tap booking with spring physics.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/calendar-scheduler.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/* ── constants ── */\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] as const;\n\nconst WK = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"] as const;\nconst WKFULL = [\n \"Sunday\",\n \"Monday\",\n \"Tuesday\",\n \"Wednesday\",\n \"Thursday\",\n \"Friday\",\n \"Saturday\",\n] as const;\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 fmtDate(y: number, m: number, d: number) {\n return `${y}-${pad(m + 1)}-${pad(d)}`;\n}\nfunction fmt12(h: number, m: number) {\n const ap = h >= 12 ? \"PM\" : \"AM\";\n const h12 = h === 0 ? 12 : h > 12 ? h - 12 : h;\n return `${h12}:${pad(m)} ${ap}`;\n}\n\n/* ── sound ── */\nlet _ctx: AudioContext | null = null;\nlet _buf: AudioBuffer | null = null;\nfunction _init() {\n if (_ctx) return;\n _ctx = new AudioContext();\n const n = Math.ceil(_ctx.sampleRate * 0.003);\n _buf = _ctx.createBuffer(1, n, _ctx.sampleRate);\n const ch = _buf.getChannelData(0);\n for (let i = 0; i < n; i++) {\n const t = i / n;\n ch[i] = (Math.random() * 2 - 1) * Math.pow(1 - t, 4) * 0.12;\n }\n}\nlet _last = 0;\nfunction _tick() {\n const now = Date.now();\n if (now - _last < 60) return;\n _last = now;\n if (!_ctx || !_buf) return;\n const s = _ctx.createBufferSource();\n s.buffer = _buf;\n s.connect(_ctx.destination);\n s.start();\n}\n\n/* ── types ── */\nexport interface CalendarSchedulerProps {\n /** Fires on confirm with ISO date string + 12h time string */\n onConfirm?: (value: { date: string; time: string }) => void;\n /** First bookable hour (0-23). Default 8 */\n startHour?: number;\n /** Last bookable hour (0-23, inclusive of :00). Default 18 */\n endHour?: number;\n /** Slot interval in minutes. Default 30 */\n interval?: number;\n /** Enable tick sound. Default true */\n sound?: boolean;\n}\n\ntype Slot = \"month\" | \"day\" | \"time\" | null;\n\n/* ── stagger helper ── */\nconst stagger = (i: number) => ({\n initial: { opacity: 0, y: 6 } as const,\n animate: { opacity: 1, y: 0, transition: { delay: i * 0.018 } } as const,\n});\n\n/* ── className helpers ── */\nconst segClass = (active: boolean, hasValue: boolean) =>\n cn(\n \"border-none font-light cursor-pointer rounded-[10px] transition-[background_0.2s,color_0.15s] font-[inherit]\",\n active\n ? \"bg-neutral-100 dark:bg-neutral-800\"\n : \"bg-transparent border-b border-neutral-200 dark:border-neutral-800\",\n hasValue\n ? \"text-neutral-900 dark:text-neutral-100\"\n : \"text-neutral-400 dark:text-neutral-600\",\n );\n\nconst cellClass = (selected: boolean, hovered: boolean) =>\n cn(\n \"border-none cursor-pointer rounded-lg transition-[background_0.12s,color_0.12s] text-center font-[inherit]\",\n selected\n ? \"bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-950 font-semibold\"\n : hovered\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300 font-normal\"\n : \"bg-transparent text-neutral-600 dark:text-neutral-400 font-normal\",\n );\n\n/* ── component ── */\nfunction CalendarScheduler({\n onConfirm,\n startHour = 8,\n endHour = 18,\n interval = 30,\n sound = true,\n}: CalendarSchedulerProps) {\n const now = useMemo(() => new Date(), []);\n const [year, setYear] = useState(now.getFullYear());\n const [month, setMonth] = useState(now.getMonth());\n const [day, setDay] = useState(now.getDate());\n const [timeIdx, setTimeIdx] = useState(null);\n const [open, setOpen] = useState(null);\n const [hovMonth, setHovMonth] = useState(null);\n const [hovDay, setHovDay] = useState(null);\n const [hovTime, setHovTime] = useState(null);\n\n const ref = useRef(null);\n\n /* time slots */\n const times = useMemo(() => {\n const out: string[] = [];\n for (let h = startHour; h < endHour; h++)\n for (let m = 0; m < 60; m += interval) out.push(fmt12(h, m));\n out.push(fmt12(endHour, 0));\n return out;\n }, [startHour, endHour, interval]);\n\n /* click outside */\n useEffect(() => {\n const fn = (e: MouseEvent) => {\n if (ref.current && !ref.current.contains(e.target as Node)) setOpen(null);\n };\n document.addEventListener(\"mousedown\", fn);\n return () => document.removeEventListener(\"mousedown\", fn);\n }, []);\n\n /* sound helpers */\n const boot = useCallback(() => {\n if (sound) _init();\n }, [sound]);\n const tick = useCallback(() => {\n if (sound) _tick();\n }, [sound]);\n\n /* toggle picker */\n const toggle = useCallback(\n (s: Slot) => {\n boot();\n tick();\n setOpen((p) => (p === s ? null : s));\n },\n [boot, tick],\n );\n\n /* derived */\n const weekday = useMemo(() => {\n if (day === null) return null;\n return WKFULL[new Date(year, month, day).getDay()];\n }, [year, month, day]);\n\n const grid = useMemo(() => {\n const total = dim(year, month);\n const off = soff(year, month);\n const cells: (number | null)[] = [];\n for (let i = 0; i < off; i++) cells.push(null);\n for (let d = 1; d <= total; d++) cells.push(d);\n return cells;\n }, [year, month]);\n\n const isToday = useCallback(\n (d: number) =>\n d === now.getDate() &&\n month === now.getMonth() &&\n year === now.getFullYear(),\n [now, month, year],\n );\n\n const ready = day !== null && timeIdx !== null;\n\n const confirm = useCallback(() => {\n if (!ready) return;\n tick();\n onConfirm?.({ date: fmtDate(year, month, day!), time: times[timeIdx!] });\n }, [ready, tick, onConfirm, year, month, day, timeIdx, times]);\n\n /* navigate month within day picker */\n const shiftMonth = useCallback(\n (delta: number) => {\n tick();\n setMonth((m) => {\n let nm = m + delta;\n let ny = year;\n if (nm < 0) {\n nm = 11;\n ny = year - 1;\n } else if (nm > 11) {\n nm = 0;\n ny = year + 1;\n }\n setYear(ny);\n const max = dim(ny, nm);\n if (day !== null && day > max) setDay(max);\n return nm;\n });\n },\n [tick, year, day],\n );\n\n return (\n \n {/* ── label ── */}\n \n Schedule for\n \n\n {/* ── the sentence ── */}\n \n {/* month */}\n toggle(\"month\")}\n whileTap={{ scale: 0.97 }}\n className={segClass(open === \"month\", true)}\n style={{\n fontSize: 28,\n fontWeight: 300,\n padding: \"2px 10px\",\n lineHeight: 1.3,\n }}\n >\n {MONTHS[month]}\n \n\n {/* day */}\n toggle(\"day\")}\n whileTap={{ scale: 0.97 }}\n className={segClass(open === \"day\", day !== null)}\n style={{\n fontSize: 28,\n fontWeight: 300,\n padding: \"2px 10px\",\n lineHeight: 1.3,\n }}\n >\n {day !== null ? day : \"\\u2014\\u2014\"}\n \n\n {/* connector */}\n \n at\n \n\n {/* time */}\n toggle(\"time\")}\n whileTap={{ scale: 0.97 }}\n className={segClass(open === \"time\", timeIdx !== null)}\n style={{\n fontSize: 28,\n fontWeight: 300,\n padding: \"2px 10px\",\n lineHeight: 1.3,\n }}\n >\n {timeIdx !== null ? times[timeIdx] : \"\\u2014:\\u2014\\u2014\"}\n \n \n\n {/* ── weekday / year ── */}\n \n {weekday ? `${weekday}, ${year}` : String(year)}\n \n\n {/* ── pickers ── */}\n \n {/* ─ month picker ─ */}\n {open === \"month\" && (\n \n \n {/* year nav */}\n \n {\n setYear((y) => y - 1);\n tick();\n }}\n whileTap={{ scale: 0.85 }}\n className=\"bg-none border-none text-neutral-400 dark:text-neutral-600 cursor-pointer font-[inherit]\"\n style={{\n fontSize: 15,\n padding: \"2px 8px\",\n }}\n >\n ‹\n \n \n {year}\n \n {\n setYear((y) => y + 1);\n tick();\n }}\n whileTap={{ scale: 0.85 }}\n className=\"bg-none border-none text-neutral-400 dark:text-neutral-600 cursor-pointer font-[inherit]\"\n style={{\n fontSize: 15,\n padding: \"2px 8px\",\n }}\n >\n ›\n \n \n\n \n {MONTHS.map((m, i) => {\n const sel = i === month;\n const hov = hovMonth === i;\n return (\n {\n setMonth(i);\n const max = dim(year, i);\n if (day !== null && day > max) setDay(max);\n tick();\n setOpen(\"day\");\n }}\n onMouseEnter={() => setHovMonth(i)}\n onMouseLeave={() => setHovMonth(null)}\n whileTap={{ scale: 0.94 }}\n className={cellClass(sel, !sel && hov)}\n style={{\n fontSize: 13,\n padding: \"9px 4px\",\n }}\n >\n {m.slice(0, 3)}\n \n );\n })}\n \n \n \n )}\n\n {/* ─ day picker ─ */}\n {open === \"day\" && (\n \n \n {/* month nav */}\n \n shiftMonth(-1)}\n whileTap={{ scale: 0.85 }}\n className=\"bg-none border-none text-neutral-400 dark:text-neutral-600 cursor-pointer font-[inherit]\"\n style={{\n fontSize: 15,\n padding: \"2px 8px\",\n }}\n >\n ‹\n \n \n {MONTHS[month]} {year}\n \n shiftMonth(1)}\n whileTap={{ scale: 0.85 }}\n className=\"bg-none border-none text-neutral-400 dark:text-neutral-600 cursor-pointer font-[inherit]\"\n style={{\n fontSize: 15,\n padding: \"2px 8px\",\n }}\n >\n ›\n \n \n\n {/* weekday headers */}\n \n {WK.map((d) => (\n \n {d}\n \n ))}\n \n\n {/* grid */}\n \n {grid.map((d, i) => {\n if (d === null)\n return
;\n\n const sel = d === day;\n const td = isToday(d);\n const hov = hovDay === d && !sel;\n\n return (\n {\n setDay(d);\n tick();\n setOpen(\"time\");\n }}\n onMouseEnter={() => setHovDay(d)}\n onMouseLeave={() => setHovDay(null)}\n whileTap={{ scale: 0.88 }}\n className={cn(\n \"border-none cursor-pointer rounded-lg transition-[background_0.12s,color_0.12s] text-center font-[inherit] relative\",\n sel\n ? \"bg-neutral-900 dark:bg-neutral-100 text-white dark:text-neutral-950 font-semibold\"\n : hov\n ? \"bg-neutral-100 dark:bg-neutral-800 text-neutral-700 dark:text-neutral-300\"\n : \"bg-transparent\",\n !sel &&\n !hov &&\n td &&\n \"text-neutral-900 dark:text-neutral-100 font-semibold\",\n !sel &&\n !hov &&\n !td &&\n \"text-neutral-600 dark:text-neutral-400 font-normal\",\n )}\n style={{\n fontSize: 13,\n padding: \"9px 4px\",\n position: \"relative\",\n }}\n >\n {d}\n {td && !sel && (\n \n )}\n \n );\n })}\n
\n \n \n )}\n\n {/* ─ time picker ─ */}\n {open === \"time\" && (\n \n \n {/* period label */}\n \n \n PICK A TIME\n \n \n\n \n {times.map((t, i) => {\n const sel = i === timeIdx;\n const hov = hovTime === i && !sel;\n return (\n {\n setTimeIdx(i);\n tick();\n setOpen(null);\n }}\n onMouseEnter={() => setHovTime(i)}\n onMouseLeave={() => setHovTime(null)}\n whileTap={{ scale: 0.94 }}\n className={cellClass(sel, hov)}\n style={{\n fontSize: 13,\n padding: \"9px 4px\",\n }}\n >\n {t}\n \n );\n })}\n \n \n \n )}\n
\n\n {/* ── confirm ── */}\n \n {ready && open === null && (\n \n \n Confirm\n \n \n )}\n \n \n );\n}\n\nexport { CalendarScheduler };\n", "type": "registry:ui", "target": "components/ruixen/calendar-scheduler.tsx" } ] }