CheckboxGroup
A checkbox group is a labelled set of checkboxes whose value is the list of
the selected ones, such as the channels a user wants notifications on. Its
root becomes a role="group", named by the group’s label and described by its
description and error message (as Field does). Each checkbox
stays a Checkbox, a native <input type="checkbox">, that
finds its group and takes from it its selected state, its name, and the
group’s disabled, read-only, required and invalid states.
It follows the WAI-ARIA Checkbox pattern for a group of checkboxes.
View source
<!doctype html><meta charset="utf-8"><style>/* The page's layout. The checkboxes' look is @uniaria/styles' default. */
:root {
font:
15px/1.5 system-ui,
sans-serif;
}
body {
display: grid;
gap: 1rem;
margin: 1.5rem;
background: var(--uni-surface-color);
color: var(--uni-text-color);
}
</style><uni-checkbox-group id="notify" name="notify" required>
<div data-uni="checkbox-group">
<span data-element="Label">Notifications</span>
<span data-element="Description">Where we tell you about new orders.</span>
<uni-checkbox value="email">
<label data-uni="checkbox">
<input data-element="Input" type="checkbox" checked />
<span data-element="Indicator"></span>
<span data-element="Label">Email</span>
</label>
</uni-checkbox>
<uni-checkbox value="sms">
<label data-uni="checkbox">
<input data-element="Input" type="checkbox" />
<span data-element="Indicator"></span>
<span data-element="Label">SMS</span>
</label>
</uni-checkbox>
<uni-checkbox value="push">
<label data-uni="checkbox">
<input data-element="Input" type="checkbox" />
<span data-element="Indicator"></span>
<span data-element="Label">Push</span>
</label>
</uni-checkbox>
<span data-element="Error" hidden>Pick at least one.</span>
</div>
</uni-checkbox-group>
<output id="log">Clear every channel to see the error.</output>
<script src="/demo/checkbox-group.js"></script><script>// The group is invalid while nothing is selected: its error shows, is
// announced, and is linked to every checkbox.
const group = document.getElementById('notify');
const log = document.getElementById('log');
group.addEventListener('valuechange', (event) => {
group.isInvalid = event.detail.length === 0;
log.textContent = `valuechange: ${JSON.stringify(event.detail)}`;
});
</script>React
import type { ReactNode } from 'react';
import { checkbox } from '@uniaria/core/checkbox';
import { checkboxGroup } from '@uniaria/core/checkbox-group';
import { OwnerProvider, useBehavior } from '@uniaria/react';
export function Notifications(props: {
value: readonly string[];
onValueChange(value: readonly string[]): void;
}) {
const group = useBehavior(checkboxGroup, {
name: 'notify',
isRequired: true,
isInvalid: props.value.length === 0,
value: props.value,
onValueChange: props.onValueChange,
});
return (
<div {...group.parts.root}>
<span {...group.parts.label}>Notifications</span>
<OwnerProvider owner={group}>
<Channel value="email">Email</Channel>
<Channel value="sms">SMS</Channel>
</OwnerProvider>
<span {...group.parts.error}>Pick at least one.</span>
</div>
);
}
function Channel(props: { value: string; children: ReactNode }) {
const channel = useBehavior(checkbox, {
value: props.value,
labelElementType: 'span',
});
return (
<label {...channel.parts.root}>
<input {...channel.parts.input} />
<span {...channel.parts.indicator} />
<span {...channel.parts.label}>{props.children}</span>
</label>
);
}Vue
<!-- Notifications.vue -->
<script setup lang="ts">
import { checkboxGroup } from '@uniaria/core/checkbox-group';
import { useBehavior } from '@uniaria/vue';
import Channel from './Channel.vue';
const value = defineModel<readonly string[]>({ required: true });
const group = useBehavior(checkboxGroup, () => ({
name: 'notify',
isRequired: true,
isInvalid: value.value.length === 0,
value: value.value,
onValueChange: (next) => (value.value = next),
}));
</script>
<template>
<div v-bind="group.parts.root">
<span v-bind="group.parts.label">Notifications</span>
<Channel value="email">Email</Channel>
<Channel value="sms">SMS</Channel>
<span v-bind="group.parts.error">Pick at least one.</span>
</div>
</template><!-- Channel.vue -->
<script setup lang="ts">
import { checkbox } from '@uniaria/core/checkbox';
import { useBehavior } from '@uniaria/vue';
const props = defineProps<{ value: string }>();
const channel = useBehavior(checkbox, () => ({
value: props.value,
labelElementType: 'span',
}));
</script>
<template>
<label v-bind="channel.parts.root">
<input v-bind="channel.parts.input" />
<span v-bind="channel.parts.indicator" />
<span v-bind="channel.parts.label"><slot /></span>
</label>
</template>The channels render inside the group’s component, so they find the group while they set up: there is no provider to add.
HTML
<uni-checkbox-group id="notify" name="notify" required>
<div data-uni="checkbox-group">
<span data-element="Label">Notifications</span>
<uni-checkbox value="email">
<label data-uni="checkbox">
<input data-element="Input" type="checkbox" checked />
<span data-element="Indicator"></span>
<span data-element="Label">Email</span>
</label>
</uni-checkbox>
<uni-checkbox value="sms">
<label data-uni="checkbox">
<input data-element="Input" type="checkbox" />
<span data-element="Indicator"></span>
<span data-element="Label">SMS</span>
</label>
</uni-checkbox>
<span data-element="Error" hidden>Pick at least one.</span>
</div>
</uni-checkbox-group>
<script type="module">
import '@uniaria/elements/checkbox-group';
import '@uniaria/elements/checkbox';
const group = document.querySelector('#notify');
group.addEventListener('valuechange', (event) => {
group.isInvalid = event.detail.length === 0;
});
</script>The checkboxes checked in the markup, or clicked before the script loaded,
make the group’s starting value: ['email'] here, which its value property
reads.
Every checkbox in a group needs a value: its key in the group’s value,
which UniAria also writes to the input. A fieldset root with a legend
label works too (pass labelElementType: 'legend' in React and Vue).
A checkbox finds its group in one of three ways:
- Inside it. A
<uni-checkbox>inside a<uni-checkbox-group>finds it on its own. In React, render the checkboxes inside<OwnerProvider owner={group}>: they are linked from their first render, so server HTML already has the right boxes checked. Without the provider, a React checkbox inside the group’s root links after it mounts. In Vue, a checkbox component inside the group’s component, directly or through a slot, finds the group as it sets up, in time for server HTML too. - Elsewhere, by id. A
<uni-checkbox group="notify">joins the<uni-checkbox-group id="notify">from anywhere in the same document or shadow root. - Elsewhere, in React or Vue. Pass the group’s instance as the
checkbox’s
ownerprop:useBehavior(checkbox, { value: 'sms', owner: group.instance }).
A checkbox can move in or out of a group at any time. See Composition.
A “select all” checkbox stays outside the group, with no value, and you
compute its state from the group’s value:
const all = useBehavior(checkbox, {
labelElementType: 'span',
isSelected: value.length === channels.length,
isIndeterminate: value.length > 0 && value.length < channels.length,
onSelectedChange: (isSelected) => setValue(isSelected ? channels : []),
});See Checkbox for the indeterminate state.
- Submission. The group writes its
nameto every checkbox while set, so the form submits each selectedvalueunder it. There is no generated name; withoutname, the checkboxes keep their own. - Reset. A form reset returns an uncontrolled group to
defaultValue, even one set after it rendered, and the checkboxes follow. WithoutdefaultValue, the element goes back to the checkboxes checked in its markup. A controlled group gets avaluechangeasking fordefaultValue, or without one the value it mounted with. - Disabled. Every checkbox is disabled natively and none is submitted.
- Validation. In a Form, the form holds the value under
the group’s
nameand checks it on each change:isRequiredfails while nothing is selected. Without a form,isRequiredandisInvalidonly mark it: you decide when it is invalid and setisInvalid, as the demo does when nothing is selected.
See Forms.
| Part | Element | Notes |
|---|---|---|
root |
Any; usually div |
Gets role="group", an id (unless it has one) and aria-labelledby, aria-describedby and aria-disabled. The outermost element: holds the label, the description, the error and the checkboxes. A fieldset works too. |
label |
span by default, any element through labelElementType |
Names the group. Not a label: a group can’t be labelled natively. |
description |
Any | Optional. Read when focus enters the group. |
error |
Any | Optional. Hidden unless invalid; announced when it appears, and linked to every checkbox. |
errorText |
any, inside error |
Optional. In a Form, UniAria writes the first error’s text here; leave it out to render errors yourself. |
A part keeps an id you give it, and everything that refers to the part
follows it; UniAria writes an id only on a part that has none (see
Ids).
The checkboxes aren’t parts of the group: they are Checkbox components with their own parts. You own the content of every part.
A checkbox in a group takes from it:
- Selected: whether the group’s value holds its
value. A toggle adds the value (last) or removes it through the group. The checkbox’s ownisSelected,defaultSelectedand form reset are unused; development warns about a controlledisSelectedand a missingvalue. - Name: the group’s
name, while set. - Disabled and read-only: the group’s, or its own.
- Required:
aria-requiredwhile the group is required and its value is empty. - Invalid: the group’s, or its own, with the group’s error added to its
aria-describedbywhile shown. - Indeterminate stays its own.
| Prop | Attribute | Type | Default | Description |
|---|---|---|---|---|
value |
— (property) | readonly string[] |
— | The selected checkboxes’ values, controlled. Pair it with onValueChange. |
defaultValue |
— (property) | readonly string[] |
[] |
The starting value, uncontrolled. The element starts from the checkboxes checked in its markup. |
name |
name |
string |
— | Written to every checkbox’s input while set. |
isDisabled |
disabled |
boolean |
false |
Disables every checkbox natively, and writes aria-disabled="true" on the root. Hides the error. |
isReadOnly |
readonly |
boolean |
false |
Makes every checkbox read-only. |
isRequired |
required |
boolean |
false |
aria-required="true" on every checkbox while the value is empty. |
isInvalid |
invalid |
boolean |
— | aria-invalid="true" on every checkbox; shows and announces the error. Unset in a Form, the form’s errors decide it. |
labelElementType |
— (read from the tag) | string |
'span' |
The label part’s tag. |
labelledBy |
labelled-by |
string |
— | More ids for the root’s aria-labelledby, after the label part’s. |
describedBy |
described-by |
string |
— | More ids for the root’s aria-describedby, after the description’s and the error’s. |
In a Form, CheckboxGroup also takes name, form, validate,
validationTrigger, validationDelay, validationMessages, dependsOn,
isEqual and shouldPreserve: see Field props for the
form.
| Event | Callback prop | Detail | When |
|---|---|---|---|
valuechange |
onValueChange |
readonly string[] |
A checkbox in the group was toggled, or a form reset asks for the default. A newly selected value goes last. |
focuschange |
onFocusChange |
boolean |
Focus entered or left the group, such as for a form that validates on blur. |
valuechange never fires for a value you set. The custom element dispatches
each event on itself, bubbling, with the value in detail. The checkboxes’
own focuschange events bubble through the group element too, so check
event.target when you listen there.
| Field | Type | Meaning |
|---|---|---|
value |
readonly string[] |
The current value. |
isDisabled |
boolean |
The isDisabled prop, or the form’s. |
isReadOnly |
boolean |
The isReadOnly prop, or the form’s. |
isRequired |
boolean |
The isRequired prop. |
isInvalid |
boolean |
The isInvalid prop, else in a form whether it holds errors. |
isErrorShown |
boolean |
The error part is shown: invalid and not disabled. |
isFocusWithin |
boolean |
A checkbox in the group has focus. |
isFocusVisible |
boolean |
Focus within came from a keyboard or assistive technology. |
To render something from state in React or Vue, select it, and the component renders again only when that value changes (in Vue, it is a ref):
const count = useBehaviorState(group, (state) => state.value.length);On the custom element, read element.instance.getState(). The value
property holds only the value you set, not the current state. See
State.
| Attribute | When |
|---|---|
data-uni="checkbox-group" |
On the root, always. |
data-element="<Part>" |
On every other part, always. |
data-disabled |
The group is disabled. |
data-readonly |
The group is read-only. |
data-invalid |
The group is invalid. |
data-required |
The group is required. |
data-focus-within |
A checkbox in the group has focus. |
data-focus-visible |
Focus within came from a keyboard or a screen reader. |
Every part of the group carries them, so you can draw a ring around the whole
group with data-focus-within and limit it to keyboard focus with
data-focus-visible. The checkboxes’ parts carry their own
state attributes, including the group’s disabled,
read-only, required and invalid states. UniAria writes no styles.
The optional @uniaria/styles package styles the
group and its checkboxes (checkbox-group and checkbox), as in the demo
above, whose CSS tab holds only the page’s layout. The checkboxes stack 8 pixels
apart. UniAria writes no data-orientation on this group, whose layout has no
ARIA: set data-orientation="horizontal" on the root yourself to wrap them in a
row.
The group handles no keys. Each checkbox is a tab stop of its own, as native checkboxes are and as the APG describes, and Space toggles it.
| Key | Behavior |
|---|---|
| Tab | Moves to the next checkbox, natively, and leaves after the last. |
| Space | Toggles the focused checkbox, natively. Nothing when read-only. |
| Enter | Nothing on a checkbox: it submits the form, natively. |
- Spoken output. Entering the group reads “group, Notifications, Where we tell you about new orders”, then each checkbox reads as usual, such as “checkbox, Email, checked”. A disabled group reads “group, Notifications, disabled”.
- Required and invalid are read on each checkbox. Screen readers ignore
aria-requiredandaria-invalidon a group, so UniAria writes them on the checkboxes: “checkbox, Email, not checked, required” while nothing is selected, and “checkbox, Email, Pick at least one, not checked, invalid” with the error. A required group can sound as if each checkbox were required; there is no better place screen readers read. - The description is read once, when focus enters the group, and not on each checkbox. The error is linked to each, so focus anywhere explains it.
- Label the group with a label part or
labelledBy: a group without a name tells screen reader users nothing about the checkboxes in it. - Focus. The group moves no focus.
- Only the error goes on each checkbox. React Aria adds the group’s description too, so it is read again on every checkbox.
- Required on each checkbox while empty, as in React Aria and Cube UI Kit.
- A newly selected value goes last, as in React Aria. A value with no checkbox stays in the value, and a removed checkbox keeps its value there.
- No context to wire up for elements. A checkbox finds its group through the DOM, React context or an id, and can move between groups.
- No orientation prop. Cube UI Kit’s only switches the layout; a row of checkboxes is your CSS.