{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "calendar-twin", "type": "registry:ui", "title": "Calendar Twin", "description": "Dual-month range picker — click start, hover to preview, click end. Continuous band with smart rounding.", "dependencies": [ "motion" ], "files": [ { "path": "registry/ruixenui/calendar-twin.tsx", "content": "\"use client\";\n\nimport { useRef, useState, useCallback } from \"react\";\nimport { motion, AnimatePresence } from \"motion/react\";\nimport { cn } from \"@/lib/utils\";\n\n/**\n * Calendar Twin — dual-month range picker.\n *\n * Two months sit side by side. Click a day to start\n * a range, hover to preview, click again to confirm.\n * A continuous band connects start to end — rounding\n * at row edges, brightening at endpoints.\n *\n * The band IS the selection.\n */\n\n/* ── Types ── */\n\nexport interface CalendarTwinProps {\n defaultStart?: string;\n defaultEnd?: string;\n onRangeChange?: (start: string | null, end: string | null) => void;\n sound?: boolean;\n}\n\n/* ── Constants ── */\n\nconst CELL = 36;\nconst DOW = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\n\n/* ── Helpers ── */\n\nfunction pad2(n: number): string {\n return String(n).padStart(2, \"0\");\n}\n\nfunction toKey(y: number, m: number, d: number): string {\n return `${y}-${pad2(m + 1)}-${pad2(d)}`;\n}\n\nfunction parseKey(key: string): [number, number, number] {\n const [y, m, d] = key.split(\"-\").map(Number);\n return [y, m - 1, d];\n}\n\nfunction formatDate(key: string): string {\n const [y, m, d] = parseKey(key);\n return new Date(y, m, d).toLocaleDateString(\"en-US\", {\n month: \"short\",\n day: \"numeric\",\n });\n}\n\nfunction daysBetween(a: string, b: string): number {\n const [ay, am, ad] = parseKey(a);\n const [by, bm, bd] = parseKey(b);\n const da = new Date(ay, am, ad);\n const db = new Date(by, bm, bd);\n return Math.round((db.getTime() - da.getTime()) / 86400000) + 1;\n}\n\nfunction ordered(a: string, b: string): [string, string] {\n return a <= b ? [a, b] : [b, a];\n}\n\n/* ── Audio ── */\n\nlet _ctx: AudioContext | null = null;\nlet _buf: 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 ensureBuf(ac: AudioContext): AudioBuffer {\n if (_buf && _buf.sampleRate === ac.sampleRate) return _buf;\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 for (let i = 0; i < len; i++) {\n const t = i / len;\n ch[i] = (Math.random() * 2 - 1) * (1 - t) ** 4;\n }\n _buf = buf;\n return buf;\n}\n\nfunction playTick(last: React.MutableRefObject) {\n const now = performance.now();\n if (now - last.current < 80) return;\n last.current = now;\n try {\n const ac = audioCtx();\n const buf = ensureBuf(ac);\n const src = ac.createBufferSource();\n const gain = ac.createGain();\n src.buffer = buf;\n src.playbackRate.value = 1.15;\n gain.gain.value = 0.03;\n src.connect(gain);\n gain.connect(ac.destination);\n src.start();\n } catch {\n /* silent */\n }\n}\n\n/* ── Component ── */\n\nexport function CalendarTwin({\n defaultStart,\n defaultEnd,\n onRangeChange,\n sound = true,\n}: CalendarTwinProps) {\n const [baseMonth, setBaseMonth] = useState(() => new Date().getMonth());\n const [baseYear, setBaseYear] = useState(() => new Date().getFullYear());\n const [rangeStart, setRangeStart] = useState(\n defaultStart ?? null,\n );\n const [rangeEnd, setRangeEnd] = useState(defaultEnd ?? null);\n const [hoverDate, setHoverDate] = useState(null);\n const [direction, setDirection] = useState(1);\n const lastSound = useRef(0);\n\n function tick() {\n if (sound) playTick(lastSound);\n }\n\n /* ── Effective range (includes hover preview) ── */\n\n const isConfirmed = rangeStart !== null && rangeEnd !== null;\n let effStart: string | null = null;\n let effEnd: string | null = null;\n\n if (rangeStart) {\n if (rangeEnd) {\n [effStart, effEnd] = ordered(rangeStart, rangeEnd);\n } else if (hoverDate) {\n [effStart, effEnd] = ordered(rangeStart, hoverDate);\n } else {\n effStart = rangeStart;\n }\n }\n\n /* ── Today ── */\n\n const now = new Date();\n const todayKey = toKey(now.getFullYear(), now.getMonth(), now.getDate());\n\n /* ── Day click ── */\n\n const handleDayClick = useCallback(\n (dateKey: string) => {\n tick();\n if (rangeStart === null || rangeEnd !== null) {\n setRangeStart(dateKey);\n setRangeEnd(null);\n onRangeChange?.(dateKey, null);\n } else {\n const [s, e] = ordered(rangeStart, dateKey);\n setRangeStart(s);\n setRangeEnd(e);\n onRangeChange?.(s, e);\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [rangeStart, rangeEnd, onRangeChange],\n );\n\n /* ── Navigation ── */\n\n function goMonth(delta: number) {\n tick();\n setDirection(delta);\n let m = baseMonth + delta;\n let y = baseYear;\n if (m < 0) {\n m = 11;\n y--;\n } else if (m > 11) {\n m = 0;\n y++;\n }\n setBaseMonth(m);\n setBaseYear(y);\n }\n\n /* ── Second month ── */\n\n let month2 = baseMonth + 1;\n let year2 = baseYear;\n if (month2 > 11) {\n month2 = 0;\n year2++;\n }\n\n /* ── Render a single month grid ── */\n\n function renderMonth(y: number, m: number) {\n const daysInMonth = new Date(y, m + 1, 0).getDate();\n const firstOffset = (new Date(y, m, 1).getDay() + 6) % 7;\n const monthLabel = new Date(y, m).toLocaleDateString(\"en-US\", {\n month: \"long\",\n });\n\n return (\n
\n {/* Month name */}\n \n {monthLabel}\n
\n\n {/* DOW headers */}\n \n {DOW.map((d) => (\n \n {d}\n \n ))}\n \n\n {/* Day grid — no gap for continuous band */}\n \n {/* Leading empties */}\n {Array.from({ length: firstOffset }).map((_, i) => (\n
\n ))}\n\n {/* Days */}\n {Array.from({ length: daysInMonth }).map((_, i) => {\n const d = i + 1;\n const dateKey = toKey(y, m, d);\n const col = (firstOffset + d - 1) % 7;\n const isToday = dateKey === todayKey;\n\n /* Range logic */\n const inRange =\n effStart !== null &&\n effEnd !== null &&\n dateKey >= effStart &&\n dateKey <= effEnd;\n const isStart = dateKey === effStart;\n const isEnd = dateKey === effEnd;\n const isSingle = isStart && isEnd;\n\n /* Neighbor checks for band rounding */\n const leftEmpty = col === 0 || d === 1;\n const rightEmpty = col === 6 || d === daysInMonth;\n const prevInRange =\n !leftEmpty &&\n effStart !== null &&\n effEnd !== null &&\n toKey(y, m, d - 1) >= effStart &&\n toKey(y, m, d - 1) <= effEnd;\n const nextInRange =\n !rightEmpty &&\n effStart !== null &&\n effEnd !== null &&\n toKey(y, m, d + 1) >= effStart &&\n toKey(y, m, d + 1) <= effEnd;\n\n const roundLeft = inRange && !prevInRange;\n const roundRight = inRange && !nextInRange;\n\n /* Radius */\n const R = \"10px\";\n const Z = \"0\";\n const radius = isSingle\n ? R\n : `${roundLeft ? R : Z} ${roundRight ? R : Z} ${roundRight ? R : Z} ${roundLeft ? R : Z}`;\n\n /* Text color classes */\n const textCls =\n isStart || isEnd\n ? \"text-white dark:text-neutral-950\"\n : inRange\n ? \"text-neutral-700 dark:text-neutral-300\"\n : isToday\n ? \"text-neutral-900 dark:text-neutral-100\"\n : \"text-neutral-400 dark:text-neutral-500\";\n\n const isHov = dateKey === hoverDate && !inRange;\n\n /* Background classes */\n const bgCls = inRange\n ? isStart || isEnd\n ? \"bg-neutral-900 dark:bg-neutral-100\"\n : \"bg-neutral-100 dark:bg-neutral-800\"\n : \"\";\n\n return (\n handleDayClick(dateKey)}\n onMouseEnter={() => setHoverDate(dateKey)}\n onMouseLeave={() => setHoverDate(null)}\n className={cn(\n \"border-none transition-colors duration-100\",\n bgCls,\n !inRange &&\n \"hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded-[10px]\",\n )}\n style={{\n width: CELL,\n height: CELL,\n display: \"flex\",\n alignItems: \"center\",\n justifyContent: \"center\",\n cursor: \"pointer\",\n padding: 0,\n position: \"relative\",\n borderRadius: inRange ? radius : undefined,\n }}\n >\n \n {d}\n \n \n );\n })}\n
\n \n );\n }\n\n /* ── Range label ── */\n\n const rangeLabel = (() => {\n if (effStart && effEnd && effStart !== effEnd) {\n const count = daysBetween(effStart, effEnd);\n return `${formatDate(effStart)} – ${formatDate(effEnd)} · ${count} day${count !== 1 ? \"s\" : \"\"}`;\n }\n if (effStart) {\n return isConfirmed\n ? formatDate(effStart)\n : `${formatDate(effStart)} — select end`;\n }\n return null;\n })();\n\n return (\n
\n {/* Header */}\n \n goMonth(-1)}\n className=\"text-neutral-400 dark:text-neutral-600 hover:text-neutral-600 dark:hover:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors duration-150\"\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n lineHeight: 1,\n padding: \"6px 10px\",\n borderRadius: 8,\n }}\n >\n ‹\n \n\n \n {baseYear}\n \n\n goMonth(1)}\n className=\"text-neutral-400 dark:text-neutral-600 hover:text-neutral-600 dark:hover:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 transition-colors duration-150\"\n style={{\n background: \"transparent\",\n border: \"none\",\n cursor: \"pointer\",\n fontSize: 16,\n lineHeight: 1,\n padding: \"6px 10px\",\n borderRadius: 8,\n }}\n >\n ›\n \n
\n\n {/* Twin grids */}\n
\n \n 0 ? 12 : -12 }}\n animate={{ opacity: 1, x: 0 }}\n exit={{ opacity: 0, x: direction > 0 ? -12 : 12 }}\n transition={{ duration: 0.18, ease: \"easeOut\" }}\n style={{\n display: \"flex\",\n gap: 24,\n }}\n >\n {renderMonth(baseYear, baseMonth)}\n {renderMonth(year2, month2)}\n \n \n
\n\n {/* Range info */}\n \n {rangeLabel && (\n \n \n {rangeLabel}\n \n \n )}\n \n \n );\n}\n\nexport default CalendarTwin;\n", "type": "registry:ui", "target": "components/ruixen/calendar-twin.tsx" } ] }