# Radio

A group of mutually exclusive options, laid out so the user sees them all at once.

Radio is a group of mutually exclusive options that takes part in forms, and
behaves as one control with one label. Useful when the set is small enough to
show in full; once the list needs scrolling, reach for
[`Select`](/components/select).

::component-preview{name="RadioPreview"}
```vue
<template>
  <Radio.Group
    v-model="plan"
    aria-label="Plan"
    class="flex flex-col gap-3"
    :options="{ name: 'plan' }"
  >
    <div
      v-for="option in plans"
      :key="option.value"
      class="flex items-start gap-3"
    >
      <Radio.Root
        :id="`plan-${option.value}`"
        :class="box"
        :disabled="option.disabled"
        :value="option.value"
      >
        <Radio.Indicator :class="dot" />
      </Radio.Root>

      <Label
        :class="label"
        :data-disabled="option.disabled ? 'true' : undefined"
        :for="`plan-${option.value}`"
      >
        {{ option.label }}
      </Label>
    </div>
  </Radio.Group>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Label } from '@maas/mirror/vue/Label'
import { Radio } from '@maas/mirror/vue/Radio'
const box = [
  'flex size-5 items-center justify-center border-2 border-surface',
  'rounded-component-round text-accent-on-solid',
  'outline-4 outline-transparent transition-all duration-100 ease-linear focus-visible:focus-ring',
  'active:border-primary-subtle',
  'data-[state=checked]:bg-accent-solid',
  'data-[state=checked]:border-[transparent]',
  'data-[state=checked]:active:bg-accent-solid-active',
  'data-[disabled=true]:border-disabled-subtle',
  'data-[disabled=true]:data-[state=checked]:bg-disabled-solid',
  'data-[disabled=true]:text-disabled-on-solid',
].join(' ')

const dot = 'rounded-component-round block size-2 bg-current'

const label = [
  'type-component-xs text-surface translate-y-[0.1rem] [--mirror-label-cursor:pointer]',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')

const plans = [
  { value: 'hobby', label: 'Hobby', disabled: false },
  { value: 'team', label: 'Team', disabled: false },
  { value: 'enterprise', label: 'Enterprise', disabled: true },
]

const plan = ref('team')
</script>
```
::

## Usage guidelines

::usage-guidelines

## Anatomy

Assemble the group, one `Radio.Root` per option.

::component-anatomy
---
parts:
  - name: Radio.Group
    required: true
    description: Renders a div with role="radiogroup", plus one hidden input per item.
    children:
      - name: Radio.Root
        required: true
        description: Renders a button with role="radio". One per option.
        children:
          - name: Radio.Indicator
            description: Renders a span. Only while the item is selected.
---
::

```vue
<script setup lang="ts">
import { Field } from '@maas/mirror/vue/Field'
import { Label } from '@maas/mirror/vue/Label'
import { Radio } from '@maas/mirror/vue/Radio'
</script>

<template>
  <Field.Root :options="{ name: 'plan' }">
    <Field.Label :native-label="false">Plan</Field.Label>
    <Radio.Group v-model="plan">
      <div v-for="option in plans" :key="option.value">
        <Radio.Root :id="`plan-${option.value}`" :value="option.value">
          <Radio.Indicator />
        </Radio.Root>
        <Label :id="`plan-${option.value}-label`" :for="`plan-${option.value}`">
          {{ option.label }}
        </Label>
      </div>
    </Radio.Group>
  </Field.Root>
</template>
```

## Examples

### Labeling

`Radio.Group` is not a labelable element, so a `Field.Label` has to opt out of
rendering a `<label>` and name the group through `aria-labelledby` instead.

```vue
<template>
  <Field.Root :options="{ name: 'plan' }">
    <Field.Label :native-label="false">Plan</Field.Label>
    <Radio.Group v-model="plan">
      <Radio.Root id="plan-hobby" value="hobby" />
      <Label id="plan-hobby-label" for="plan-hobby">Hobby</Label>
    </Radio.Group>
  </Field.Root>
</template>
```

### Orientation and direction

`orientation` decides which arrow keys move between items. It is `'both'` by
default, so `ArrowUp` / `ArrowDown` and `ArrowLeft` / `ArrowRight` all move;
narrowing it to `'horizontal'` or `'vertical'` leaves the other pair to
whatever else is on the page. `dir` swaps `ArrowLeft` and `ArrowRight`; unset,
it follows a `DirectionProvider` or the surrounding `dir` attribute, as
described in [Composition](/components/composition).

```vue
<template>
  <Radio.Group
    v-model="alignment"
    :options="{ orientation: 'horizontal', dir: 'rtl' }"
  >
    <Radio.Root value="start" />
    <Radio.Root value="center" />
    <Radio.Root value="end" />
  </Radio.Group>
</template>
```

### Disabling

An item is disabled when either the group or the item says so, and a disabled
item is skipped by the arrow keys as well as being unselectable.

```vue
<template>
  <Radio.Group v-model="plan">
    <Radio.Root value="hobby" />
    <Radio.Root value="team" />
    <Radio.Root value="enterprise" disabled />
  </Radio.Group>
</template>
```

`readOnly` blocks selection while keeping every item focusable, so the value can
still be read and copied. Both `readOnly` and `required` also sit on
`Radio.Root`, where an item’s own value wins over the group’s.

```vue
<template>
  <Radio.Group v-model="plan" :options="{ readOnly: true }">
    <Radio.Root value="hobby" />
    <Radio.Root value="team" :read-only="false" />
  </Radio.Group>
</template>
```

### Inside a form

`Radio.Group` renders one visually hidden radio input per registered item, all
sharing the group’s `name`, so [the form sees it](/components/form-integration#form-participation).

```vue
<template>
  <form @submit.prevent="submit">
    <Radio.Group v-model="plan" :options="{ name: 'plan', required: true }">
      <Radio.Root value="hobby" />
      <Radio.Root value="team" />
    </Radio.Group>
  </form>
</template>
```

Without a `name`, from either the group or the field, nothing is submitted at
all. A group rendered outside the `<form>` it belongs to points at it by `id`
through `form`, which reaches every hidden input.

The inputs themselves are exposed as `inputs` on the group instance, in
registration order, for the rare case where you need to read `validity` or call
`setCustomValidity()` on one of them.

### Styling from state

Paint the item from `data-state`. If you want the dot to scale in rather than
appear, `transition` on `Radio.Indicator` names the transition it enters and
leaves under, and defaults to `mirror-radio-indicator`. Where the dot should
never unmount, `force-mount` keeps it in the DOM and you animate it from
`data-state` instead. Both are set on the indicator itself, so two items in one
group can differ.

```vue
<template>
  <Radio.Group v-model="plan">
    <Radio.Root value="team" class="radio">
      <Radio.Indicator class="radio-dot" force-mount />
    </Radio.Root>
  </Radio.Group>
</template>

<style>
.radio[data-state='checked'] {
  border-color: currentcolor;
}

.radio[data-disabled='true'] {
  opacity: 0.5;
}
</style>
```

An indicator left to mount and unmount broadcasts its transition hooks through
the [emitter](#emitter), so something that renders no part of the group can
still hear which item has finished appearing.

## API reference

**Module.** A bundled `options` object, and `useMirrorRadio(id)`
as the programmatic API.

### `Radio.Group`

Renders a `<div role="radiogroup">` plus one visually hidden
`<input type="radio">` per registered item.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: The instance ID.
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: modelValue
        description: The selected value. `v-model`.
      - label: 'string | number | undefined'
      - label: undefined
  - items:
      - label: defaultValue
        description: 'Initial selection when [uncontrolled](/components/composition#controlled-and-uncontrolled).'
      - label: 'string | number | null'
      - label: 'null'
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: RadioOptions
      - label: see below
        plaintext: true
---
::

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: name
        description: Form field name, shared by every item.
      - label: string
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: form
        description: The `id` of the form, when rendered outside it.
      - label: string
      - label: undefined
  - items:
      - label: orientation
        description: 'The [arrow-key axis](#orientation-and-direction).'
      - label: '''horizontal'' | ''vertical'' | ''both'''
      - label: '''both'''
  - items:
      - label: dir
        description: 'Direction for the [horizontal arrow keys](#orientation-and-direction).'
      - label: '''ltr'' | ''rtl'''
      - label: inherited
        plaintext: true
  - items:
      - label: loop
        description: Arrow keys wrap at the ends.
      - label: boolean
      - label: 'true'
  - items:
      - label: disabled
        description: Disables every item.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: readOnly
        description: Blocks changes, keeps focus.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: required
        description: Marks the group required.
      - label: boolean
      - label: inherited from `Field`
        plaintext: true
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:modelValue
        description: The selection changes.
      - label: 'string | number'
---
::

#### Slot props

The [field state set](/components/styling#the-field-state-set), plus the one below.

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: value
        description: The selected value.
      - label: 'string | number | null'
---
::

`Radio.Group` also exposes `inputs`, the hidden `<input type="radio">` elements
in registration order, on its instance.

### `Radio.Root`

Renders a `<button role="radio">`, registering itself with the group on mount
and unregistering on unmount.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: 'The item’s DOM ID, for a [per-item `Label`](#labeling).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: value
        description: Required. The value this item selects.
      - label: 'string | number'
      - label: none
        plaintext: true
  - items:
      - label: disabled
        description: 'Disables [this item](#disabling) only.'
      - label: boolean
      - label: 'false'
  - items:
      - label: readOnly
        description: Blocks selection of this item, keeping it focusable.
      - label: boolean
      - label: inherited from the group
        plaintext: true
  - items:
      - label: required
        description: Marks this item’s hidden input required.
      - label: boolean
      - label: inherited from the group
        plaintext: true
---
::

#### Slot props

The field state set, with `disabled`, `readOnly`, `required`, `filled` and
`focused` narrowed to this item, plus the one below.

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: checked
        description: Mirrors `data-checked`.
      - label: boolean
---
::

### `Radio.Indicator`

Renders a `<span>` while the item is selected. Both props below are set on the
indicator itself. To animate every indicator in a group the same way, spread one
object over each of them.

```vue
<script setup lang="ts">
const dot = { transition: 'plan-dot' }
</script>

<template>
  <Radio.Group v-model="plan">
    <Radio.Root value="team">
      <Radio.Indicator v-bind="dot" />
    </Radio.Root>
    <Radio.Root value="solo">
      <Radio.Indicator v-bind="dot" />
    </Radio.Root>
  </Radio.Group>
</template>
```

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: forceMount
        description: 'Keeps this indicator [mounted](#styling-from-state) while the item is unselected.'
      - label: boolean
      - label: 'false'
  - items:
      - label: transition
        description: 'Vue [transition name](/components/animation#the-transition-names) this indicator enters with.'
      - label: string
      - label: '''mirror-radio-indicator'''
---
::

#### Slot props

The same as `Radio.Root`.

### Composable

`useMirrorRadio(id)` reaches a group from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: value
        description: The selected value.
      - label: 'ComputedRef<RadioValue | null>'
        escape: true
  - items:
      - label: values
        description: Every registered item value, in document order.
      - label: ComputedRef<Array<RadioValue>>
        escape: true
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: readOnly
        description: Mirrors `data-readonly`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: select
        description: Selects a value.
      - label: '(next: RadioValue) => void'
        escape: true
---
::

### Emitter

Every transition hook is broadcast app-wide through `useMirrorEmitter`, so a
listener needs no shared ancestor with the group. Import it from
`@maas/mirror/vue` and call it inside `<script setup>`.

The six transition hooks are broadcast as `radio:beforeEnter`, `radio:enter`,
`radio:afterEnter`, `radio:beforeLeave`, `radio:leave` and `radio:afterLeave`.
Each payload carries the group’s instance `id`, the `part` that is
transitioning, which is always `indicator`, and the `value` of the item the
indicator belongs to.

```vue
<script setup lang="ts">
import { useMirrorEmitter } from '@maas/mirror/vue'

const emitter = useMirrorEmitter()

emitter.on('radio:afterEnter', ({ id, value }) => {
  if (id === RadioId.Plan) {
    previewPlan(value)
  }
})
</script>
```

The hooks belong to the Vue transition, so they fire only while the
indicator’s `transition` prop names one. It defaults to
`mirror-radio-indicator`, so every indicator broadcasts them without any
configuration, and a name of your own keeps them coming. An item that is
already selected when the group first renders fires no enter hooks, and an
indicator kept in the DOM with `force-mount` never enters or leaves, so it
fires none of them either.

Selecting another item leaves the current indicator and enters the new one, so
one change broadcasts both halves, each with its own `value`.

::docs-table
---
columns:
  - label: Event
  - label: Payload
rows:
  - items:
      - label: radio:beforeEnter
        description: An indicator is about to enter.
      - label: "{ id: string, part: 'indicator', value: string | number }"
        escape: true
  - items:
      - label: radio:enter
        description: An indicator has started entering.
      - label: "{ id: string, part: 'indicator', value: string | number }"
        escape: true
  - items:
      - label: radio:afterEnter
        description: An indicator has finished entering.
      - label: "{ id: string, part: 'indicator', value: string | number }"
        escape: true
  - items:
      - label: radio:beforeLeave
        description: An indicator is about to leave.
      - label: "{ id: string, part: 'indicator', value: string | number }"
        escape: true
  - items:
      - label: radio:leave
        description: An indicator has started leaving.
      - label: "{ id: string, part: 'indicator', value: string | number }"
        escape: true
  - items:
      - label: radio:afterLeave
        description: An indicator has finished leaving and is out of the DOM.
      - label: "{ id: string, part: 'indicator', value: string | number }"
        escape: true
---
::

`RadioLifecyclePayload` types the lifecycle payload, and the whole map is
`RadioEvents`. [Emitter](/components/emitter) covers the channel itself.

### Data attributes

Every part writes the [field state set](/components/styling#the-field-state-set).

::data-attributes
::

### CSS variables

::css-variables
::

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_radio_group_context
        description: '`Radio.Root` rendered outside `Radio.Group`.'
  - items:
      - label: missing_context
        description: '`Radio.Root` rendered outside the group’s roving-focus context.'
  - items:
      - label: missing_radio_context
        description: '`Radio.Indicator` rendered outside `Radio.Root`.'
  - items:
      - label: duplicate_radio_value
        description: Two `Radio.Root` items in one group declared the same `value`.
---
::

## Accessibility

The group is `role="radiogroup"` and each item `role="radio"` with
`aria-checked`, and the whole group is a single tab stop that focus and
selection travel through together.

::docs-table
---
columns:
  - label: Key
  - label: Behavior
rows:
  - items:
      - label: Tab
      - label: Enters the group at the selected item, else the first enabled item. A second press leaves the group.
        plaintext: true
  - items:
      - label: '`ArrowDown` / `ArrowUp`'
        plaintext: true
      - label: Moves to and selects the next or previous enabled item. Inert while `orientation` is `'horizontal'`.
        plaintext: true
  - items:
      - label: '`ArrowRight` / `ArrowLeft`'
        plaintext: true
      - label: 'Moves to and selects the next or previous enabled item. Swapped under `dir="rtl"`, and inert while `orientation` is `''vertical''`.'
        plaintext: true
  - items:
      - label: '`Home` / `End`'
        plaintext: true
      - label: Moves to and selects the first or last enabled item, in either orientation.
        plaintext: true
  - items:
      - label: Space
      - label: Selects the focused item.
        plaintext: true
  - items:
      - label: Enter
      - label: Nothing. The default is blocked, so the group never submits its form.
        plaintext: true
---
::

Disabled items are skipped throughout, and the ends wrap while `loop` is on and
stop while it is off.
