GrCheckboxGroup

Package: @feugene/granularitycoreGroup: forms

Collects checkboxes into one multi-select field with a shared model and states.

Machine-translated from the Russian original, not yet reviewed. Read the original

When to take it

  • several values out of a short set — permissions, tags, days of the week, notification channels;
  • every option has to be visible — the selection is seen as a whole, without opening a panel;
  • the group is part of a form — a shared string[] model, shared disabled, readonly and invalid;
  • there are up to a dozen options — beyond that the list grows longer than the screen.

When to take something else

NeedTake
There are many optionsGrSelect with multiple
There are many options and they are searched by typingGrAutocomplete
Only one can be selectedGrRadioGroup
There is a single checkboxGrCheckbox
The options are nestedGrTreeSelect

The model and the context

The group hands the nested checkboxes the selected values, name, size and the disabled/readonly/invalid states, so they need no v-model of their own — value is enough:

<GrCheckboxGroup v-model="channels" name="channels" :options="options" />

<GrCheckboxGroup v-model="channels" direction="horizontal">
  <GrCheckbox value="sms">SMS</GrCheckbox>
  <GrCheckbox value="email">Email</GrCheckbox>
</GrCheckboxGroup>

name goes to every checked checkbox, so a native form gets a repeated field: new FormData(form).getAll('channels').

The disabled of an individual option beats an enabled group. A value in the model that is not among the options is not lost by the group: unchecking a neighbouring checkbox keeps it.

The role and ARIA

role="group", not radiogroup: checkboxes have no roving tabindex and no moving of the selection with the arrows — each stays a Tab stop of its own, exactly like a set of native <input type="checkbox">.

role="group" does not support aria-required and aria-readonly (axe reports that as a critical aria-allowed-attr), so both states are declared by the checkboxes themselves. aria-invalid, on the contrary, hangs on the group alone: duplicated on every item, it would make a screen reader repeat “invalid value” as many times as there are checkboxes in the group — on an error the items are merely recoloured.

Being required

As with a single checkbox, required is a declaration; a form rule does the checking. For a group the built-in required works: an empty array counts as an empty value.

const rules: GrFormRules = {
  channels: [{ required: true, message: 'Choose at least one channel' }],
}

Playground 7

Loading…

Code
<GrCheckboxGroup />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
optionsGrCheckboxGroupOption[] | undefinedundefined
disabledboolean | undefinedfalse
readonlyboolean | undefinedfalseRead-only: the selection is visible but does not change.
invalidboolean | undefinedfalseThe visual and ARIA state of an error for the whole group.
requiredboolean | undefinedfalseA required group: `aria-required` is announced by the checkboxes themselves.
size"xs" | "sm" | "md" | "lg" | undefinedundefined
ariaLabelstring | undefinedundefined
namestring | undefinedundefinedA shared name for a native form: the values leave as repeated fields.
directionGrCheckboxGroupDirection | undefined"vertical"
modelValuerequiredstring[]

Slots

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

Events

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

Methods / Expose

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

Examples 2

Multi-select from an options list

Email
SMS
Push
Webhook
Selected: email, push

Basic
<script setup lang="ts">
import { ref } from 'vue'

import { GrCheckboxGroup, GrSegmented } from '@feugene/granularity'

const options = [
  { value: 'email', label: 'Email' },
  { value: 'sms', label: 'SMS' },
  { value: 'push', label: 'Push' },
  { value: 'webhook', label: 'Webhook', disabled: true },
]

const channels = ref(['email', 'push'])
const direction = ref<'vertical' | 'horizontal'>('vertical')
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]">
    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <GrCheckboxGroup
        v-model="channels"
        name="channels"
        :options="options"
        :direction="direction"
        aria-label="Notification channels"
      />
    </div>

    <div class="grid gap-3 rounded-2xl border border-dashed border-[var(--gr-brd)] p-4">
      <GrSegmented
        v-model="direction"
        size="sm"
        :options="[
          { value: 'vertical', label: 'Vertical' },
          { value: 'horizontal', label: 'Horizontal' },
        ]"
      />

      <div class="text-sm text-[var(--gr-muted-fg)]">
        Selected: <span class="font-semibold text-[var(--gr-fg)]">{{ channels.join(', ') || 'none' }}</span>
      </div>
    </div>
  </div>
</template>

Validation inside GrForm

Incidents
Releases
Weekly digest
I accept the notification policy

Form
<script setup lang="ts">
import { reactive, ref } from 'vue'

import { GrButton, GrCheckbox, GrCheckboxGroup, GrForm, GrFormField, type GrFormRules } from '@feugene/granularity'

const options = [
  { value: 'incidents', label: 'Incidents' },
  { value: 'releases', label: 'Releases' },
  { value: 'digest', label: 'Weekly digest' },
]

const model = reactive({ scopes: [] as string[], terms: false })

// `required` пустым считает `null`/`''`/`[]`, но не `false`: снятый чекбокс —
// это законное значение поля. «Согласие обязательно» — это `validator`.
const rules: GrFormRules = {
  scopes: [{ required: true, message: 'Pick at least one subscription' }],
  terms: [{ validator: value => value === true || 'Accept the policy to continue' }],
}

const submitted = ref(false)
</script>

<template>
  <GrForm
    :model="model"
    :rules="rules"
    class="grid max-w-md gap-4"
    @submit="submitted = true"
  >
    <GrFormField name="scopes" label="Subscriptions">
      <GrCheckboxGroup v-model="model.scopes" name="scopes" :options="options" required />
    </GrFormField>

    <GrFormField name="terms" label="Policy">
      <GrCheckbox v-model="model.terms" required>
        I accept the notification policy
      </GrCheckbox>
    </GrFormField>

    <div class="flex gap-2">
      <GrButton type="submit" size="sm">
        Save preferences
      </GrButton>
    </div>

    <p v-if="submitted" class="text-sm text-[var(--gr-success-text)]">
      Saved — the form is valid.
    </p>
  </GrForm>
</template>

Accessibility

APG pattern
group

Full keyboard contract of the package

Component documentationAll components