# 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, Radio } from '@maas/mirror/vue'
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

- The ARIA radio group pattern keeps the whole group as one tab stop, so focus
  and selection travel together and the arrow keys commit a choice as they go.
  If the user should be able to browse before choosing, reach for something
  else.
- Both arrow pairs move through the group by default, which is what the ARIA
  pattern asks for. Set `orientation` to `'horizontal'` or `'vertical'` only
  where one axis belongs to something else, a slider or a scrolling panel
  beside the group.
- Give every per-item `Label` its own `id`, or inside a `Field` they share the
  field’s label ID and all register as its label.

## 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, Label, Radio } from '@maas/mirror/vue'
</script>

<template>
  <Field.Root :options="{ name: 'plan' }">
    <Label :native-label="false">Plan</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

### Labelling

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

```vue
<template>
  <Field.Root :options="{ name: 'plan' }">
    <Label :native-label="false">Plan</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>
```

## 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`](#labelling).'
      - 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
---
::

### 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: Behaviour
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.
