{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "radio-group",
  "title": "Radio Group",
  "type": "registry:ui",
  "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"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/radio-group/radio-group.ts",
      "content": "import { LitElement, css, html, nothing } from 'lit'\nimport { Circle } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nexport type RadioOption = string | { label: string; value: string; disabled?: boolean }\n\ninterface Opt {\n  value: string\n  label: string\n  disabled: boolean\n}\n\ntype ButtonSize = 'small' | 'middle' | 'large'\n\n// --- class tables: React's radio-group.tsx verbatim ---------------------------\nconst groupDensityClasses = {\n  compact: 'gap-1',\n  default: 'gap-3',\n  comfortable: 'gap-4',\n}\n\nconst itemDensityClasses = {\n  compact: 'gap-1',\n  default: 'gap-2',\n  comfortable: 'gap-3',\n}\n\nconst buttonSizeClasses = {\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\nconst buttonVariantClasses = {\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// RadioGroupItem defaults (size=\"md\", color=\"primary\").\nconst itemClasses = 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  'size-4',\n  'data-[state=checked]:border-primary',\n)\nconst indicatorIconClasses = cn(\n  'absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 fill-current text-current',\n  'size-2',\n)\n// <Label> (registry label.tsx) — the Default story pairs each item with one.\nconst labelComponentClasses =\n  'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50'\n\nfunction normalizeOption(option: RadioOption): Opt {\n  if (typeof option === 'string') return { label: option, value: option, disabled: false }\n  return { label: option.label, value: option.value, disabled: Boolean(option.disabled) }\n}\n\nlet uid = 0\n\n/**\n * <uip-radio-group> — the registry RadioGroup (+ RadioGroupItem / RadioButton)\n * as ONE web component.\n *\n * Items come from light-DOM `<option value=\"…\">Label</option>` children (read,\n * not rendered — like `uip-select`) or from the `options` property / JSON\n * attribute (React's `options` prop). Rendered in the shadow root:\n *  - `option-type=\"default\"`: a RadioGroupItem with a label next to it. From\n *    `options` the label uses React's options-path classes; from `<option>`\n *    children it uses the registry <Label> classes (React's composed usage).\n *  - `option-type=\"button\"`: a RadioButton per item.\n * Radios, labels (`for`) and the radiogroup all live in one shadow root, so\n * the id references resolve.\n *\n * Keyboard (Radix parity): roving tabindex; arrow keys (Up/Down when vertical,\n * Left/Right when horizontal, mirrored under `dir=\"rtl\"`) move focus and\n * check; Home/End; `loop` wraps (default true, `loop=\"false\"` to disable);\n * Space checks.\n *\n * Form-associated: submits `name=value`, supports `required`, resets to the\n * initial `value`. Events: `input`, `change` (read `.value`), and\n * `value-change` (React's `onValueChange`, `detail` = the new value).\n *\n * Custom composition: `slot=\"label-n\"` (0-based item index) replaces item n's\n * label with rich markup (both `default` and `button` variants); without it\n * the option label renders as before. Per-item RadioGroupItem props (size,\n * color, hint, labelPosition, loading, hideIcon) stay data-driven — niche\n * next to the main `options` / `option-type` API. Parts: `base` (group),\n * `item` / `button` (per option), `indicator`, `label` (style from the host,\n * e.g. `class=\"[&::part(label)]:font-bold\"`).\n */\nexport class UipRadioGroup extends LitElement {\n  static formAssociated = true\n  static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true }\n  // :host display can't be a utility class (the host has no template of its own).\n  static styles = [tailwind, css`:host { display: block; }`]\n\n  static properties = {\n    value: {},\n    name: { reflect: true },\n    disabled: { type: Boolean, reflect: true },\n    required: { type: Boolean, reflect: true },\n    orientation: { reflect: true },\n    loop: { converter: { fromAttribute: (v: string | null) => v !== 'false' } },\n    label: {},\n    hint: {},\n    errorMessages: { attribute: 'error-messages' },\n    error: { type: Boolean },\n    density: {},\n    flat: { type: Boolean },\n    bordered: { type: Boolean },\n    options: { type: Array },\n    size: {},\n    optionType: { attribute: 'option-type' },\n    buttonVariant: { attribute: 'button-variant' },\n    accessibleLabel: { attribute: 'aria-label' },\n    childOptions: { state: true },\n  }\n\n  value = ''\n  name?: string\n  disabled = false\n  required = false\n  orientation: 'horizontal' | 'vertical' = 'vertical'\n  loop = true\n  label?: string\n  hint?: string\n  errorMessages?: string | string[]\n  error = false\n  density: keyof typeof groupDensityClasses = 'default'\n  /** Accepted for API parity; React's RadioGroup doesn't style it either. */\n  flat = false\n  bordered = false\n  options?: RadioOption[]\n  size: ButtonSize = 'middle'\n  optionType: 'default' | 'button' = 'default'\n  buttonVariant: 'outline' | 'solid' = 'outline'\n  accessibleLabel?: string\n  private childOptions: Opt[] = []\n  private defaultValue = ''\n  private readonly uidBase = `uip-radio-group-${++uid}`\n  private internals = this.attachInternals()\n  private childObserver?: MutationObserver\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'radio-group')\n    this.readOptions()\n    this.defaultValue = this.value\n    // Frameworks render <option> children after the host connects.\n    this.childObserver = new MutationObserver(() => this.readOptions())\n    this.childObserver.observe(this, { childList: true, subtree: true, characterData: true, attributes: true })\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    this.childObserver?.disconnect()\n  }\n\n  private readOptions() {\n    this.childOptions = [...this.querySelectorAll('option')].map((o) => ({\n      value: o.value,\n      label: o.label,\n      disabled: o.disabled,\n    }))\n  }\n\n  private get fromOptionsProp() {\n    return Boolean(this.options && this.options.length > 0)\n  }\n\n  private get items(): Opt[] {\n    return this.fromOptionsProp ? this.options!.map(normalizeOption) : this.childOptions\n  }\n\n  protected willUpdate(changed: Map<string, unknown>) {\n    if (changed.has('value') || changed.has('required')) {\n      this.internals.setFormValue(this.value || null)\n      if (this.required && !this.value) {\n        this.internals.setValidity({ valueMissing: true }, 'Please select one of these options.', this.radios[0])\n      } else {\n        this.internals.setValidity({})\n      }\n    }\n  }\n\n  formResetCallback() {\n    this.value = this.defaultValue\n  }\n  formDisabledCallback(disabled: boolean) {\n    this.disabled = disabled\n  }\n\n  /** Focus the tabbable radio (checked one, else first enabled). */\n  focus(options?: FocusOptions) {\n    const tabbable = this.radios.find((r) => r.tabIndex === 0)\n    if (tabbable) tabbable.focus(options)\n    else super.focus(options)\n  }\n\n  private get radios() {\n    return [...(this.renderRoot?.querySelectorAll<HTMLButtonElement>('[role=radio]') ?? [])]\n  }\n\n  private isDisabled(o: Opt) {\n    return this.disabled || o.disabled\n  }\n\n  private check(o: Opt) {\n    if (this.isDisabled(o) || o.value === this.value) return\n    this.value = o.value\n    this.dispatchEvent(new Event('input', { bubbles: true, composed: true }))\n    this.dispatchEvent(new Event('change', { bubbles: true, composed: true }))\n    this.dispatchEvent(new CustomEvent('value-change', { detail: this.value, bubbles: true, composed: true }))\n  }\n\n  private onKeyDown(e: KeyboardEvent, index: number) {\n    if (e.key === 'Enter') {\n      // WAI-ARIA radio groups don't check on Enter (Radix prevents it too).\n      e.preventDefault()\n      return\n    }\n    const rtl = this.matches(':dir(rtl)')\n    const vertical = this.orientation === 'vertical'\n    const keys: Record<string, 'prev' | 'next' | 'first' | 'last'> = vertical\n      ? { ArrowUp: 'prev', ArrowDown: 'next' }\n      : rtl\n        ? { ArrowRight: 'prev', ArrowLeft: 'next' }\n        : { ArrowLeft: 'prev', ArrowRight: 'next' }\n    Object.assign(keys, { Home: 'first', End: 'last', PageUp: 'first', PageDown: 'last' })\n    const intent = keys[e.key]\n    if (!intent) return\n    e.preventDefault()\n    const items = this.items\n    const enabled = items.map((o, i) => (this.isDisabled(o) ? -1 : i)).filter((i) => i >= 0)\n    if (!enabled.length) return\n    let target: number\n    if (intent === 'first') target = enabled[0]\n    else if (intent === 'last') target = enabled[enabled.length - 1]\n    else {\n      const pos = enabled.indexOf(index)\n      let next = intent === 'next' ? pos + 1 : pos - 1\n      if (next < 0) next = this.loop ? enabled.length - 1 : 0\n      if (next >= enabled.length) next = this.loop ? 0 : enabled.length - 1\n      target = enabled[next]\n    }\n    this.check(items[target])\n    this.radios[target]?.focus()\n  }\n\n  private renderRadio(o: Opt, i: number, tabbable: number, slot: string, classes: string, content: unknown) {\n    const checked = o.value === this.value\n    const disabled = this.isDisabled(o)\n    return html`<button\n      id=${`${this.uidBase}-${i}`}\n      type=\"button\"\n      role=\"radio\"\n      part=${slot === 'radio-button' ? 'button' : 'item'}\n      data-uipkge=\"\"\n      data-slot=${slot}\n      aria-checked=${checked ? 'true' : 'false'}\n      data-state=${checked ? 'checked' : 'unchecked'}\n      data-orientation=${this.orientation}\n      ?data-disabled=${disabled}\n      ?disabled=${disabled}\n      value=${o.value}\n      tabindex=${i === tabbable ? 0 : -1}\n      class=${classes}\n      @click=${() => this.check(o)}\n      @keydown=${(e: KeyboardEvent) => this.onKeyDown(e, i)}\n    >\n      ${content}\n    </button>`\n  }\n\n  private renderItem(o: Opt, i: number, tabbable: number) {\n    const checked = o.value === this.value\n    const indicator = checked\n      ? html`<span\n          part=\"indicator\"\n          data-state=\"checked\"\n          ?data-disabled=${this.isDisabled(o)}\n          data-uipkge=\"\"\n          data-slot=\"radio-group-indicator\"\n          class=\"relative flex items-center justify-center\"\n          >${icon(Circle, 'circle', indicatorIconClasses)}</span\n        >`\n      : nothing\n    const radio = html`<div class=${cn('flex flex-col', itemDensityClasses.default)}>\n      <div class=\"flex items-center\">\n        ${this.renderRadio(o, i, tabbable, 'radio-group-item', itemClasses, indicator)}\n      </div>\n    </div>`\n    const labelClass = this.fromOptionsProp\n      ? cn('cursor-pointer text-sm font-medium select-none', o.disabled && 'cursor-not-allowed opacity-50')\n      : labelComponentClasses\n    return html`<div class=\"flex items-center gap-2\">\n      ${radio}\n      <label\n        part=\"label\"\n        for=${`${this.uidBase}-${i}`}\n        data-slot=${this.fromOptionsProp ? nothing : 'label'}\n        class=${labelClass}\n        ><slot name=${`label-${i}`}>${o.label}</slot></label\n      >\n    </div>`\n  }\n\n  private renderButton(o: Opt, i: number, tabbable: number) {\n    const groupClasses =\n      this.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    const classes = 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      buttonSizeClasses[this.size] ?? buttonSizeClasses.middle,\n      buttonVariantClasses[this.buttonVariant] ?? buttonVariantClasses.outline,\n      groupClasses,\n    )\n    return this.renderRadio(\n      o,\n      i,\n      tabbable,\n      'radio-button',\n      classes,\n      html`<slot name=${`label-${i}`}>${o.label || o.value}</slot>`,\n    )\n  }\n\n  render() {\n    const items = this.items\n    const checkedIndex = items.findIndex((o) => o.value === this.value && !this.isDisabled(o))\n    const tabbable = checkedIndex >= 0 ? checkedIndex : items.findIndex((o) => !this.isDisabled(o))\n    const msgs = this.errorMessages\n    const hasError = Boolean(this.error) || Boolean(msgs && (typeof msgs === 'string' ? msgs : msgs.length > 0))\n    const isButton = this.optionType === 'button'\n    const labelId = `${this.uidBase}-label`\n\n    return html`<div class=\"flex flex-col gap-2\">\n      ${this.label ? html`<label id=${labelId} class=\"text-sm font-medium\">${this.label}</label>` : nothing}\n      ${this.hint && !hasError ? html`<p class=\"text-muted-foreground text-xs\">${this.hint}</p>` : nothing}\n      <div\n        part=\"base\"\n        role=\"radiogroup\"\n        aria-required=${this.required ? 'true' : 'false'}\n        aria-orientation=${this.orientation}\n        aria-labelledby=${this.label && !this.accessibleLabel ? labelId : nothing}\n        aria-label=${this.accessibleLabel ?? nothing}\n        data-slot=\"radio-group\"\n        data-orientation=${this.orientation}\n        ?data-disabled=${this.disabled}\n        class=${cn(\n          'grid gap-3',\n          this.orientation === 'horizontal' && 'flex flex-row items-center gap-4',\n          isButton && this.orientation === 'horizontal' && 'flex flex-row items-stretch gap-0',\n          isButton && this.orientation === 'vertical' && 'flex flex-col items-stretch gap-0',\n          !isButton && (groupDensityClasses[this.density] ?? groupDensityClasses.default),\n          this.bordered && 'rounded-lg border p-4',\n        )}\n      >\n        ${items.map((o, i) => (isButton ? this.renderButton(o, i, tabbable) : this.renderItem(o, i, tabbable)))}\n      </div>\n      ${hasError\n        ? html`<div class=\"flex flex-col gap-0.5\">\n            ${(typeof msgs === 'string' ? [msgs] : (msgs ?? [])).map(\n              (m) => html`<p class=\"text-destructive text-xs\">${m}</p>`,\n            )}\n          </div>`\n        : nothing}\n    </div>`\n  }\n}\n\ncustomElements.get('uip-radio-group') || customElements.define('uip-radio-group', UipRadioGroup)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-radio-group': UipRadioGroup\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/radio-group/radio-group.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
