{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "range-calendar", "type": "registry:ui", "title": "Range Calendar", "description": "A compact calendar grid for date range selection — continuous capsule highlight, hover preview, spring-animated month transitions, today dot.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/range-calendar.tsx", "content": "\"use client\";\n\nimport { useState, useRef, useCallback, useMemo } from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Range Calendar — Rauno Freiberg craft.\n *\n * A compact calendar grid stripped to pure numbers.\n * No borders, no cell outlines — just floating numerals in a 7-column grid.\n * Date range shown as a continuous capsule highlight that wraps across rows,\n * with per-row start/end rounding so the highlight reads as one fluid shape.\n * Click to set start, click again to set end. Hover previews the range.\n * Spring-animated month transitions with directional slide.\n * Today marked with a tiny dot below the number.\n * Duration display appears once a range is selected.\n * Soft noise-burst tick on selection and navigation.\n */\n\n/* ── Types ── */\n\nexport interface DateRange {\n start: Date;\n end: Date;\n}\n\ninterface RangeCalendarProps {\n value?: DateRange | null;\n defaultValue?: DateRange | null;\n onChange?: (range: DateRange | null) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst CELL = 34;\nconst DAYS = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\nconst MONTH_NAMES = [\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/* ── Audio — soft 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);\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 < 30) 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.0;\n gain.gain.value = 0.06;\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 sameDay(a: Date, b: Date) {\n return (\n a.getFullYear() === b.getFullYear() &&\n a.getMonth() === b.getMonth() &&\n a.getDate() === b.getDate()\n );\n}\n\nfunction daysBetween(a: Date, b: Date) {\n const msDay = 86400000;\n const utcA = Date.UTC(a.getFullYear(), a.getMonth(), a.getDate());\n const utcB = Date.UTC(b.getFullYear(), b.getMonth(), b.getDate());\n return Math.round(Math.abs(utcB - utcA) / msDay);\n}\n\nfunction getGrid(year: number, month: number): (Date | null)[] {\n const first = new Date(year, month, 1);\n // Monday = 0 .. Sunday = 6\n const startDay = (first.getDay() + 6) % 7;\n const daysInMonth = new Date(year, month + 1, 0).getDate();\n\n const cells: (Date | null)[] = [];\n for (let i = 0; i < startDay; i++) cells.push(null);\n for (let d = 1; d <= daysInMonth; d++) cells.push(new Date(year, month, d));\n while (cells.length < 42) cells.push(null);\n return cells;\n}\n\nfunction inRange(date: Date, start: Date, end: Date) {\n const t = date.getTime();\n const lo = start.getTime() <= end.getTime() ? start : end;\n const hi = start.getTime() <= end.getTime() ? end : start;\n return t >= lo.getTime() && t <= hi.getTime();\n}\n\n/* ── Component ── */\n\nexport function RangeCalendar({\n value: controlledValue,\n defaultValue,\n onChange,\n sound = true,\n}: RangeCalendarProps) {\n const today = useMemo(() => new Date(), []);\n const [internal, setInternal] = useState(\n () => defaultValue ?? null,\n );\n const isControlled = controlledValue !== undefined;\n const range = isControlled ? controlledValue : internal;\n const lastSoundTime = useRef(0);\n\n const update = useCallback(\n (r: DateRange | null) => {\n if (!isControlled) setInternal(r);\n onChange?.(r);\n },\n [isControlled, onChange],\n );\n\n /* View state */\n const [viewYear, setViewYear] = useState(\n () => range?.start.getFullYear() ?? today.getFullYear(),\n );\n const [viewMonth, setViewMonth] = useState(\n () => range?.start.getMonth() ?? today.getMonth(),\n );\n const [direction, setDirection] = useState(0);\n\n /* Selection state (always internal) */\n const [selStart, setSelStart] = useState(null);\n const [hover, setHover] = useState(null);\n\n const grid = useMemo(\n () => getGrid(viewYear, viewMonth),\n [viewYear, viewMonth],\n );\n\n /* Navigation */\n const goPrev = useCallback(() => {\n if (sound) playTick(lastSoundTime);\n setDirection(-1);\n setViewMonth((m) => {\n if (m === 0) {\n setViewYear((y) => y - 1);\n return 11;\n }\n return m - 1;\n });\n }, [sound]);\n\n const goNext = useCallback(() => {\n if (sound) playTick(lastSoundTime);\n setDirection(1);\n setViewMonth((m) => {\n if (m === 11) {\n setViewYear((y) => y + 1);\n return 0;\n }\n return m + 1;\n });\n }, [sound]);\n\n /* Click handler */\n const onDayClick = useCallback(\n (date: Date) => {\n if (sound) playTick(lastSoundTime);\n\n if (!selStart) {\n // First click — set start\n setSelStart(date);\n update(null);\n } else {\n // Second click — set range\n const start = selStart.getTime() <= date.getTime() ? selStart : date;\n const end = selStart.getTime() <= date.getTime() ? date : selStart;\n setSelStart(null);\n setHover(null);\n update({ start, end });\n }\n },\n [selStart, sound, update],\n );\n\n /* Compute visual range (committed range OR in-progress selection preview) */\n const visStart = selStart\n ? hover\n ? selStart.getTime() <= hover.getTime()\n ? selStart\n : hover\n : selStart\n : (range?.start ?? null);\n\n const visEnd = selStart\n ? hover\n ? selStart.getTime() <= hover.getTime()\n ? hover\n : selStart\n : selStart\n : (range?.end ?? null);\n\n /* Duration label */\n const days = range ? daysBetween(range.start, range.end) : null;\n\n const key = `${viewYear}-${viewMonth}`;\n\n return (\n
\n {/* Header — month/year + nav */}\n \n \n \n \n \n \n\n \n {MONTH_NAMES[viewMonth]} {viewYear}\n \n\n \n \n \n \n \n
\n\n {/* Day labels */}\n
\n {DAYS.map((d) => (\n \n {d}\n
\n ))}\n \n\n {/* Grid with animated month transitions */}\n \n \n \n {grid.map((date, i) => {\n if (!date) {\n return (\n \n );\n }\n\n const dayNum = date.getDate();\n const col = i % 7;\n const isToday = sameDay(date, today);\n const daysInMonth = new Date(\n viewYear,\n viewMonth + 1,\n 0,\n ).getDate();\n\n /* Range highlight logic */\n const isInRange =\n visStart && visEnd\n ? inRange(date, visStart, visEnd)\n : visStart\n ? sameDay(date, visStart)\n : false;\n const isStart = visStart ? sameDay(date, visStart) : false;\n const isEnd = visEnd ? sameDay(date, visEnd) : false;\n const isSingle = isStart && isEnd;\n\n /* Per-row capsule rounding */\n const roundL = isSingle || isStart || col === 0 || dayNum === 1;\n const roundR =\n isSingle || isEnd || col === 6 || dayNum === daysInMonth;\n\n const borderRadius = isInRange\n ? `${roundL ? CELL / 2 : 0}px ${roundR ? CELL / 2 : 0}px ${roundR ? CELL / 2 : 0}px ${roundL ? CELL / 2 : 0}px`\n : \"0\";\n\n /* Text color classes */\n const textCls =\n isStart || isEnd\n ? \"text-white dark:text-neutral-950\"\n : isInRange\n ? \"text-neutral-700 dark:text-neutral-300\"\n : isToday\n ? \"text-neutral-600 dark:text-neutral-400\"\n : \"text-neutral-400 dark:text-neutral-500\";\n\n return (\n onDayClick(date)}\n onMouseEnter={() => {\n if (selStart) setHover(date);\n }}\n onMouseLeave={() => {\n if (selStart) setHover(null);\n }}\n >\n {/* Range highlight background */}\n {isInRange && (\n \n )}\n\n {/* Number */}\n \n {dayNum}\n \n\n {/* Today dot */}\n {isToday && (\n
\n )}\n
\n );\n })}\n \n
\n \n\n {/* Duration display — always rendered to prevent layout shift */}\n \n {days === null\n ? \"\\u00A0\"\n : days === 0\n ? \"1 day\"\n : days === 1\n ? \"2 days\"\n : `${days + 1} days`}\n \n \n );\n}\n\nexport default RangeCalendar;\n", "type": "registry:ui", "target": "components/ruixen/range-calendar.tsx" } ] }