"use client"; import { motion, MotionConfig, useReducedMotion } from "motion/react"; import { createContext, type KeyboardEvent, useCallback, useContext, useId, useLayoutEffect, useMemo, useState, type ReactNode, } from "react"; import { SPRING_LAYOUT, SPRING_PRESS } from "@/lib/ease"; import { cn } from "@/lib/utils"; type RadioCtx = { value: string; setValue: (value: string) => void; layoutId: string; /** * The one option Tab reaches: the selected option, or the first enabled one * when nothing enabled is selected. `null` until the options have * registered, when every enabled option stays reachable. */ tabStop: string | null; register: (value: string, disabled: boolean) => void; unregister: (value: string) => void; }; const RadioCtx = createContext(null); function useRadioGroup() { const ctx = useContext(RadioCtx); if (!ctx) { throw new Error("RadioGroupItem must be used inside "); } return ctx; } export interface RadioGroupProps { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; children: ReactNode; className?: string; orientation?: "vertical" | "horizontal"; /** Accessible name for the group when no visible label names it. */ "aria-label"?: string; /** Id of a visible element that names the group. */ "aria-labelledby"?: string; } export function RadioGroup({ value, defaultValue = "", onValueChange, children, className, orientation = "vertical", "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy, }: RadioGroupProps) { const [internal, setInternal] = useState(defaultValue); const layoutId = useId(); const reduce = useReducedMotion(); const controlled = value !== undefined; const current = controlled ? value : internal; const setValue = useCallback( (next: string) => { if (!controlled) setInternal(next); onValueChange?.(next); }, [controlled, onValueChange], ); const [options, setOptions] = useState>(new Map()); const register = useCallback((option: string, disabled: boolean) => { setOptions((map) => map.get(option) === disabled ? map : new Map(map).set(option, disabled), ); }, []); const unregister = useCallback((option: string) => { setOptions((map) => { if (!map.has(option)) return map; const next = new Map(map); next.delete(option); return next; }); }, []); // Resolved during render so the group has exactly one tab stop in the same // commit that changes the selection or the options. let tabStop: string | null = null; if (options.size > 0) { const enabled = Array.from(options).filter(([, disabled]) => !disabled); tabStop = enabled.some(([option]) => option === current) ? current : (enabled[0]?.[0] ?? ""); } const contextValue = useMemo( () => ({ value: current, setValue, layoutId, tabStop, register, unregister }), [current, layoutId, setValue, tabStop, register, unregister], ); return (
{children}
); } export interface RadioGroupItemProps { value: string; label?: string; /** Supporting text under the label, announced as the description rather than the name. */ description?: string; disabled?: boolean; className?: string; id?: string; } export function RadioGroupItem({ value, label, description, disabled, className, id: idProp, }: RadioGroupItemProps) { const { value: groupValue, setValue, layoutId, tabStop, register, unregister, } = useRadioGroup(); const autoId = useId(); const id = idProp ?? autoId; const reduce = useReducedMotion(); const selected = groupValue === value; const descriptionId = description ? `${id}-description` : undefined; // The wrapping