Styled radio options with roving tabindex, built on the @foldkit/ui RadioGroup submodel.

Preview
Style
Basic
Description
Choice Card
Fieldset
Subscription Plan

Yearly and lifetime plans offer significant savings.

Disabled
Invalid
Notification Preferences

Choose how you want to receive notifications.

RTL
src/demo/views/radio-group.ts422 lines
import { Update } from 'foldkit'
import { Option, Schema as S } from 'effect'
import { modifyFields } from 'foldkit/struct'
import { defineMessageUnion } from 'foldkit/message'
import type { Html, HtmlBuilder } from 'foldkit/html'

import * as radioGroup from '../../generated/registry/ui/radio-group'
import { fieldDescription, fieldLegend, fieldSet } from '../../generated/registry/ui/fieldset'

import { defineSlice, type UpdateReturn } from '../slice'
import type { Model, Message as AppMessage } from '../assemble'

const Message = defineMessageUnion({
  GotRadioGroupMessage: { message: radioGroup.Message },
  GotRadioDescriptionMessage: { message: radioGroup.Message },
  GotRadioChoiceCardMessage: { message: radioGroup.Message },
  GotRadioFieldsetMessage: { message: radioGroup.Message },
  GotRadioDisabledMessage: { message: radioGroup.Message },
  GotRadioInvalidMessage: { message: radioGroup.Message },
  GotRadioRtlMessage: { message: radioGroup.Message },
})

const DensityValue = S.Literals(['default', 'comfortable', 'compact'])
type DensityValue = typeof DensityValue.Type
const DensityGroup = radioGroup.create<DensityValue>()
const densityLabels = {
  default: 'Default',
  comfortable: 'Comfortable',
  compact: 'Compact',
} as const
const densityDescriptions = {
  default: 'Standard spacing for most use cases.',
  comfortable: 'More space between elements.',
  compact: 'Minimal spacing for dense layouts.',
} as const

const PlanValue = S.Literals(['plus', 'pro', 'enterprise'])
type PlanValue = typeof PlanValue.Type
const PlanGroup = radioGroup.create<PlanValue>()
const plans = {
  plus: { label: 'Plus', description: 'For individuals and small teams.' },
  pro: { label: 'Pro', description: 'For growing businesses.' },
  enterprise: { label: 'Enterprise', description: 'For large teams and enterprises.' },
} as const

const SubscriptionValue = S.Literals(['monthly', 'yearly', 'lifetime'])
type SubscriptionValue = typeof SubscriptionValue.Type
const SubscriptionGroup = radioGroup.create<SubscriptionValue>()
const subscriptionLabels = {
  monthly: 'Monthly ($9.99/month)',
  yearly: 'Yearly ($99.99/year)',
  lifetime: 'Lifetime ($299.99)',
} as const

const DisabledValue = S.Literals(['option1', 'option2', 'option3'])
type DisabledValue = typeof DisabledValue.Type
const DisabledGroup = radioGroup.create<DisabledValue>()
const disabledLabels = {
  option1: 'Disabled',
  option2: 'Option 2',
  option3: 'Option 3',
} as const

const NotificationValue = S.Literals(['email', 'sms', 'both'])
type NotificationValue = typeof NotificationValue.Type
const NotificationGroup = radioGroup.create<NotificationValue>()
const notificationLabels = {
  email: 'Email only',
  sms: 'SMS only',
  both: 'Both Email & SMS',
} as const

const rtlLabels = {
  default: 'افتراضي',
  comfortable: 'مريح',
  compact: 'مضغوط',
} as const
const rtlDescriptions = {
  default: 'تباعد قياسي لمعظم حالات الاستخدام.',
  comfortable: 'مساحة أكبر بين العناصر.',
  compact: 'تباعد أدنى للتخطيطات الكثيفة.',
} as const

export const radioGroupView = (model: Model, h: HtmlBuilder<AppMessage>): Html =>
  h.div(
    [h.Class('flex w-full flex-col gap-8')],
    [
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Basic']),
          h.submodel({
            slotId: model.radioGroup.id,
            model: model.radioGroup,
            view: DensityGroup.view,
            viewInputs: radioGroup.styledViewInputs<AppMessage, DensityValue>(
              {
                options: DensityValue.literals,
                selectedValue: model.maybeRadioValue,
                ariaLabel: 'Density',
                optionLabel: (value) => densityLabels[value],
                groupClass: 'w-fit',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotRadioGroupMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Description']),
          h.submodel({
            slotId: model.radioDescription.id,
            model: model.radioDescription,
            view: DensityGroup.view,
            viewInputs: radioGroup.styledViewInputs<AppMessage, DensityValue>(
              {
                options: DensityValue.literals,
                selectedValue: model.maybeRadioDescription,
                ariaLabel: 'Density with descriptions',
                optionLabel: (value) => densityLabels[value],
                optionDescription: (value) => densityDescriptions[value],
                optionLayout: 'field',
                groupClass: 'w-fit',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotRadioDescriptionMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Choice Card']),
          h.submodel({
            slotId: model.radioChoiceCard.id,
            model: model.radioChoiceCard,
            view: PlanGroup.view,
            viewInputs: radioGroup.styledViewInputs<AppMessage, PlanValue>(
              {
                options: PlanValue.literals,
                selectedValue: model.maybeRadioPlan,
                ariaLabel: 'Subscription plan choice card',
                optionLabel: (value) => plans[value].label,
                optionDescription: (value) => plans[value].description,
                optionLayout: 'choice-card',
                groupClass: 'max-w-sm',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotRadioChoiceCardMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Fieldset']),
          fieldSet<AppMessage>(
            { className: 'w-full max-w-xs' },
            [
              fieldLegend<AppMessage>({ variant: 'label' }, ['Subscription Plan'], h),
              fieldDescription<AppMessage>(
                {},
                ['Yearly and lifetime plans offer significant savings.'],
                h,
              ),
              h.submodel({
                slotId: model.radioFieldset.id,
                model: model.radioFieldset,
                view: SubscriptionGroup.view,
                viewInputs: radioGroup.styledViewInputs<AppMessage, SubscriptionValue>(
                  {
                    options: SubscriptionValue.literals,
                    selectedValue: model.maybeRadioSubscription,
                    ariaLabel: 'Subscription Plan',
                    name: 'subscription-plan',
                    optionLabel: (value) => subscriptionLabels[value],
                    optionLayout: 'field',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotRadioFieldsetMessage({ message }),
              }),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Disabled']),
          h.submodel({
            slotId: model.radioDisabled.id,
            model: model.radioDisabled,
            view: DisabledGroup.view,
            viewInputs: radioGroup.styledViewInputs<AppMessage, DisabledValue>(
              {
                options: DisabledValue.literals,
                selectedValue: model.maybeRadioDisabled,
                ariaLabel: 'Disabled example',
                isOptionDisabled: (value) => value === 'option1',
                optionLabel: (value) => disabledLabels[value],
                optionLayout: 'field',
                groupClass: 'w-fit',
              },
              h,
            ),
            toParentMessage: (message) => Message.GotRadioDisabledMessage({ message }),
          }),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['Invalid']),
          fieldSet<AppMessage>(
            { className: 'w-full max-w-xs' },
            [
              fieldLegend<AppMessage>({ variant: 'label' }, ['Notification Preferences'], h),
              fieldDescription<AppMessage>(
                {},
                ['Choose how you want to receive notifications.'],
                h,
              ),
              h.submodel({
                slotId: model.radioInvalid.id,
                model: model.radioInvalid,
                view: NotificationGroup.view,
                viewInputs: radioGroup.styledViewInputs<AppMessage, NotificationValue>(
                  {
                    options: NotificationValue.literals,
                    selectedValue: model.maybeRadioNotification,
                    ariaLabel: 'Notification Preferences',
                    name: 'notification-preferences',
                    isInvalid: true,
                    optionLabel: (value) => notificationLabels[value],
                    optionLayout: 'field',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotRadioInvalidMessage({ message }),
              }),
            ],
            h,
          ),
        ],
      ),
      h.div(
        [h.Class('flex w-full flex-col gap-2')],
        [
          h.div([h.Class('px-1 text-xs font-medium text-muted-foreground')], ['RTL']),
          h.div(
            [h.Dir('rtl')],
            [
              h.submodel({
                slotId: model.radioRtl.id,
                model: model.radioRtl,
                view: DensityGroup.view,
                viewInputs: radioGroup.styledViewInputs<AppMessage, DensityValue>(
                  {
                    options: DensityValue.literals,
                    selectedValue: model.maybeRadioRtl,
                    ariaLabel: 'الكثافة',
                    optionLabel: (value) => rtlLabels[value],
                    optionDescription: (value) => rtlDescriptions[value],
                    optionLayout: 'field',
                    groupClass: 'w-fit',
                  },
                  h,
                ),
                toParentMessage: (message) => Message.GotRadioRtlMessage({ message }),
              }),
            ],
          ),
        ],
      ),
    ],
  )

const foldRadioGroup = Update.foldChild({
  update: DensityGroup.update,
  read: (model: State) => Option.some(model.radioGroup),
  write: (model, next) => modifyFields(model, { radioGroup: () => next }),
  toParentMessage: (message) => Message.GotRadioGroupMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioValue: () => Option.some(outMessage.value) }),
  }),
})

const foldRadioDescription = Update.foldChild({
  update: DensityGroup.update,
  read: (model: State) => Option.some(model.radioDescription),
  write: (model, next) => modifyFields(model, { radioDescription: () => next }),
  toParentMessage: (message) => Message.GotRadioDescriptionMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioDescription: () => Option.some(outMessage.value) }),
  }),
})

const foldRadioChoiceCard = Update.foldChild({
  update: PlanGroup.update,
  read: (model: State) => Option.some(model.radioChoiceCard),
  write: (model, next) => modifyFields(model, { radioChoiceCard: () => next }),
  toParentMessage: (message) => Message.GotRadioChoiceCardMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioPlan: () => Option.some(outMessage.value) }),
  }),
})

const foldRadioFieldset = Update.foldChild({
  update: SubscriptionGroup.update,
  read: (model: State) => Option.some(model.radioFieldset),
  write: (model, next) => modifyFields(model, { radioFieldset: () => next }),
  toParentMessage: (message) => Message.GotRadioFieldsetMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioSubscription: () => Option.some(outMessage.value) }),
  }),
})

const foldRadioDisabled = Update.foldChild({
  update: DisabledGroup.update,
  read: (model: State) => Option.some(model.radioDisabled),
  write: (model, next) => modifyFields(model, { radioDisabled: () => next }),
  toParentMessage: (message) => Message.GotRadioDisabledMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioDisabled: () => Option.some(outMessage.value) }),
  }),
})

const foldRadioInvalid = Update.foldChild({
  update: NotificationGroup.update,
  read: (model: State) => Option.some(model.radioInvalid),
  write: (model, next) => modifyFields(model, { radioInvalid: () => next }),
  toParentMessage: (message) => Message.GotRadioInvalidMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioNotification: () => Option.some(outMessage.value) }),
  }),
})

const foldRadioRtl = Update.foldChild({
  update: DensityGroup.update,
  read: (model: State) => Option.some(model.radioRtl),
  write: (model, next) => modifyFields(model, { radioRtl: () => next }),
  toParentMessage: (message) => Message.GotRadioRtlMessage({ message }),
  foldOutMessage: (outMessage) => (model: State) => ({
    model: modifyFields(model, { maybeRadioRtl: () => Option.some(outMessage.value) }),
  }),
})

const fields = {
  radioGroup: radioGroup.Model,
  maybeRadioValue: S.Option(DensityValue),
  radioDescription: radioGroup.Model,
  maybeRadioDescription: S.Option(DensityValue),
  radioChoiceCard: radioGroup.Model,
  maybeRadioPlan: S.Option(PlanValue),
  radioFieldset: radioGroup.Model,
  maybeRadioSubscription: S.Option(SubscriptionValue),
  radioDisabled: radioGroup.Model,
  maybeRadioDisabled: S.Option(DisabledValue),
  radioInvalid: radioGroup.Model,
  maybeRadioNotification: S.Option(NotificationValue),
  radioRtl: radioGroup.Model,
  maybeRadioRtl: S.Option(DensityValue),
}

const stateSchema = S.Struct(fields)
type State = typeof stateSchema.Type

export const slice = defineSlice({
  fields,
  init: {
    radioGroup: radioGroup.init({ id: 'radio-group-demo' }),
    maybeRadioValue: Option.some('comfortable' satisfies DensityValue),
    radioDescription: radioGroup.init({ id: 'radio-description-demo' }),
    maybeRadioDescription: Option.some('comfortable' satisfies DensityValue),
    radioChoiceCard: radioGroup.init({ id: 'radio-choice-card-demo' }),
    maybeRadioPlan: Option.some('plus' satisfies PlanValue),
    radioFieldset: radioGroup.init({ id: 'radio-fieldset-demo' }),
    maybeRadioSubscription: Option.some('monthly' satisfies SubscriptionValue),
    radioDisabled: radioGroup.init({ id: 'radio-disabled-demo' }),
    maybeRadioDisabled: Option.some('option2' satisfies DisabledValue),
    radioInvalid: radioGroup.init({ id: 'radio-invalid-demo' }),
    maybeRadioNotification: Option.some('email' satisfies NotificationValue),
    radioRtl: radioGroup.init({ id: 'radio-rtl-demo' }),
    maybeRadioRtl: Option.some('comfortable' satisfies DensityValue),
  },
  messages: [
    Message.GotRadioGroupMessage,
    Message.GotRadioDescriptionMessage,
    Message.GotRadioChoiceCardMessage,
    Message.GotRadioFieldsetMessage,
    Message.GotRadioDisabledMessage,
    Message.GotRadioInvalidMessage,
    Message.GotRadioRtlMessage,
  ],
  handlers: (model: State) => ({
    GotRadioGroupMessage: (payload: typeof Message.GotRadioGroupMessage.Type): UpdateReturn =>
      foldRadioGroup(model, payload.message),
    GotRadioDescriptionMessage: (
      payload: typeof Message.GotRadioDescriptionMessage.Type,
    ): UpdateReturn => foldRadioDescription(model, payload.message),
    GotRadioChoiceCardMessage: (
      payload: typeof Message.GotRadioChoiceCardMessage.Type,
    ): UpdateReturn => foldRadioChoiceCard(model, payload.message),
    GotRadioFieldsetMessage: (payload: typeof Message.GotRadioFieldsetMessage.Type): UpdateReturn =>
      foldRadioFieldset(model, payload.message),
    GotRadioDisabledMessage: (payload: typeof Message.GotRadioDisabledMessage.Type): UpdateReturn =>
      foldRadioDisabled(model, payload.message),
    GotRadioInvalidMessage: (payload: typeof Message.GotRadioInvalidMessage.Type): UpdateReturn =>
      foldRadioInvalid(model, payload.message),
    GotRadioRtlMessage: (payload: typeof Message.GotRadioRtlMessage.Type): UpdateReturn =>
      foldRadioRtl(model, payload.message),
  }),
  samples: [],
})

This code is shown verbatim — it’s the exact file used for this preview. Much of the surrounding wiring (slice, fields, messages, handlers) belongs to this site’s demo harness; you only need the view and the state that matters for your own app. Adapt what you need.

Installation

Add this component to your project:

pnpm dlx shadcn@latest add @foldcn/radio-group

Source

The component ships as plain source — no build step, no wrapper. Copy it and make it yours.

registry/default/ui/radio-group.ts335 lines
/** Stateful submodel — import the whole module as a namespace and wire its
 *  Model/Message/init/update into your app:
 *  `import * as RadioGroup from '@/components/ui/radio-group'`
 */
import { RadioGroup as FoldkitRadioGroup } from '@foldkit/ui'
import type { Option } from 'effect/Option'
import type { Html, HtmlBuilder } from 'foldkit/html'

import { cn } from '@/lib/utils'
import {
  fieldBaseClass,
  fieldContentClass,
  fieldDescriptionClass,
  fieldLabelClass,
  fieldOrientationClasses,
  fieldTitleClass,
  labelClass,
} from './fieldset'

/**
 * Two rendering paths in `styledViewInputs`:
 *  - `optionLabel` (+ optional `optionDescription`) → upstream anatomy: a
 *    16px circle control carrying the foldkit attributes, an indicator dot
 *    mounted only while selected, and a wired label/description pair. Set
 *    `optionLayout` to compose the documented Field or Choice Card anatomy.
 *  - legacy `option` callback → consumer-owned row content inside a
 *    group-attributed wrapper (kept for backward compatibility).
 *
 */

// Re-export the @foldkit/ui RadioGroup surface. Create a bundle once per
// option type:
//
//   export const PlanRadioGroup = RadioGroup.create<"Startup" | "Business">()

export const create = FoldkitRadioGroup.create
export const init = FoldkitRadioGroup.init
export const Model = FoldkitRadioGroup.Model
export type Model = typeof Model.Type
export const Message = FoldkitRadioGroup.Message
export type Message = typeof Message.Type
export const OutMessage = FoldkitRadioGroup.OutMessage
export type OutMessage = typeof OutMessage.Type

export type Bundle<Value extends string = string> = FoldkitRadioGroup.Bundle<Value>
export type InitConfig = FoldkitRadioGroup.InitConfig
export type ViewInputs<Value extends string = string> = FoldkitRadioGroup.ViewInputs<Value>
export type RenderInfo<Value extends string = string> = FoldkitRadioGroup.RenderInfo<Value>

/** Upstream group component string. Horizontal orientation appends a
 *  responsive row extension (foldcn API exposes orientation; upstream leaves
 *  layout to the consumer). */
export const radioGroupClass = 'grid gap-2 w-full'

export const radioGroupVerticalClass = 'grid gap-2 w-full'

export const radioGroupHorizontalClass = 'grid gap-2 w-full sm:flex-row sm:items-center'

/** Upstream item component string. The disabled: variants are inert under
 *  foldkit (aria-/data- twins are inlined at style resolution). */
export const radioItemClass =
  'border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:border-destructive/50 flex size-4 rounded-full focus-visible:ring-3 aria-invalid:ring-3 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input group-has-[:focus-visible]/field-label:data-checked:border-primary aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-disabled:cursor-not-allowed data-disabled:opacity-50 group/radio-group-item peer relative aspect-square shrink-0 border outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50'

export const radioIndicatorClass = 'flex size-4 items-center justify-center'

export const radioDotClass = 'bg-primary-foreground absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full'

/** Label for the upstream-anatomy option row. The circle control (radioItemClass)
 *  carries `peer` and precedes the label, and foldkit emits aria-disabled/
 *  data-disabled on it when disabled — so the peer twins below are live
 *  (upstream's native peer-disabled variant can never match). */
export const radioItemLabelClass =
  'gap-2 text-sm leading-none font-medium group-data-[disabled]:opacity-50 flex items-center select-none peer-aria-disabled:pointer-events-none peer-aria-disabled:cursor-not-allowed peer-data-[disabled]:pointer-events-none peer-data-[disabled]:cursor-not-allowed'

export const radioItemDescriptionClass = 'text-sm text-muted-foreground'

/** Legacy consumer-drawn row (used when `option` callback is supplied). */
export const radioOptionClass =
  'group/radio-group-item peer relative flex cursor-pointer select-none items-center justify-between rounded-md border border-border bg-card p-4 text-card-foreground shadow-sm transition-colors after:absolute after:-inset-x-3 after:-inset-y-2 hover:bg-accent hover:text-accent-foreground data-[checked]:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50'

/** Legacy path label: nested inside the option row (which itself is the
 *  `group/radio-group-item` carrying foldkit's data-disabled), so disabled
 *  state flows via the group variant — a `peer-*` form can never match a
 *  descendant of the peer element. */
export const radioOptionLabelClass =
  'text-sm font-medium leading-none group-data-[disabled]/radio-group-item:cursor-not-allowed group-data-[disabled]/radio-group-item:opacity-70'

export const radioOptionDescriptionClass = 'text-sm text-muted-foreground'

export type StyledViewInputs<M, Value extends string = string> = Readonly<{
  options: ReadonlyArray<Value>
  selectedValue: Option<Value>
  ariaLabel: string
  /** Legacy path: renders each option row's content yourself. Receives the
   *  option value, its per-option render info (`isSelected`, attribute
   *  bundles) and the full render. Mutually exclusive with `optionLabel`. */
  option?: (
    value: Value,
    info: FoldkitRadioGroup.OptionInfo<Value>,
    render: RenderInfo<Value>,
    h: HtmlBuilder<M>,
  ) => Html
  /** Upstream-anatomy path: plain text label per option, rendered next to a
   *  circle control with the indicator dot. */
  optionLabel?: (value: Value) => string
  optionDescription?: (value: Value) => string
  /** `field` matches upstream's Description, Fieldset, Disabled, Invalid, and
   * RTL examples. `choice-card` places the control at the end of a card row. */
  optionLayout?: 'default' | 'field' | 'choice-card'
  orientation?: 'Horizontal' | 'Vertical'
  isOptionDisabled?: (value: Value, index: number) => boolean
  isInvalid?: boolean
  isDisabled?: boolean
  isReadOnly?: boolean
  name?: string
  groupClass?: string
  optionClass?: string
}>

const defaultOptionRow = <M, Value extends string>(
  info: FoldkitRadioGroup.OptionInfo<Value>,
  labelText: string,
  descriptionText: string | undefined,
  optionClass: string | undefined,
  isInvalid: boolean | undefined,
  optionLayout: 'default' | 'field' | 'choice-card',
  h: HtmlBuilder<M>,
): Html => {
  const isDisabled = info.isDisabled
  const control = h.button(
    [
      ...info.option,
      ...(isDisabled ? [h.Disabled(true), h.Tabindex(-1)] : []),
      ...(isInvalid ? [h.Attribute('aria-invalid', 'true')] : []),
      h.DataAttribute('slot', 'radio-group-item'),
      h.Class(radioItemClass),
    ],
    info.isSelected
      ? [
          h.span(
            [h.DataAttribute('slot', 'radio-group-indicator'), h.Class(radioIndicatorClass)],
            [h.span([h.Class(radioDotClass)])],
          ),
        ]
      : [],
  )

  if (optionLayout === 'choice-card') {
    return h.label(
      [
        h.DataAttribute('slot', 'field-label'),
        h.Class(cn(labelClass, fieldLabelClass, optionClass)),
      ],
      [
        h.div(
          [
            h.Role('group'),
            h.DataAttribute('slot', 'field'),
            h.DataAttribute('orientation', 'horizontal'),
            ...(isDisabled ? [h.DataAttribute('disabled', 'true')] : []),
            ...(isInvalid ? [h.DataAttribute('invalid', 'true')] : []),
            h.Class(cn(fieldBaseClass, fieldOrientationClasses.horizontal)),
          ],
          [
            h.div(
              [h.DataAttribute('slot', 'field-content'), h.Class(fieldContentClass)],
              [
                h.div(
                  [...info.label, h.DataAttribute('slot', 'field-label'), h.Class(fieldTitleClass)],
                  [labelText],
                ),
                ...(descriptionText === undefined
                  ? []
                  : [
                      h.p(
                        [
                          ...info.description,
                          h.DataAttribute('slot', 'field-description'),
                          h.Class(cn(fieldDescriptionClass, 'rtl:text-right')),
                        ],
                        [descriptionText],
                      ),
                    ]),
              ],
            ),
            control,
          ],
        ),
      ],
    )
  }

  const label = h.span(
    [
      ...info.label,
      h.DataAttribute('slot', optionLayout === 'field' ? 'field-label' : 'radio-group-item-label'),
      h.Class(
        optionLayout === 'field'
          ? cn(
              labelClass,
              fieldLabelClass,
              descriptionText === undefined ? 'font-normal' : undefined,
            )
          : radioItemLabelClass,
      ),
    ],
    [labelText],
  )

  if (optionLayout === 'field') {
    return h.label(
      [
        h.DataAttribute('slot', 'field'),
        h.DataAttribute('orientation', 'horizontal'),
        ...(isDisabled ? [h.DataAttribute('disabled', 'true')] : []),
        ...(isInvalid ? [h.DataAttribute('invalid', 'true')] : []),
        h.Class(cn(fieldBaseClass, fieldOrientationClasses.horizontal, optionClass)),
      ],
      [
        control,
        descriptionText === undefined
          ? label
          : h.span(
              [h.DataAttribute('slot', 'field-content'), h.Class(fieldContentClass)],
              [
                label,
                h.span(
                  [
                    ...info.description,
                    h.DataAttribute('slot', 'field-description'),
                    h.Class(cn(fieldDescriptionClass, 'rtl:text-right')),
                  ],
                  [descriptionText],
                ),
              ],
            ),
      ],
    )
  }

  return h.label(
    [
      h.Class(
        cn(
          descriptionText === undefined
            ? 'flex w-full items-center gap-2'
            : 'flex w-full items-start gap-2 [&>[role=radio]]:mt-0.5',
          optionClass,
        ),
      ),
    ],
    [
      control,
      descriptionText === undefined
        ? label
        : h.span(
            [h.Class('flex min-w-0 flex-1 flex-col gap-0.5 peer-aria-disabled:opacity-50')],
            [
              label,
              h.span(
                [
                  ...info.description,
                  h.DataAttribute('slot', 'radio-group-item-description'),
                  h.Class(radioItemDescriptionClass),
                ],
                [descriptionText],
              ),
            ],
          ),
    ],
  )
}

/** Build styled `RadioGroup.ViewInputs`. Pass your view's `h`. */
export const styledViewInputs = <M, Value extends string = string>(
  viewInputs: StyledViewInputs<M, Value>,
  h: HtmlBuilder<M>,
): ViewInputs<Value> => {
  const isHorizontal = viewInputs.orientation === 'Horizontal'
  return {
    options: viewInputs.options,
    selectedValue: viewInputs.selectedValue,
    ariaLabel: viewInputs.ariaLabel,
    orientation: viewInputs.orientation,
    isOptionDisabled: viewInputs.isOptionDisabled,
    hasOptionDescription: (value) =>
      viewInputs.option === undefined && viewInputs.optionDescription?.(value) !== undefined,
    isDisabled: viewInputs.isDisabled,
    isReadOnly: viewInputs.isReadOnly,
    name: viewInputs.name,
    toView: (render) => {
      const { group, options, hiddenInput } = render
      return h.div(
        [
          ...group,
          h.DataAttribute('slot', 'radio-group'),
          h.Class(
            cn(
              isHorizontal ? radioGroupHorizontalClass : radioGroupVerticalClass,
              viewInputs.groupClass,
            ),
          ),
        ],
        [
          ...options.map((option) => {
            if (viewInputs.option !== undefined) {
              // Legacy path: consumer-owned content inside the attributed row.
              return h.div(
                [
                  ...option.option,
                  ...(option.isDisabled ? [h.Tabindex(-1)] : []),
                  ...(viewInputs.isInvalid ? [h.Attribute('aria-invalid', 'true')] : []),
                  h.DataAttribute('slot', 'radio-group-item'),
                  h.Class(cn(radioOptionClass, viewInputs.optionClass)),
                ],
                [viewInputs.option(option.value, option, render, h)],
              )
            }
            return defaultOptionRow(
              option,
              viewInputs.optionLabel?.(option.value) ?? String(option.value),
              viewInputs.optionDescription?.(option.value),
              viewInputs.optionClass,
              viewInputs.isInvalid,
              viewInputs.optionLayout ?? 'default',
              h,
            )
          }),
          ...(hiddenInput.length > 0 ? [h.input([...hiddenInput])] : []),
        ],
      )
    },
  }
}