GrRadioGroup
Combines radio options into a single-choice selection flow.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- there are up to seven options and all are visible — a delivery method, a payment type, a role: a choice without an extra click on a panel;
- the options have a description — a line of explanation under the label, which a select does not give;
- the group is part of a form — a shared
name,disabledandreadonlyare handed out through the context; - an option looks like a button —
variant="button"without losing the semantics of aradiogroup.
When to take something else
| Need | Take |
|---|---|
| There are more than seven options | GrSelect |
| There are 2–5 options and they switch a view rather than a value | GrSegmented |
| Several can be selected | GrCheckboxGroup |
| There is one switch and it is about “on/off” | GrSwitch |
| A single switch outside a group | GrRadio |
Two modes
<!-- With a prop: the short path for a flat list. -->
<GrRadioGroup v-model="status" :options="options" />
<!-- With a slot: when the options need markup of their own. -->
<GrRadioGroup v-model="status">
<GrRadio value="draft">Draft</GrRadio>
<GrRadio value="review">In review</GrRadio>
</GrRadioGroup>
Both modes get one and the same keyboard and one and the same context: the composition of the group is assembled by the registration of the children rather than by a walk over the DOM, so it works on the server as well.
The options
interface GrRadioGroupOption {
value: string | number | boolean
label: string
disabled?: boolean
description?: string
}
disabled switches off a single option without moving the whole group onto a slot — that used to
be the only way. description is drawn under the label and only in the radiobox variant: in a
button chip a description has nowhere to live.
The layout
orientation is vertical (the default) or horizontal. The button variant is always
horizontal: it is assembled by GrButtonGroup, and orientation affects nothing there.
The states
disabled dims the whole group, and readonly leaves the selection visible but unchangeable:
aria-readonly is announced by the group itself (the radio role has no such attribute), and the
switches stop promising a click to the cursor.
invalid and required reach both the group and the look of the switches. Inside GrFormField
the same arrives from the context of the field: the name through aria-labelledby, the hint and
the text of the error through aria-describedby, and the error through aria-invalid. A group is
not a labelable element, so <label for> is not applicable to it.
The keyboard
The whole layout is described in the card of GrRadio: the group is one Tab stop, and inside it
↑/↓/←/→ walk in a ring and Home/End go to the edges. Disabled options are skipped
everywhere — both in the roving tabindex and in the walk with the arrows.
Playground 7
Loading…
<GrRadioGroup />Install
npm i @feugene/granularityImport
import { GrRadioGroup } from '@feugene/granularity/components/GrRadioGroup'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
variant | GrRadioGroupVariant | undefined | "radiobox" | — |
options | GrRadioGroupOption[] | undefined | undefined | — |
disabled | boolean | undefined | false | — |
readonly | boolean | undefined | false | Read only: the choice is visible but does not change. |
invalid | boolean | undefined | false | The visual and ARIA state of an error. |
required | boolean | undefined | false | A mandatory field (`aria-required`). |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
name | string | undefined | undefined | — |
orientation | GrRadioGroupOrientation | undefined | "vertical" | The layout of the `radiobox` variant. The button variant is always horizontal — it is assembled by `GrButtonGroup`. |
modelValuerequired | GrRadioValue | — | — |
Slots
| Slot | Type | Description |
|---|---|---|
default | any | Markup of your own for the radios instead of generating them from `options`. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: GrRadioValue] | — |
change | [value: GrRadioValue] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 4
Generated group from options list
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { GrRadioGroupOrientation } from '@feugene/granularity'
import { GrRadioGroup, GrSegmented } from '@feugene/granularity'
const status = ref('review')
const orientation = ref<GrRadioGroupOrientation>('vertical')
const readonly = ref(false)
// Опция умеет быть отключённой и нести пояснение — без перехода на слот.
const options = [
{ value: 'draft', label: 'Draft', description: 'Виден только автору' },
{ value: 'review', label: 'In review', description: 'Ждёт решения редактора' },
{ value: 'published', label: 'Published', description: 'Опубликовано на сайте' },
{ value: 'archived', label: 'Archived', description: 'Доступно после снятия блокировки', disabled: true },
]
const selectedOption = computed(() => options.find(option => option.value === status.value)?.label ?? status.value)
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<div class="grid gap-4">
<div class="flex flex-wrap items-center gap-4">
<GrSegmented
v-model="orientation"
size="sm"
:options="[
{ value: 'vertical', label: 'vertical' },
{ value: 'horizontal', label: 'horizontal' },
]"
/>
<label class="flex items-center gap-2 text-sm text-[var(--gr-muted-fg)]">
<input v-model="readonly" type="checkbox">
readonly
</label>
</div>
<GrRadioGroup
v-model="status"
:options="options"
:orientation="orientation"
:readonly="readonly"
/>
</div>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Selected state:
<div class="mt-2 text-base font-semibold text-[var(--gr-fg)]">
{{ selectedOption }}
</div>
<div class="mt-3">
Отключённый вариант пропускается и стрелками, и `Tab`.
</div>
</div>
</div>
</template>Size Scale
xssmmdlgxssmmdlg<script setup lang="ts">
import { ref } from 'vue'
import { GrButton, GrRadioGroup } from '@feugene/granularity'
const sizes = ['xs', 'sm', 'md', 'lg'] as const
const viewOptions = [
{ value: 'board', label: 'Board' },
{ value: 'calendar', label: 'Calendar' },
{ value: 'table', label: 'Table' },
]
const planOptions = [
{ value: 'free', label: 'Free' },
{ value: 'team', label: 'Team' },
]
// По значению на ступень: одна модель на все четыре сделала бы выбор общим, и
// разница между ступенями читалась бы хуже.
const buttonView = ref<Record<string, string>>({ xs: 'board', sm: 'calendar', md: 'board', lg: 'table' })
const radioboxPlan = ref<Record<string, string>>({ xs: 'free', sm: 'team', md: 'free', lg: 'team' })
</script>
<template>
<div class="grid gap-6">
<div class="grid gap-3">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
variant="button" — ступень в ступень с GrButton
</div>
<div
v-for="size in sizes"
:key="`button-${size}`"
class="flex flex-wrap items-center gap-3"
>
<code class="w-8 text-xs text-[var(--gr-muted-fg)]">{{ size }}</code>
<GrRadioGroup
v-model="buttonView[size]"
:options="viewOptions"
variant="button"
:size="size"
/>
<!-- Кнопка рядом той же ступени: у кнопочного варианта карта размеров общая
с `GrButton`, и высоты обязаны совпадать. -->
<GrButton :size="size" variant="outline">
GrButton {{ size }}
</GrButton>
</div>
</div>
<div class="grid gap-3">
<div class="text-xs font-semibold text-[var(--gr-muted-fg)]">
variant="radiobox" — коробка, точка и подпись тоже по ступеням
</div>
<div
v-for="size in sizes"
:key="`radiobox-${size}`"
class="flex flex-wrap items-center gap-3"
>
<code class="w-8 text-xs text-[var(--gr-muted-fg)]">{{ size }}</code>
<GrRadioGroup
v-model="radioboxPlan[size]"
:options="planOptions"
:size="size"
orientation="horizontal"
/>
</div>
</div>
</div>
</template>Custom slot content for per-option annotations
<script setup lang="ts">
import { ref } from 'vue'
import { GrBadge, GrRadio, GrRadioGroup } from '@feugene/granularity'
const channel = ref('slack')
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<GrRadioGroup v-model="channel" name="incident-channel">
<GrRadio value="slack">
<span class="inline-flex items-center gap-2">
Slack
<GrBadge tone="success" size="sm">Primary</GrBadge>
</span>
</GrRadio>
<GrRadio value="email">
<span class="inline-flex items-center gap-2">
Email
<GrBadge tone="warning" size="sm">Fallback</GrBadge>
</span>
</GrRadio>
<GrRadio value="pagerduty">
<span class="inline-flex items-center gap-2">
PagerDuty
<GrBadge tone="danger" size="sm">Escalation</GrBadge>
</span>
</GrRadio>
</GrRadioGroup>
<div class="rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4 text-sm text-[var(--gr-muted-fg)]">
Routed through: <span class="font-semibold text-[var(--gr-fg)]">{{ channel }}</span>
</div>
</div>
</template>Inheritance
<script setup lang="ts">
import { ref } from 'vue'
import { GrRadio, GrRadioGroup, GrSwitch } from '@feugene/granularity'
const environment = ref('staging')
const disabled = ref(false)
</script>
<template>
<div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_220px]">
<GrRadioGroup v-model="environment" name="target-environment" :disabled="disabled">
<GrRadio value="local">Local preview</GrRadio>
<GrRadio value="staging">Staging</GrRadio>
<GrRadio value="production">Production</GrRadio>
</GrRadioGroup>
<div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
<GrSwitch v-model="disabled" size="sm">
Disable full group
</GrSwitch>
<div class="text-sm text-[var(--gr-muted-fg)]">
Active target: <span class="font-semibold text-[var(--gr-fg)]">{{ environment }}</span>
</div>
</div>
</div>
</template>Accessibility
- APG pattern
radio