{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "checkbox-group", "type": "registry:ui", "title": "Checkbox Group (Base UI)", "description": "Animated checkbox group with proximity hover, contiguous selection merging, and spring-animated check marks. Base UI flavor.", "dependencies": [ "framer-motion", "tw-animate-css" ], "registryDependencies": [ "https://zeron-ui.vercel.app/r/surfaces.json", "https://zeron-ui.vercel.app/r/utils.json", "https://zeron-ui.vercel.app/r/springs.json", "https://zeron-ui.vercel.app/r/icon-context.json", "https://zeron-ui.vercel.app/r/checkbox.json", "https://zeron-ui.vercel.app/r/use-proximity-hover.json", "https://zeron-ui.vercel.app/r/use-merge-split.json" ], "files": [ { "path": "packages/ui/src/components/checkbox-group.tsx", "content": "\"use client\";\nimport { useRef, useState, useEffect, createContext, useContext, forwardRef, type ReactNode, type HTMLAttributes, } from \"react\";\nimport { motion, AnimatePresence } from \"framer-motion\";\nimport type { IconComponent } from \"@lib/icon-context\";\nimport { cn } from \"@lib/utils\";\nimport { spring } from \"@lib/springs\";\nimport { useProximityHover } from \"@hooks/use-proximity-hover\";\nimport { useMergeSplitBlocks, SelectionBackgrounds } from \"@hooks/use-merge-split\";\nimport { Checkbox } from \"@ui/checkbox\";\ninterface CheckboxGroupContextValue {\n registerItem: (index: number, element: HTMLElement | null) => void;\n activeIndex: number | null;\n}\nconst CheckboxGroupContext = createContext(null);\nfunction useCheckboxGroup() {\n const ctx = useContext(CheckboxGroupContext);\n if (!ctx)\n throw new Error(\"useCheckboxGroup must be used within a CheckboxGroup\");\n return ctx;\n}\ninterface CheckboxGroupProps extends HTMLAttributes {\n children: ReactNode;\n checkedIndices: Set;\n}\nconst CheckboxGroup = forwardRef(({ children, checkedIndices, className, ...props }, ref) => {\n const containerRef = useRef(null);\n const groupIdCounter = useRef(0);\n const prevGroupMap = useRef(new Map());\n const { activeIndex, setActiveIndex, itemRects, sessionRef, handlers, registerItem, measureItems, } = useProximityHover(containerRef);\n useEffect(() => {\n measureItems();\n }, [measureItems, children]);\n // Group contiguous checked indices into runs with stable IDs\n const runs: {\n start: number;\n end: number;\n }[] = [];\n const sortedChecked = [...checkedIndices].sort((a, b) => a - b);\n for (const idx of sortedChecked) {\n const last = runs[runs.length - 1];\n if (last && idx === last.end + 1) {\n last.end = idx;\n }\n else {\n runs.push({ start: idx, end: idx });\n }\n }\n // Assign stable IDs: reuse previous ID if any member overlaps\n const usedIds = new Set();\n const newGroupMap = new Map();\n const checkedGroups = runs.map((run) => {\n let stableId: number | null = null;\n for (let i = run.start; i <= run.end; i++) {\n const prevId = prevGroupMap.current.get(i);\n if (prevId !== undefined && !usedIds.has(prevId)) {\n stableId = prevId;\n break;\n }\n }\n const id = stableId ?? ++groupIdCounter.current;\n usedIds.add(id);\n for (let i = run.start; i <= run.end; i++) {\n newGroupMap.set(i, id);\n }\n return { ...run, id };\n });\n prevGroupMap.current = newGroupMap;\n const [focusedIndex, setFocusedIndex] = useState(null);\n const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;\n const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n // Selected backgrounds, with the merge/split boundary animation when one\n // unchecked row bridges or splits two checked runs.\n const blocks = useMergeSplitBlocks(checkedGroups, itemRects);\n return (\n
{\n (containerRef as React.MutableRefObject).current = node;\n if (typeof ref === \"function\")\n ref(node);\n else if (ref)\n (ref as React.MutableRefObject).current = node;\n }} onMouseEnter={handlers.onMouseEnter} onMouseMove={handlers.onMouseMove} onMouseLeave={handlers.onMouseLeave} onFocus={(e) => {\n const indexAttr = (e.target as HTMLElement)\n .closest(\"[data-proximity-index]\")\n ?.getAttribute(\"data-proximity-index\");\n if (indexAttr != null) {\n const idx = Number(indexAttr);\n setActiveIndex(idx);\n setFocusedIndex((e.target as HTMLElement).matches(\":focus-visible\") ? idx : null);\n }\n }} onBlur={(e) => {\n // Don't clear hover when focus moves to another item within the group\n if (containerRef.current?.contains(e.relatedTarget as Node))\n return;\n setFocusedIndex(null);\n setActiveIndex(null);\n }} onKeyDown={(e) => {\n // Scope to row wrappers only. The inner checkbox primitive also\n // carries role=\"checkbox\", so a bare [role=\"checkbox\"] selector\n // matches twice per row and arrows skip onto the hidden control.\n const items = Array.from(containerRef.current?.querySelectorAll(\"[data-proximity-index]\") ?? []) as HTMLElement[];\n const currentIdx = items.indexOf(e.target as HTMLElement);\n if (currentIdx === -1)\n return;\n if ([\"ArrowDown\", \"ArrowUp\"].includes(e.key)) {\n e.preventDefault();\n const next = e.key === \"ArrowDown\"\n ? (currentIdx + 1) % items.length\n : (currentIdx - 1 + items.length) % items.length;\n items[next].focus();\n }\n else if (e.key === \"Home\") {\n e.preventDefault();\n items[0]?.focus();\n }\n else if (e.key === \"End\") {\n e.preventDefault();\n items[items.length - 1]?.focus();\n }\n }} role=\"group\" className={cn(\"relative flex flex-col w-72 max-w-full select-none\", className)} {...props}>\n {/* Selected backgrounds (merged for contiguous checked items).\n A run is normally one block; mid merge/split it is drawn as two\n abutting halves — see useMergeSplitBlocks. */}\n \n\n {/* Hover background */}\n \n {activeRect && ()}\n \n\n {/* Focus ring */}\n \n {focusRect && ()}\n \n\n {children}\n
\n
);\n});\nCheckboxGroup.displayName = \"CheckboxGroup\";\ninterface CheckboxItemProps extends HTMLAttributes {\n label: string;\n index: number;\n checked: boolean;\n icon?: IconComponent;\n onToggle: () => void;\n trailing?: ReactNode;\n}\nconst CheckboxItem = forwardRef(({ label, index, checked, icon: Icon, onToggle, trailing, className, ...props }, ref) => {\n const internalRef = useRef(null);\n const { registerItem, activeIndex } = useCheckboxGroup();\n useEffect(() => {\n registerItem(index, internalRef.current);\n return () => registerItem(index, null);\n }, [index, registerItem]);\n const isActive = activeIndex === index;\n return (
{\n (internalRef as React.MutableRefObject).current = node;\n if (typeof ref === \"function\")\n ref(node);\n else if (ref)\n (ref as React.MutableRefObject).current = node;\n }} data-proximity-index={index} tabIndex={0} role=\"checkbox\" aria-checked={checked} aria-label={label} onClick={onToggle} onMouseDown={(e) => {\n // Clicking the 15px checkbox square would natively focus the hidden\n // primitive (nearest focusable ancestor of the click target), after\n // which arrow-key nav dead-zones: the group keydown handler can't\n // find the target among the row wrappers. Prevent the native focus\n // move (click still fires) and land focus on the row instead. Skip\n // genuinely interactive children so we don't hijack their focus.\n const interactive = (e.target as HTMLElement).closest('button:not([tabindex=\"-1\"]), a[href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])');\n if (interactive && interactive !== e.currentTarget)\n return;\n e.preventDefault();\n e.currentTarget.focus();\n }} onKeyDown={(e) => {\n if (e.key === \" \" || e.key === \"Enter\") {\n e.preventDefault();\n onToggle();\n }\n }} className={cn(\n // Fixed height keeps every option row aligned and easy to target.\n `relative z-content flex h-control-sm items-center gap-2.5 rounded-lg px-3 cursor-pointer outline-none`, className)} {...props}>\n onToggle()} tabIndex={-1} aria-hidden className={cn(\"shrink-0\", !checked && isActive &&\n \"border-input-hover\")} onClick={(e) => e.stopPropagation()}/>\n\n {Icon && ()}\n\n {/* The invisible weighted copy reserves width so weight changes do not reflow. */}\n \n \n {label}\n \n \n {label}\n \n \n\n {trailing !== undefined && trailing !== null && (\n {trailing}\n )}\n
);\n});\nCheckboxItem.displayName = \"CheckboxItem\";\nexport { CheckboxGroup, CheckboxItem };\nexport default CheckboxGroup;\n", "type": "registry:ui", "target": "components/ui/checkbox-group.tsx" } ] }