GrCheckbox

Package: @feugene/granularitycoreGroup: forms

Lets you toggle independent options and select multiple items.

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

When to take it

  • consent or a flag in a form — “remember me”, “I agree to the terms”: the value leaves with the submission;
  • selecting rows of a list — a checkbox in the header with indeterminate shows a partial selection;
  • the label is interactive — it lives outside the role, so a link inside it stays a link;
  • a native form is needed — the hidden <input type="checkbox"> leaves with name and value.

When to take something else

NeedTake
The setting applies immediatelyGrSwitch
There are several related optionsGrCheckboxGroup
Only one option can be selectedGrRadio
Display modes are switchedGrSegmented

The border with GrSwitch runs along the moment of application: a checkbox is a value of a form and leaves with the save; a switch changes the state of the system at once, and there must be no “Save” button next to it.

`required` is declared but not checked by the browser

required reaches aria-required on the span[role="checkbox"] and is not set on the hidden input. A native check would demand that the browser focus an invalid control, and that control is invisible and aria-hidden: in such a case Chrome cancels the submission of the whole form and writes “An invalid form control … is not focusable” into the console — that is, required would not prevent submission, it would break submission silently.

A form rule checks whether the field is required:

<script setup lang="ts">
// `required` counts `null`/`''`/`[]` as empty, but not `false`: an unchecked
// checkbox is a legitimate value of the field. "Consent is required" is a
// `validator`.
const rules: GrFormRules = {
  terms: [{ validator: value => value === true || 'Accept the terms' }],
}
</script>

<template>
  <GrForm :model="model" :rules="rules">
    <GrFormField name="terms" label="Terms">
      <GrCheckbox v-model="model.terms" required />
    </GrFormField>
  </GrForm>
</template>

The label lives outside the widget role

role="checkbox" declares its descendants presentational, so there is neither a native input nor slot content inside it: the name of the widget arrives through aria-labelledby. That is exactly why the label is moved outside — it may contain a link to a policy or a “show changes” button.

The consequence: a click on the label toggles the checkbox, and a click on its interactive content (a, button, input, select, textarea, a nested label, [role="button"], [role="link"]) does not — such an element addresses itself.

labelPosition="start" puts the label before the control.

States

disabled and invalid are shown with background and border tokens rather than with transparency: opacity dilutes text tokens tuned to AA. readonly leaves the value in the form and returns the native input to the model if it was toggled by a click on an external <label for>.

Inside GrFormField the control gets an id on the widget, aria-describedby, aria-invalid and aria-required from the context of the field.

Playground 13

Loading…

Code
<GrCheckbox />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
modelValueboolean | undefinedundefinedUnset inside a `GrCheckboxGroup` — the state comes from the group.
disabledboolean | undefinedundefined
readonlyboolean | undefinedfalseRead-only: the state is visible but is not toggled.
invalidboolean | undefinedfalseThe visual and ARIA state of an error.
requiredboolean | undefinedfalseA required field: it is announced as `aria-required`, there is no native check.
size"xs" | "sm" | "md" | "lg" | undefinedundefinedThe size of the control. Unset — it comes from the group, then from `GrConfigProvider`, otherwise `md`.
ariaLabelstring | undefinedundefinedThe name of the control when there is no label in the slot (or it is purely visual).
namestring | undefinedundefined
valuestring | undefined"on"
formstring | undefinedundefined
idstring | undefinedundefinedPassed onto the hidden native `<input>`, so that `<label for="...">` works.
indeterminateboolean | undefinedfalseThe intermediate ("mixed") state: `aria-checked="mixed"`, and a dash as the indicator.
labelPosition"end" | "start" | undefined"end"The side the label is on relative to the control.

Slots

SlotTypeDescription
defaultanyThe label of the checkbox. It may contain links: the role stays on the control itself.

Events

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

Methods / Expose

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

Examples 4

Sizes aligned with the rest of the form row

xs
sm
md
lg
Compact rows
Partial digest selection

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

import { GrCheckbox, GrFormField, GrInput } from '@feugene/granularity'

const sizes = ['xs', 'sm', 'md', 'lg'] as const

const size = ref<typeof sizes[number]>('md')
const compact = ref(true)
const digest = ref(false)
const project = ref('Granularity')
</script>

<template>
  <div class="grid gap-4">
    <div class="flex flex-wrap items-center gap-4">
      <GrCheckbox
        v-for="value in sizes"
        :key="value"
        :model-value="size === value"
        :size="value"
        @update:model-value="size = value"
      >
        {{ value }}
      </GrCheckbox>
    </div>

    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <!-- Подпись через GrFormField: он выдаёт контролу id и связывает с ним
           `<label for>`. Нарисованный рядом текст доступным именем не становится. -->
      <GrFormField label="Project name">
        <GrInput v-model="project" :size="size" />
      </GrFormField>

      <GrCheckbox v-model="compact" :size="size">
        Compact rows
      </GrCheckbox>
      <GrCheckbox v-model="digest" :size="size" indeterminate>
        Partial digest selection
      </GrCheckbox>
    </div>
  </div>
</template>

Checked, unchecked and locked states

Weekly product digest
Incident alerts
Security bulletins are always enabled
Accept the notification policy
Label before the control
Selection summary
1 of 2 optional channels are active.

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

import { GrCheckbox, GrSwitch } from '@feugene/granularity'

const weeklyDigest = ref(true)
const incidentAlerts = ref(false)
const controlsDisabled = ref(false)
const terms = ref(false)
const compactRow = ref(true)

const enabledCount = computed(() => [weeklyDigest.value, incidentAlerts.value].filter(Boolean).length)
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_240px]">
    <div class="grid gap-3">
      <GrCheckbox v-model="weeklyDigest" :disabled="controlsDisabled">
        Weekly product digest
      </GrCheckbox>
      <GrCheckbox v-model="incidentAlerts" :disabled="controlsDisabled">
        Incident alerts
      </GrCheckbox>
      <GrCheckbox :model-value="true" disabled>
        Security bulletins are always enabled
      </GrCheckbox>
      <GrCheckbox v-model="terms" :invalid="!terms" required>
        Accept the notification policy
      </GrCheckbox>
      <GrCheckbox v-model="compactRow" label-position="start">
        Label before the control
      </GrCheckbox>
    </div>

    <div class="grid gap-3 rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-card)] p-4">
      <div>
        <div class="text-sm font-semibold text-[var(--gr-fg)]">
          Selection summary
        </div>
        <div class="text-sm text-[var(--gr-muted-fg)]">
          {{ enabledCount }} of 2 optional channels are active.
        </div>
      </div>

      <GrSwitch v-model="controlsDisabled" size="sm">
        Lock editable options
      </GrSwitch>
    </div>
  </div>
</template>

Interactive content inside the label slot

I accept the rollout policy and reviewed the privacy policy
Checkbox value: pending · Preview clicked 0 times.

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

import { GrCheckbox } from '@feugene/granularity'

const accepted = ref(false)
const previewOpens = ref(0)
</script>

<template>
  <div class="grid gap-4">
    <GrCheckbox v-model="accepted">
      <span class="inline-flex flex-wrap items-center gap-2 text-sm">
        I accept the rollout policy and reviewed the
        <a
          class="font-medium text-[var(--gr-primary-text)] underline underline-offset-2"
          href="https://example.com/policy"
          target="_blank"
          rel="noreferrer"
          @click.stop
        >
          privacy policy
        </a>
        <button
          type="button"
          class="rounded-full border border-[var(--gr-brd)] px-2 py-1 text-xs font-medium text-[var(--gr-fg)] transition hover:border-[var(--gr-primary)] hover:text-[var(--gr-primary-text)]"
          @click.stop="previewOpens += 1"
        >
          Preview changes
        </button>
      </span>
    </GrCheckbox>

    <div class="rounded-2xl border border-dashed border-[var(--gr-brd)] bg-[var(--gr-muted)]/35 p-4 text-sm text-[var(--gr-muted-fg)]">
      Checkbox value: <span class="font-semibold text-[var(--gr-fg)]">{{ accepted ? 'accepted' : 'pending' }}</span> ·
      Preview clicked {{ previewOpens }} times.
    </div>
  </div>
</template>

Native form submission semantics

Marketing updates
Beta feature updates
Submit the form to inspect native checkbox values.

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

import { GrButton, GrCheckbox } from '@feugene/granularity'

const marketing = ref(true)
const productUpdates = ref(false)
const submission = ref('Submit the form to inspect native checkbox values.')

function onSubmit(event: SubmitEvent): void {
  event.preventDefault()

  const formData = new FormData(event.currentTarget as HTMLFormElement)

  submission.value = JSON.stringify(Object.fromEntries(formData.entries()), null, 2)
}
</script>

<template>
  <div class="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
    <form class="grid gap-3" @submit="onSubmit">
      <GrCheckbox v-model="marketing" name="marketing" value="enabled">
        Marketing updates
      </GrCheckbox>
      <GrCheckbox v-model="productUpdates" name="productUpdates" value="beta">
        Beta feature updates
      </GrCheckbox>

      <div class="flex items-center gap-3 pt-2">
        <GrButton type="submit" size="sm">Read form data</GrButton>
      </div>
    </form>

    <!-- tabindex: скроллящийся блок обязан быть достижим с клавиатуры,
         иначе его содержимое недоступно без мыши (axe: scrollable-region-focusable). -->
    <pre
      tabindex="0"
      aria-label="Submitted form data"
      class="overflow-x-auto rounded-2xl border border-[var(--gr-brd)] bg-[var(--gr-fg)] p-4 text-xs text-[var(--gr-bg)]"
    >{{ submission }}</pre>
  </div>
</template>

Accessibility

APG pattern
checkbox

Full keyboard contract of the package

Component documentationAll components