{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "radio-group",
  "title": "Radio Group",
  "type": "registry:ui",
  "files": [
    {
      "path": "packages/registry-svelte/components/radio-group/RadioGroup.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Snippet } from 'svelte'\n  import type { HTMLAttributes } from 'svelte/elements'\n\n  export type RadioOption = string | { label: string; value: string; disabled?: boolean }\n\n  /** Context shared with RadioGroupItem / RadioButton. Getters stay reactive in children. */\n  export interface RadioGroupContext {\n    readonly current: unknown\n    readonly disabled: boolean\n    readonly size: 'small' | 'middle' | 'large'\n    readonly optionType: 'default' | 'button'\n    readonly buttonVariant: 'outline' | 'solid'\n    readonly orientation: 'horizontal' | 'vertical'\n    readonly tabbableValue: unknown\n    select: (value: unknown) => void\n    register: (value: unknown, disabled: boolean, el: HTMLElement) => void\n    unregister: (el: HTMLElement) => void\n  }\n\n  // Omit `children`: the snippet carries the group value, which narrows the base `Snippet` type.\n  export interface RadioGroupProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {\n    /** The controlled value. Bindable. */\n    value?: any\n    /** Initial value for uncontrolled use. */\n    defaultValue?: any\n    /** Called with the new value after every commit. */\n    onValueChange?: (value: any) => void\n    /** When `true`, prevents the user from interacting with the radio group */\n    disabled?: boolean\n    /** The orientation of the radio items */\n    orientation?: 'horizontal' | 'vertical'\n    /** When `true`, keyboard navigation will loop from last item to first, and vice versa */\n    loop?: boolean\n    /** Label for the radio group */\n    label?: string\n    /** Hint text for the radio group */\n    hint?: string\n    /** Error messages to display */\n    errorMessages?: string | string[]\n    /** Whether to show error state */\n    error?: boolean\n    /** Density of the radio items */\n    density?: 'compact' | 'default' | 'comfortable'\n    /** Whether the radio group appears flat */\n    flat?: boolean\n    /** Whether to show a border around the group */\n    bordered?: boolean\n    /** The reading direction */\n    dir?: 'ltr' | 'rtl'\n    /** Array of options to render automatically */\n    options?: RadioOption[]\n    /** Size of button-style radios */\n    size?: 'small' | 'middle' | 'large'\n    /** Type of options to render */\n    optionType?: 'default' | 'button'\n    /** Visual variant for button-style radios */\n    buttonVariant?: 'outline' | 'solid'\n    /** Form field name. Renders a hidden input so the value submits with native forms. */\n    name?: string\n    /** Marks the group as required for assistive tech. */\n    required?: boolean\n    children?: Snippet<[{ value: unknown }]>\n    ref?: HTMLDivElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { setContext } from 'svelte'\n  import { cn } from '$lib/utils'\n  import RadioButton from './RadioButton.svelte'\n  import RadioGroupItem from './RadioGroupItem.svelte'\n\n  let {\n    class: className,\n    value = $bindable(),\n    defaultValue = undefined,\n    onValueChange,\n    disabled = false,\n    orientation = 'vertical',\n    loop = true,\n    label = undefined,\n    hint = undefined,\n    errorMessages = undefined,\n    error = false,\n    density = 'default',\n    flat = false,\n    bordered = false,\n    dir = undefined,\n    options = undefined,\n    size = 'middle',\n    optionType = 'default',\n    buttonVariant = 'outline',\n    name = undefined,\n    required = false,\n    children,\n    onkeydown: onkeydownProp,\n    ref = $bindable(null),\n    ...restProps\n  }: RadioGroupProps = $props()\n\n  let root: HTMLDivElement | null = null\n  // Uncontrolled seed: intentionally the initial `defaultValue` only.\n  // svelte-ignore state_referenced_locally\n  let internal = $state<any>(defaultValue)\n  let registered = $state<{ value: unknown; disabled: boolean; el: HTMLElement }[]>([])\n\n  // `bind:this` can only target one variable; mirror the root onto `ref`.\n  $effect(() => {\n    ref = root\n  })\n\n  const current = $derived(value ?? internal)\n\n  const tabbableValue = $derived.by(() => {\n    if (registered.some((r) => r.value === current && !r.disabled)) return current\n    return registered.find((r) => !r.disabled)?.value\n  })\n\n  function select(next: unknown) {\n    if (disabled) return\n    internal = next\n    value = next\n    onValueChange?.(next)\n  }\n\n  setContext<RadioGroupContext>('radioGroup', {\n    get current() {\n      return current\n    },\n    get disabled() {\n      return disabled\n    },\n    get size() {\n      return size\n    },\n    get optionType() {\n      return optionType\n    },\n    get buttonVariant() {\n      return buttonVariant\n    },\n    get orientation() {\n      return orientation\n    },\n    get tabbableValue() {\n      return tabbableValue\n    },\n    select,\n    register: (v, d, el) => {\n      // Idempotent: the item $effect re-runs when registration itself\n      // invalidates its deps, so a redundant write would ping-pong forever\n      // (effect_update_depth_exceeded). Skip the write when nothing changed.\n      const existing = registered.find((r) => r.el === el)\n      if (existing && existing.value === v && existing.disabled === d) return\n      registered = [...registered.filter((r) => r.el !== el), { value: v, disabled: d, el }]\n    },\n    unregister: (el) => {\n      if (!registered.some((r) => r.el === el)) return\n      registered = registered.filter((r) => r.el !== el)\n    },\n  })\n\n  function handleKeydown(event: KeyboardEvent) {\n    const target = event.target as HTMLElement | null\n    if (!target?.matches?.('[role=\"radio\"]')) return\n    if (!root) return\n    const items = [...root.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]')]\n    if (items.length === 0) return\n    const index = items.indexOf(target as HTMLButtonElement)\n    if (index === -1) return\n\n    const isEnabled = (i: number) => !items[i]!.hasAttribute('data-disabled')\n    /** Step past disabled items, wrapping when `loop` is on. -1 = nowhere to go. */\n    function step(from: number, direction: 1 | -1): number {\n      let i = from\n      for (let k = 0; k < items.length; k++) {\n        i += direction\n        if (i < 0) {\n          if (!loop) return -1\n          i = items.length - 1\n        } else if (i >= items.length) {\n          if (!loop) return -1\n          i = 0\n        }\n        if (isEnabled(i)) return i\n      }\n      return -1\n    }\n\n    const rtl = dir === 'rtl'\n    const horizontal = orientation === 'horizontal'\n    let next = -1\n    if (event.key === 'Home') next = items.findIndex((_, i) => isEnabled(i))\n    else if (event.key === 'End') {\n      next = -1\n      for (let i = items.length - 1; i >= 0; i--) {\n        if (isEnabled(i)) {\n          next = i\n          break\n        }\n      }\n    }\n    else if (horizontal && (event.key === 'ArrowRight' || event.key === 'ArrowLeft')) {\n      const forward = event.key === 'ArrowRight' ? !rtl : rtl\n      next = step(index, forward ? 1 : -1)\n    } else if (!horizontal && (event.key === 'ArrowDown' || event.key === 'ArrowUp')) {\n      next = step(index, event.key === 'ArrowDown' ? 1 : -1)\n    } else return\n\n    if (next === -1) return\n    event.preventDefault()\n    const el = items[next]!\n    el.focus()\n    // The real (possibly non-string) value round-trips via registration,\n    // matched by element identity; fall back to the data-value attribute.\n    const candidate = registered.find((r) => r.el === el)?.value\n    select(candidate !== undefined ? candidate : el.getAttribute('data-value'))\n  }\n\n  // Density classes\n  const densityClasses = {\n    compact: 'gap-1',\n    default: 'gap-3',\n    comfortable: 'gap-4',\n  }\n\n  const hasError = $derived.by(() => {\n    if (error) return true\n    if (errorMessages && (typeof errorMessages === 'string' ? errorMessages : errorMessages.length > 0)) return true\n    return false\n  })\n\n  function normalizeOption(option: RadioOption): { label: string; value: string; disabled?: boolean } {\n    if (typeof option === 'string') {\n      return { label: option, value: option }\n    }\n    return option\n  }\n</script>\n\n<!--\n  `class` is forwarded ONLY to the radiogroup root below (per shadcn\n  convention). Applying it on the outer wrapper as well caused grid-*\n  utilities to fight the wrapper's `flex flex-col`, so consumers had\n  to fall back to column-count hacks. Forwarding to one element keeps\n  layout intent unambiguous.\n-->\n<div class=\"flex flex-col gap-2\">\n  {#if label}\n    <span class=\"text-sm font-medium\">\n      {label}\n    </span>\n  {/if}\n\n  {#if hint && !hasError}\n    <p class=\"text-muted-foreground text-xs\">\n      {hint}\n    </p>\n  {/if}\n\n  <div\n    bind:this={root}\n    role=\"radiogroup\"\n    data-uipkge=\"\"\n    data-slot=\"radio-group\"\n    data-orientation={orientation}\n    aria-disabled={disabled || undefined}\n    aria-required={required || undefined}\n    aria-invalid={hasError || undefined}\n    {dir}\n    class={cn(\n      'grid gap-3',\n      orientation === 'horizontal' && 'flex flex-row items-center gap-4',\n      optionType === 'button' && orientation === 'horizontal' && 'flex flex-row items-stretch gap-0',\n      optionType === 'button' && orientation === 'vertical' && 'flex flex-col items-stretch gap-0',\n      optionType !== 'button' && densityClasses[density],\n      bordered && 'rounded-lg border p-4',\n      className,\n    )}\n    {...restProps}\n    onkeydown={(e) => {\n      handleKeydown(e)\n      onkeydownProp?.(e)\n    }}\n  >\n    {#if name}\n      <input type=\"hidden\" {name} value={current == null ? '' : String(current)} />\n    {/if}\n    {#if options && options.length > 0}\n      {#if optionType === 'button'}\n        {#each options as option (normalizeOption(option).value)}\n          {@const opt = normalizeOption(option)}\n          <RadioButton value={opt.value} disabled={opt.disabled} label={opt.label} />\n        {/each}\n      {:else}\n        {#each options as option (normalizeOption(option).value)}\n          {@const opt = normalizeOption(option)}\n          <div class=\"flex items-center gap-2\">\n            <RadioGroupItem id={opt.value} value={opt.value} disabled={opt.disabled} />\n            <label\n              for={opt.value}\n              class={cn('cursor-pointer text-sm font-medium select-none', opt.disabled && 'cursor-not-allowed opacity-50')}\n            >\n              {opt.label}\n            </label>\n          </div>\n        {/each}\n      {/if}\n    {/if}\n\n    {@render children?.({ value: current })}\n  </div>\n\n  {#if hasError}\n    <div class=\"flex flex-col gap-0.5\">\n      {#if typeof errorMessages === 'string'}\n        <p class=\"text-destructive text-xs\">\n          {errorMessages}\n        </p>\n      {:else}\n        {#each errorMessages ?? [] as msg, i (i)}\n          <p class=\"text-destructive text-xs\">\n            {msg}\n          </p>\n        {/each}\n      {/if}\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/radio-group/RadioGroup.svelte"
    },
    {
      "path": "packages/registry-svelte/components/radio-group/RadioGroupItem.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Snippet } from 'svelte'\n  import type { HTMLButtonAttributes } from 'svelte/elements'\n  import type { RadioGroupContext } from './RadioGroup.svelte'\n\n  export interface RadioGroupItemProps extends HTMLButtonAttributes {\n    value: any\n    /** Size of the radio item */\n    size?: 'sm' | 'md' | 'lg'\n    /** Custom color for the checked state */\n    color?: 'primary' | 'secondary' | 'success' | 'warning' | 'error' | 'info' | string\n    /** Label text displayed next to the radio item */\n    label?: string\n    /** Hint text shown below the radio item */\n    hint?: string\n    /** Error message to display */\n    errorMessages?: string | string[]\n    /** Whether to show error state */\n    error?: boolean\n    /** Density of the radio item */\n    density?: 'compact' | 'default' | 'comfortable'\n    /** Label position - before or after the radio */\n    labelPosition?: 'before' | 'after'\n    /** Loading state */\n    loading?: boolean\n    /** Hide the indicator icon */\n    hideIcon?: boolean\n    /** Custom indicator markup. Defaults to a filled circle. */\n    children?: Snippet\n    ref?: HTMLButtonElement | null\n  }\n\n  // Re-exported for consumers that build custom items against the group context.\n  export type { RadioGroupContext }\n</script>\n\n<script lang=\"ts\">\n  import { getContext, untrack } from 'svelte'\n  import { Circle } from '@lucide/svelte'\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value,\n    id = undefined,\n    disabled = undefined,\n    size = 'md',\n    color = 'primary',\n    label = undefined,\n    hint = undefined,\n    errorMessages = undefined,\n    error = false,\n    density = 'default',\n    labelPosition = 'after',\n    loading = false,\n    hideIcon = false,\n    children,\n    onclick: onclickProp,\n    ref = $bindable(null),\n    ...restProps\n  }: RadioGroupItemProps = $props()\n\n  const group = getContext<RadioGroupContext | undefined>('radioGroup')\n\n  let el: HTMLButtonElement | null = null\n\n  $effect(() => {\n    ref = el\n  })\n\n  const effectiveDisabled = $derived(Boolean(loading) || Boolean(disabled ?? group?.disabled ?? false))\n  const checked = $derived(group?.current === value)\n  const tabbable = $derived(group ? group.tabbableValue === value : true)\n\n  $effect(() => {\n    if (!el || !group) return\n    const node = el\n    // Track value + disabled so re-registration follows prop updates.\n    const v = value\n    const d = effectiveDisabled\n    // untrack: the register write must not subscribe this effect to the\n    // group's `registered` state, or the write re-triggers the effect\n    // (effect_update_depth_exceeded). Registration itself is idempotent.\n    untrack(() => group.register(v, d, node))\n    return () => group.unregister(node)\n  })\n\n  // Size classes\n  const sizeClasses = {\n    sm: 'size-3.5',\n    md: 'size-4',\n    lg: 'size-5',\n  }\n\n  const indicatorSizes = {\n    sm: 'size-1.5',\n    md: 'size-2',\n    lg: 'size-2.5',\n  }\n\n  // Color classes\n  const colorClasses: Record<string, string> = {\n    primary: 'data-[state=checked]:border-primary',\n    secondary: 'data-[state=checked]:border-secondary',\n    success: 'data-[state=checked]:border-success data-[state=checked]:text-success',\n    warning: 'data-[state=checked]:border-warning data-[state=checked]:text-warning',\n    error: 'data-[state=checked]:border-destructive data-[state=checked]:text-destructive',\n    info: 'data-[state=checked]:border-info data-[state=checked]:text-info',\n  }\n\n  // Density classes\n  const densityClasses = {\n    compact: 'gap-1',\n    default: 'gap-2',\n    comfortable: 'gap-3',\n  }\n\n  const hasError = $derived.by(() => {\n    if (error) return true\n    if (errorMessages && (typeof errorMessages === 'string' ? errorMessages : errorMessages.length > 0)) return true\n    return false\n  })\n</script>\n\n<div class={cn('flex flex-col', densityClasses[density])}>\n  <div class=\"flex items-center\">\n    {#if label && labelPosition === 'before'}\n      <label\n        for={id}\n        class={cn(\n          'mr-2 cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n          hasError && 'text-destructive',\n          effectiveDisabled && 'cursor-not-allowed opacity-50',\n        )}\n      >\n        {label}\n      </label>\n    {/if}\n\n    <!-- aria-invalid mirrors the Vue twin's error state (svelte's role table omits radio). -->\n    <!-- svelte-ignore a11y_role_supports_aria_props -->\n    <button\n      bind:this={el}\n      type=\"button\"\n      role=\"radio\"\n      {id}\n      data-uipkge=\"\"\n      data-slot=\"radio-group-item\"\n      data-value={typeof value === 'string' ? value : undefined}\n      data-state={checked ? 'checked' : 'unchecked'}\n      data-disabled={effectiveDisabled || undefined}\n      aria-checked={checked}\n      aria-busy={loading || undefined}\n      aria-invalid={hasError || undefined}\n      disabled={effectiveDisabled}\n      tabindex={tabbable ? 0 : -1}\n      class={cn(\n        'border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square shrink-0 rounded-full border shadow-sm transition-colors duration-200 outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50',\n        sizeClasses[size],\n        colorClasses[color] || colorClasses.primary,\n        hasError && 'border-destructive',\n        loading && 'opacity-50',\n        className,\n      )}\n      {...restProps}\n      onclick={(e) => {\n        if (!effectiveDisabled) group?.select(value)\n        onclickProp?.(e)\n      }}\n    >\n      <span data-uipkge=\"\" data-slot=\"radio-group-indicator\" class=\"relative flex items-center justify-center\">\n        {#if children}\n          {@render children()}\n        {:else if !hideIcon}\n          <Circle\n            class={cn(\n              'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-current text-current',\n              indicatorSizes[size],\n            )}\n          />\n        {/if}\n      </span>\n    </button>\n\n    {#if label && labelPosition === 'after'}\n      <label\n        for={id}\n        class={cn(\n          'ml-2 cursor-pointer text-sm leading-none font-medium select-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',\n          hasError && 'text-destructive',\n          effectiveDisabled && 'cursor-not-allowed opacity-50',\n        )}\n      >\n        {label}\n      </label>\n    {/if}\n  </div>\n\n  {#if hint && !hasError}\n    <p class=\"text-muted-foreground ml-6 text-xs\">\n      {hint}\n    </p>\n  {/if}\n\n  {#if hasError}\n    <div class=\"ml-6 flex flex-col gap-0.5\">\n      {#if typeof errorMessages === 'string'}\n        <p class=\"text-destructive text-xs\">\n          {errorMessages}\n        </p>\n      {:else}\n        {#each errorMessages ?? [] as msg, i (i)}\n          <p class=\"text-destructive text-xs\">\n            {msg}\n          </p>\n        {/each}\n      {/if}\n    </div>\n  {/if}\n</div>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/radio-group/RadioGroupItem.svelte"
    },
    {
      "path": "packages/registry-svelte/components/radio-group/RadioButton.svelte",
      "content": "<script lang=\"ts\" module>\n  import type { Snippet } from 'svelte'\n  import type { HTMLButtonAttributes } from 'svelte/elements'\n  import type { RadioGroupContext } from './RadioGroup.svelte'\n\n  export interface RadioButtonProps extends HTMLButtonAttributes {\n    value: any\n    /** Size of the button radio. Defaults to the group's size. */\n    size?: 'small' | 'middle' | 'large'\n    /** Visual variant. Defaults to the group's buttonVariant. */\n    variant?: 'outline' | 'solid'\n    /** Label text */\n    label?: string\n    children?: Snippet\n    ref?: HTMLButtonElement | null\n  }\n</script>\n\n<script lang=\"ts\">\n  import { getContext, untrack } from 'svelte'\n  import { cn } from '$lib/utils'\n\n  let {\n    class: className,\n    value,\n    size = undefined,\n    variant = undefined,\n    label = undefined,\n    disabled = undefined,\n    children,\n    onclick: onclickProp,\n    ref = $bindable(null),\n    ...restProps\n  }: RadioButtonProps = $props()\n\n  const group = getContext<RadioGroupContext | undefined>('radioGroup')\n\n  let el: HTMLButtonElement | null = null\n\n  $effect(() => {\n    ref = el\n  })\n\n  const effectiveSize = $derived(size ?? group?.size ?? 'middle')\n  const effectiveVariant = $derived(variant ?? group?.buttonVariant ?? 'outline')\n  const effectiveDisabled = $derived(disabled ?? group?.disabled ?? false)\n  const checked = $derived(group?.current === value)\n  const tabbable = $derived(group ? group.tabbableValue === value : true)\n\n  $effect(() => {\n    if (!el || !group) return\n    const node = el\n    const v = value\n    const d = effectiveDisabled\n    // untrack: keep the register write from subscribing this effect to the\n    // group's `registered` state (effect_update_depth_exceeded). See RadioGroup.\n    untrack(() => group.register(v, d, node))\n    return () => group.unregister(node)\n  })\n\n  const sizeClasses = {\n    small: 'h-7 px-2.5 text-xs',\n    middle: 'h-8 px-4 text-sm',\n    large: 'h-10 px-4.5 text-base',\n  }\n\n  const variantClasses = {\n    outline: cn(\n      'border border-input bg-transparent text-foreground hover:text-foreground hover:bg-muted/50',\n      'data-[state=checked]:border-primary data-[state=checked]:text-primary',\n      'disabled:hover:bg-transparent',\n    ),\n    solid: cn(\n      'border border-input bg-transparent text-foreground hover:text-foreground hover:bg-muted/50',\n      'data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground',\n      'disabled:hover:bg-transparent',\n    ),\n  }\n\n  const groupClasses = $derived(\n    group?.orientation === 'vertical'\n      ? 'rounded-md w-full justify-start'\n      : cn('rounded-none first:rounded-l-md last:rounded-r-md', 'border-l-0 first:border-l', '-ml-px first:ml-0'),\n  )\n</script>\n\n<button\n  bind:this={el}\n  type=\"button\"\n  role=\"radio\"\n  data-uipkge=\"\"\n  data-slot=\"radio-button\"\n  data-value={typeof value === 'string' ? value : undefined}\n  data-state={checked ? 'checked' : 'unchecked'}\n  data-disabled={effectiveDisabled || undefined}\n  aria-checked={checked}\n  disabled={effectiveDisabled}\n  tabindex={tabbable ? 0 : -1}\n  class={cn(\n    'inline-flex items-center justify-center gap-2 font-medium whitespace-nowrap transition-colors duration-200',\n    'focus-visible:border-ring focus-visible:ring-ring/50 outline-none focus-visible:ring-[3px]',\n    'disabled:cursor-not-allowed disabled:opacity-50',\n    sizeClasses[effectiveSize],\n    variantClasses[effectiveVariant],\n    groupClasses,\n    className,\n  )}\n  {...restProps}\n  onclick={(e) => {\n    if (!effectiveDisabled) group?.select(value)\n    onclickProp?.(e)\n  }}\n>\n  {#if children}\n    {@render children()}\n  {:else}\n    {label ?? value}\n  {/if}\n</button>\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/radio-group/RadioButton.svelte"
    },
    {
      "path": "packages/registry-svelte/components/radio-group/index.ts",
      "content": "export { default as RadioGroup, type RadioGroupContext, type RadioGroupProps, type RadioOption } from './RadioGroup.svelte'\nexport { default as RadioGroupItem, type RadioGroupItemProps } from './RadioGroupItem.svelte'\nexport { default as RadioButton, type RadioButtonProps } from './RadioButton.svelte'\n",
      "type": "registry:ui",
      "target": "~/src/lib/components/ui/radio-group/index.ts"
    }
  ],
  "dependencies": [
    "@lucide/svelte"
  ],
  "devDependencies": [],
  "registryDependencies": [],
  "description": "Single-selection group of radio inputs. Vertical or horizontal layout, optional descriptions per item, and full keyboard navigation. Pair with Form for validation messages.",
  "categories": [
    "form"
  ]
}