GrCheckbox
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
indeterminateshows 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 withnameandvalue.
When to take something else
| Need | Take |
|---|---|
| The setting applies immediately | GrSwitch |
| There are several related options | GrCheckboxGroup |
| Only one option can be selected | GrRadio |
| Display modes are switched | GrSegmented |
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…
<GrCheckbox />Install
npm i @feugene/granularityImport
import { GrCheckbox } from '@feugene/granularity/components/GrCheckbox'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
modelValue | boolean | undefined | undefined | Unset inside a `GrCheckboxGroup` — the state comes from the group. |
disabled | boolean | undefined | undefined | — |
readonly | boolean | undefined | false | Read-only: the state is visible but is not toggled. |
invalid | boolean | undefined | false | The visual and ARIA state of an error. |
required | boolean | undefined | false | A required field: it is announced as `aria-required`, there is no native check. |
size | "xs" | "sm" | "md" | "lg" | undefined | undefined | The size of the control. Unset — it comes from the group, then from `GrConfigProvider`, otherwise `md`. |
ariaLabel | string | undefined | undefined | The name of the control when there is no label in the slot (or it is purely visual). |
name | string | undefined | undefined | — |
value | string | undefined | "on" | — |
form | string | undefined | undefined | — |
id | string | undefined | undefined | Passed onto the hidden native `<input>`, so that `<label for="...">` works. |
indeterminate | boolean | undefined | false | The 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
| Slot | Type | Description |
|---|---|---|
default | any | The label of the checkbox. It may contain links: the role stays on the control itself. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: boolean] | — |
change | [value: boolean] | — |
focus | [event: FocusEvent] | — |
blur | [event: FocusEvent] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
focus | () => void | — |
blur | () => void | — |
Examples 4
Sizes aligned with the rest of the form row
<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
<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
<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
Submit the form to inspect native checkbox values.
<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