{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "radio-group",
  "type": "registry:ui",
  "title": "Radio Group",
  "description": "Fluent 2-styled radio group with shadcn APIs. Built on @base-ui/react's RadioGroup and Radio primitives for roving-tabindex keyboard navigation and native form participation; checked items show a brand ring with a centered brand dot.",
  "author": "graundtech <https://github.com/graundtech/fluent2-react-kit>",
  "dependencies": [
    "@base-ui/react"
  ],
  "registryDependencies": [
    "https://fluent2-react-kit.graund.io/r/utils.json"
  ],
  "files": [
    {
      "path": "components/ui/radio-group.tsx",
      "type": "registry:ui",
      "target": "components/ui/radio-group.tsx",
      "content": "import { Radio } from \"@base-ui/react/radio\";\nimport { RadioGroup as RadioGroupPrimitive } from \"@base-ui/react/radio-group\";\n\nimport { cn } from \"@/lib/utils\";\n\n/**\n * RadioGroup / RadioGroupItem — Fluent 2-styled, shadcn-API radio group.\n *\n * This is a Base UI case (conventions §9): roving tabindex, arrow-key\n * selection and the checked/unchecked state machine are genuine behavior a\n * `<div>` + `<input type=\"radio\">` can't express on their own without\n * reimplementing Base UI's composite-focus logic, so the two parts wrap\n * `@base-ui/react/radio-group` (`RadioGroup`, imported and re-exported as\n * `RadioGroupPrimitive`) and `@base-ui/react/radio` (`Radio`, imported as a\n * namespace exposing `Radio.Root` / `Radio.Indicator` — matches the actual\n * `export * as Radio from \"./index.parts.js\"` shape in node_modules, same\n * pattern `avatar.tsx` uses for `@base-ui/react/avatar`). Arrow-key\n * roving/selection, click-to-select, and the hidden native\n * `<input type=\"radio\">` (native form participation) all come from Base UI —\n * none of it is reimplemented here.\n *\n * `\"use client\"` is intentionally omitted here, same reasoning as\n * `avatar.tsx`: `@base-ui/react/radio-group`'s `RadioGroup` and\n * `@base-ui/react/radio`'s `Radio.Root` / `Radio.Indicator` each carry their\n * own `'use client'` directive at the compiled-source level (verified in\n * `node_modules/@base-ui/react/{radio-group/RadioGroup.js,radio/root/RadioRoot.js,radio/indicator/RadioIndicator.js}`),\n * so they are already client boundaries on their own. This wrapper file has\n * no hooks/handlers of its own — it only forwards props into those parts —\n * so per conventions §2 it can stay a plain (server-renderable) module; a\n * Server Component tree can render a Client Component child without the\n * parent re-declaring the directive. Confirmed via `pnpm typecheck` + the\n * test suite; add the directive here only if that stops holding true.\n *\n * Layout: shadcn's reference implementation defaults `RadioGroup` to\n * `\"grid gap-3\"`. This kit ships `\"grid gap-2\"` instead — a deliberate,\n * spec-directed deviation to match this component family's spacing scale (a\n * 16px control at `gap-2` == 8px reads as a tighter, denser Fluent list than\n * shadcn's 12px gap). Callers needing shadcn's exact spacing can override via\n * `className=\"gap-3\"` (later utility wins through `cn`'s `tailwind-merge`).\n *\n * Checked indicator: shadcn fills a `lucide-react` `CircleIcon` positioned\n * absolutely inside the indicator. Fluent 2's RadioGroup instead shows a\n * brand-colored **ring** (the whole circle's border turns brand) with a\n * small brand **dot** centered inside — no checkmark glyph. This\n * implementation matches that: the item's border switches to\n * `border-primary` when `data-checked` is present, and the indicator renders\n * a plain `size-2 rounded-full bg-primary` `<span>` (flex-centered, no icon\n * import needed — a filled circle needs no glyph, so pulling in\n * `@fluentui/react-icons` for this would add a dependency for no visual\n * gain). `Radio.Indicator` only mounts while the radio is checked (Base UI\n * unmounts it otherwise, per `RadioIndicator.js`'s `shouldRender` check), so\n * the dot itself needs no extra checked-conditional styling — but it does\n * ramp its color alongside the ring (see below).\n *\n * Borders + interaction ramp: the unchecked ring outlines with Fluent's\n * `NeutralStrokeAccessible` ramp — rest `#616161` (`border-stroke-accessible`),\n * hover `#575757`, pressed `#4d4d4d` — the higher-contrast neutral spec for\n * interactive control outlines (extracted from the Figma radio), NOT the\n * lighter `border-input` grey the item previously used. When checked, the ring\n * (border) and dot step through `CompoundBrandStroke`/`CompoundBrandForeground1`\n * Hover/Pressed (`brand-70`/`brand-60`; dark hover `brand-80`), mirroring\n * Button's per-theme brand ramp (conventions §4). The ring uses\n * `data-[checked]:hover:`/`:active:` (which outrank the neutral hover/active\n * border by specificity); the dot, having no `data-checked` of its own, rides\n * the root's `group` hover/press instead.\n *\n * States: `data-checked`/`data-unchecked` and `data-disabled` are Base UI's\n * own presence attributes on `Radio.Root`'s rendered `<span>` (see\n * `RadioRootDataAttributes` in node_modules — boolean presence, not a\n * `data-state=\"checked\"` value pair like Radix), matched here via Tailwind's\n * `data-[checked]:` / `data-[disabled]:` arbitrary-attribute variants (there\n * is no native `:disabled` pseudo-class to reach for — the rendered element\n * is a `<span role=\"radio\">`, not a real `<input>`/`<button>`, so the\n * opacity/pointer-events disabled recipe from conventions §4 is re-expressed\n * against `data-[disabled]:` instead of `disabled:`). Focus, per conventions\n * §4, stays the generic offset-ring recipe (not the field bottom-accent\n * variant — a radio is a selectable control, not a typed-into field).\n * `aria-invalid` is a bare ARIA attribute Base UI passes through untouched,\n * so the standard `aria-invalid:` recipe applies as-is.\n *\n * Typing: Base UI's `RadioGroup`/`Radio.Root` are generic over the radio\n * value type (`Value = any` upstream). `ComponentProps<typeof X>` on a\n * generic function component collapses that parameter to `unknown` (it has\n * no call site to infer from), which would force every consumer to cast in\n * `onValueChange`. Both parts pin `Value` to `string` instead via each\n * primitive's own namespace type (`RadioGroupPrimitive.Props<string>`,\n * `Radio.Root.Props<string>` — available because Base UI declaration-merges\n * a namespace onto each component, matching shadcn/Radix's own radio API,\n * which is string-only, not generic) so callers get a plain, un-cast\n * `value`/`onValueChange: (value: string, …) => void` surface.\n */\nfunction RadioGroup({\n  className,\n  ...props\n}: RadioGroupPrimitive.Props<string>) {\n  return (\n    <RadioGroupPrimitive\n      data-slot=\"radio-group\"\n      className={cn(\"grid gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\nfunction RadioGroupItem({\n  className,\n  ...props\n}: Radio.Root.Props<string>) {\n  return (\n    <Radio.Root\n      data-slot=\"radio-group-item\"\n      className={cn(\n        // layout — Fluent 16px circle. The Root itself flex-centers the\n        // indicator on BOTH axes: Base UI's `Radio.Root` renders a `display:\n        // block` <span>, so without this the indicator (an 8px-tall block-level\n        // flex box that stretches to the content width) sits flush against the\n        // top of the circle — horizontally centered but ~3px high on the\n        // vertical. `group` lets the checked dot (a child span) pick up the\n        // root's hover/press to ramp alongside the ring.\n        \"group flex aspect-square size-4 shrink-0 cursor-pointer items-center justify-center rounded-full border bg-background\",\n        // motion\n        \"outline-none transition-colors duration-fast ease-ease\",\n        // unchecked — Fluent NeutralStrokeAccessible rest/hover/pressed ramp\n        \"border-stroke-accessible hover:border-stroke-accessible-hover active:border-stroke-accessible-pressed\",\n        // checked — Fluent brand ring (the dot itself lives in the indicator),\n        // stepping through CompoundBrandStroke Hover/Pressed; dark hover\n        // brightens to brand-80 (Button's per-theme ramp). These outrank the\n        // neutral hover/active border by specificity so the ring stays brand.\n        \"data-[checked]:border-primary data-[checked]:hover:border-brand-70 data-[checked]:active:border-brand-60 dark:data-[checked]:hover:border-brand-80\",\n        // disabled — opacity-based (conventions §4), re-expressed against\n        // Base UI's data-disabled presence attribute (this renders a\n        // <span>, not a native disableable element)\n        \"data-[disabled]:pointer-events-none data-[disabled]:opacity-50\",\n        // focus — generic offset-ring recipe (conventions §4)\n        \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n        // invalid — shadcn aria-invalid treatment (conventions §4)\n        \"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40\",\n        className\n      )}\n      {...props}\n    >\n      <Radio.Indicator\n        data-slot=\"radio-group-indicator\"\n        className=\"flex items-center justify-center\"\n      >\n        {/* dot — brand fill, ramping with the ring via the root's `group`\n            hover/press (CompoundBrandForeground1 Hover/Pressed). */}\n        <span className=\"size-2 rounded-full bg-primary transition-colors duration-fast ease-ease group-hover:bg-brand-70 group-active:bg-brand-60 dark:group-hover:bg-brand-80\" />\n      </Radio.Indicator>\n    </Radio.Root>\n  );\n}\n\nexport { RadioGroup, RadioGroupItem };\n"
    }
  ]
}
