{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "radio-group", "type": "registry:ui", "title": "Radio Group (Base UI)", "description": "Composable radio group with an atomic RadioGroupItem, form support, and enhanced proximity-hover RadioItem rows. Base UI flavor.", "dependencies": [ "framer-motion", "@base-ui/react", "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/use-proximity-hover.json" ], "files": [ { "path": "packages/ui/src/components/radio-group.tsx", "content": "\"use client\";\nimport { Children, useRef, useState, useEffect, createContext, useContext, forwardRef, isValidElement, type ReactNode, type HTMLAttributes, } from \"react\";\nimport { motion, AnimatePresence, useReducedMotion } from \"framer-motion\";\nimport { RadioGroup as RadioGroupPrimitive } from \"@base-ui/react/radio-group\";\nimport { Radio as RadioPrimitive } from \"@base-ui/react/radio\";\nimport { cn } from \"@lib/utils\";\nimport { spring } from \"@lib/springs\";\nimport { useProximityHover } from \"@hooks/use-proximity-hover\";\nexport type RadioGroupItemProps = RadioPrimitive.Root.Props;\n/** Atomic radio control shared by simple groups and enhanced RadioItem rows. */\nfunction RadioGroupItem({ className, ...props }: RadioGroupItemProps) {\n const reduceMotion = useReducedMotion() ?? false;\n return (\n \n \n \n );\n}\ninterface RadioGroupContextValue {\n registerItem: (index: number, element: HTMLElement | null) => void;\n activeIndex: number | null;\n disabled: boolean;\n readOnly: boolean;\n selectedIndex: number | null;\n selectedValue?: string;\n onValueChange?: (value: string) => void;\n /** Whether any item in the group is currently selected. Drives the roving\n * tabindex fallback: with no selection, the first item must stay tabbable\n * or the whole group becomes unreachable by keyboard. */\n hasSelection: boolean;\n}\nconst RadioGroupContext = createContext(null);\nfunction useRadioGroupContext() {\n const ctx = useContext(RadioGroupContext);\n if (!ctx)\n throw new Error(\"useRadioGroup must be used within a RadioGroup\");\n return ctx;\n}\ninterface RadioGroupProps extends Omit, \"defaultValue\" | \"onSelect\"> {\n children: ReactNode;\n defaultValue?: string;\n disabled?: boolean;\n form?: string;\n inputRef?: React.Ref;\n name?: string;\n readOnly?: boolean;\n required?: boolean;\n selectedIndex?: number;\n value?: string;\n onValueChange?: (value: string) => void;\n}\nconst RadioGroup = forwardRef(({ children, className, defaultValue, disabled, form, inputRef, name, onValueChange, readOnly, required, selectedIndex, value, ...props }, ref) => {\n const containerRef = useRef(null);\n const childElements = Children.toArray(children).filter(isValidElement);\n const childValues = childElements.map((child) => (child.props as {\n value?: string;\n }).value);\n const selectedChildIndex = childElements.findIndex((child) => (child.props as {\n selected?: boolean;\n }).selected === true);\n const enhancedMode = childElements.some((child) => (child.props as {\n index?: number;\n }).index !== undefined);\n const { activeIndex, setActiveIndex, itemRects, sessionRef, handlers, registerItem, measureItems, } = useProximityHover(containerRef);\n useEffect(() => {\n measureItems();\n }, [measureItems, children]);\n const [focusedIndex, setFocusedIndex] = useState(null);\n const resolvedSelectedIndex = value !== undefined\n ? childValues.findIndex((childValue) => childValue === value)\n : selectedIndex ?? selectedChildIndex;\n const hasSelection = resolvedSelectedIndex >= 0;\n const primitiveValue = value ??\n (resolvedSelectedIndex >= 0\n ? `__zeron-radio-index-${resolvedSelectedIndex}`\n : undefined);\n const activeRect = activeIndex !== null ? itemRects[activeIndex] : null;\n const focusRect = focusedIndex !== null ? itemRects[focusedIndex] : null;\n const selectedRect = resolvedSelectedIndex >= 0 ? itemRects[resolvedSelectedIndex] : null;\n const content = (
{\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 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 hidden radio primitive also\n // carries role=\"radio\", so a bare [role=\"radio\"] selector matches\n // twice per row and arrows land on the invisible 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 // In value mode this handler is merged with Base UI RadioGroup's\n // composite onto the same element; suppress the composite's own\n // roving focus (it targets the hidden sr-only radios).\n const preventBaseUI = (e as unknown as {\n preventBaseUIHandler?: () => void;\n }).preventBaseUIHandler;\n if ([\"ArrowDown\", \"ArrowUp\", \"ArrowRight\", \"ArrowLeft\"].includes(e.key)) {\n e.preventDefault();\n preventBaseUI?.();\n const next = [\"ArrowDown\", \"ArrowRight\"].includes(e.key)\n ? (currentIdx + 1) % items.length\n : (currentIdx - 1 + items.length) % items.length;\n items[next].focus();\n items[next].click();\n }\n else if (e.key === \"Home\") {\n e.preventDefault();\n preventBaseUI?.();\n items[0]?.focus();\n items[0]?.click();\n }\n else if (e.key === \"End\") {\n e.preventDefault();\n preventBaseUI?.();\n items[items.length - 1]?.focus();\n items[items.length - 1]?.click();\n }\n }} role=\"radiogroup\" className={cn(enhancedMode\n ? \"relative flex w-72 max-w-full flex-col select-none\"\n : \"grid gap-3\", className)} {...props}>\n {/* Selected background */}\n {selectedRect && ()}\n\n {/* Hover background */}\n \n {activeRect && ()}\n \n\n {/* Focus ring */}\n \n {focusRect && ()}\n \n\n {children}\n
);\n return (= 0 ? resolvedSelectedIndex : null,\n selectedValue: value,\n onValueChange,\n hasSelection,\n }}>\n onValueChange?.(nextValue as string)} readOnly={readOnly} render={content} required={required} value={primitiveValue}/>\n );\n});\nRadioGroup.displayName = \"RadioGroup\";\ninterface RadioItemProps extends HTMLAttributes {\n label: string;\n index: number;\n selected?: boolean;\n onSelect?: () => void;\n value?: string;\n}\nconst RadioItem = forwardRef(({ label, index, selected, onSelect, value, className, ...props }, ref) => {\n const internalRef = useRef(null);\n const { registerItem, activeIndex, disabled, readOnly, selectedIndex, selectedValue, onValueChange, hasSelection, } = useRadioGroupContext();\n useEffect(() => {\n registerItem(index, internalRef.current);\n return () => registerItem(index, null);\n }, [index, registerItem]);\n const isActive = activeIndex === index;\n const isSelected = value !== undefined && selectedValue !== undefined\n ? selectedValue === value\n : selected ?? selectedIndex === index;\n const handleSelect = () => {\n if (disabled || readOnly)\n return;\n if (value !== undefined) {\n onValueChange?.(value);\n }\n onSelect?.();\n };\n const radioValue = value ?? `__zeron-radio-index-${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} \n // Roving tabindex: selected item is the tab stop; with no selection the\n // first item takes it so the group stays keyboard-reachable.\n tabIndex={disabled ? -1 : isSelected ? 0 : !hasSelection && index === 0 ? 0 : -1} role=\"radio\" aria-checked={isSelected} aria-disabled={disabled || undefined} aria-label={label} aria-readonly={readOnly || undefined} onClick={handleSelect} onMouseDown={(e) => {\n // Clicking the 15px radio circle 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 handleSelect();\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`, disabled && \"pointer-events-none opacity-50\", readOnly && \"cursor-default\", className)} {...props}>\n \n\n {/* The invisible bold copy reserves width so weight changes do not reflow. */}\n \n \n {label}\n \n \n {label}\n \n \n\n
);\n});\nRadioItem.displayName = \"RadioItem\";\nexport { RadioGroup, RadioGroupItem, RadioItem };\nexport default RadioGroup;\n", "type": "registry:ui", "target": "components/ui/radio-group.tsx" } ] }