GrRadioGroup

Package: @feugene/granularitycoreGroup: forms

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, disabled and readonly are handed out through the context;
  • an option looks like a buttonvariant="button" without losing the semantics of a radiogroup.

When to take something else

NeedTake
There are more than seven optionsGrSelect
There are 2–5 options and they switch a view rather than a valueGrSegmented
Several can be selectedGrCheckboxGroup
There is one switch and it is about “on/off”GrSwitch
A single switch outside a groupGrRadio

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…

Code
<GrRadioGroup />

Install

npm i @feugene/granularity

Import

import { GrRadioGroup } from '@feugene/granularity/components/GrRadioGroup'

API

Props

PropTypedefaultDescription
variantGrRadioGroupVariant | undefined"radiobox"
optionsGrRadioGroupOption[] | undefinedundefined
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead only: the choice is visible but does not change.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA mandatory field (`aria-required`).
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefined
orientationGrRadioGroupOrientation | undefined"vertical"The layout of the `radiobox` variant. The button variant is always horizontal — it is assembled by `GrButtonGroup`.
modelValuerequiredGrRadioValue

Slots

SlotTypeDescription
defaultanyMarkup of your own for the radios instead of generating them from `options`.

Events

EventTypeDescription
update:modelValue[value: GrRadioValue]
change[value: GrRadioValue]
focus[event: FocusEvent]
blur[event: FocusEvent]

Methods / Expose

Methods / ExposeTypeDescription
focus() => void
blur() => void

Examples 4

Generated group from options list

Selected state:
In review
Отключённый вариант пропускается и стрелками, и `Tab`.

Options
<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

variant="button" — ступень в ступень с GrButton
xs
sm
md
lg
variant="radiobox" — коробка, точка и подпись тоже по ступеням
xs
sm
md
lg

Size Scale
<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

Routed through: slack

Custom Slots
<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

Active target: staging

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

Full keyboard contract of the package

Component documentationAll components