# Checkbox Group

One value for a set of checkboxes, with a parent checkbox that ticks or clears them all.

Checkbox Group collects a set of checkboxes into a single value, an array of the
ones that are ticked, and takes part in forms as one control. Useful when the
answer is “any of these” rather than “one of these”, which is where
[`Radio`](/components/radio) belongs instead.

::component-preview{name="CheckboxGroupPreview"}
```vue
<template>
  <Checkbox.Group
    v-model="selected"
    :all-values="allValues"
    aria-label="Notifications"
    class="flex flex-col gap-3"
    :options="{ name: 'notifications' }"
  >
    <div class="flex items-start gap-3">
      <Checkbox.Root
        id="notifications-all"
        :class="box"
        :options="{ parent: true }"
      >
        <Checkbox.Indicator
          v-slot="{ indeterminate: mixed }"
          class="block size-[0.9375rem]"
        >
          <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
            <path v-if="mixed" :d="indeterminate" />
            <path v-else :d="check" />
          </svg>
        </Checkbox.Indicator>
      </Checkbox.Root>

      <Label :class="label" for="notifications-all">
        All notifications
      </Label>
    </div>

    <div
      v-for="channel in channels"
      :key="channel.value"
      class="flex items-start gap-3 pl-8"
    >
      <Checkbox.Root
        :id="`notifications-${channel.value}`"
        :class="box"
        :options="{ value: channel.value }"
      >
        <Checkbox.Indicator class="block size-[0.9375rem]">
          <svg viewBox="0 0 18 18" fill="currentColor" aria-hidden="true">
            <path :d="check" />
          </svg>
        </Checkbox.Indicator>
      </Checkbox.Root>

      <Label :class="label" :for="`notifications-${channel.value}`">
        {{ channel.label }}
      </Label>
    </div>
  </Checkbox.Group>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { Checkbox, Label } from '@maas/mirror/vue'

const box = [
  'flex size-5 items-center justify-center border-2 border-surface',
  'rounded-component-compact-md text-accent-on-solid',
  'transition-all duration-100 ease-linear',
  'outline-4 outline-transparent focus-visible:focus-ring',
  'active:border-primary-subtle',
  'data-[state=checked]:bg-accent-solid data-[state=indeterminate]:bg-accent-solid',
  'data-[state=checked]:border-[transparent] data-[state=indeterminate]:border-[transparent]',
  'data-[state=checked]:active:bg-accent-solid-active',
  'data-[state=indeterminate]:active:bg-accent-solid-active',
  'data-[disabled=true]:border-disabled-subtle',
  'data-[disabled=true]:data-[state=checked]:bg-disabled-solid',
  'data-[disabled=true]:text-disabled-on-solid',
].join(' ')

const label = [
  'type-component-xs text-surface translate-y-[0.1rem] [--mirror-label-cursor:pointer]',
  'data-[disabled=true]:text-disabled-solid',
].join(' ')

const check =
  'M13.6875 5.1875C13.875 5 14.1042 4.90625 14.375 4.90625C14.6458 4.90625 14.875 5 15.0625 5.1875C15.25 5.375 15.3438 5.60417 15.3438 5.875C15.3438 6.14583 15.25 6.375 15.0625 6.5625L7.96875 13.6562C7.63542 13.9896 7.22917 14.1562 6.75 14.1562C6.27083 14.1562 5.86458 13.9896 5.53125 13.6562L2.9375 11.0625C2.75 10.875 2.65625 10.6458 2.65625 10.375C2.65625 10.1042 2.75 9.875 2.9375 9.6875C3.125 9.5 3.35417 9.40625 3.625 9.40625C3.89583 9.40625 4.125 9.5 4.3125 9.6875L6.90625 12.2812C6.86458 12.2396 6.8125 12.2188 6.75 12.2188C6.6875 12.2188 6.63542 12.2396 6.59375 12.2812L13.6875 5.1875Z'

const indeterminate =
  'M14.0313 8.03125C14.3021 8.03125 14.5312 8.125 14.7187 8.3125C14.9062 8.5 15 8.72917 15 9C15 9.27083 14.9062 9.5 14.7187 9.6875C14.5312 9.875 14.3021 9.96875 14.0313 9.96875H3.96875C3.69792 9.96875 3.46875 9.875 3.28125 9.6875C3.09375 9.5 3 9.27083 3 9C3 8.72917 3.09375 8.5 3.28125 8.3125C3.46875 8.125 3.69792 8.03125 3.96875 8.03125H14.0313Z'

const channels = [
  { value: 'email', label: 'Email' },
  { value: 'sms', label: 'Text message' },
  { value: 'push', label: 'Push' },
]

const allValues = channels.map((channel) => channel.value)

const selected = ref(['email'])
</script>
```
::

## Usage guidelines

- Every checkbox in the group needs `options.value`, because that string is what
  goes into the array and what the form submits.
- The parent checkbox needs the group’s `allValues`, since nothing else tells
  it what “everything” means. Members that have not mounted yet still count.
- The group is one control as far as a `Field` is concerned, so validation runs
  on the array and the members read the result rather than registering
  themselves.
- Give every per-member `Label` its own `id`, or inside a `Field` they share
  the field’s label ID and all register as its label.

## Anatomy

Assemble the group, one `Checkbox.Root` per value, plus a parent if you want
one.

::component-anatomy
---
parts:
  - name: Checkbox.Group
    required: true
    description: Renders a div with role="group", plus one hidden input per selected value.
    children:
      - name: Checkbox.Root
        required: true
        description: Renders a button with role="checkbox". One per value, plus an optional parent.
        children:
          - name: Checkbox.Indicator
            description: Renders a span. Only while the checkbox is ticked or mixed.
---
::

```vue
<script setup lang="ts">
import { Checkbox, Label } from '@maas/mirror/vue'
</script>

<template>
  <Checkbox.Group
    v-model="channels"
    :all-values="allValues"
    :options="{ name: 'channels' }"
  >
    <Checkbox.Root id="channels-email" :options="{ value: 'email' }">
      <Checkbox.Indicator />
    </Checkbox.Root>
    <Label id="channels-email-label" for="channels-email">Email</Label>
  </Checkbox.Group>
</template>
```

## Examples

### Controlled and uncontrolled

Leave `modelValue` out and the group keeps its own array, seeded by
`defaultValue`. Bind `v-model` and you own it instead, which is
[decided once at mount](/components/composition#controlled-and-uncontrolled).

```vue
<template>
  <Checkbox.Group :default-value="['email']">
    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>

  <Checkbox.Group v-model="channels">
    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>
</template>

<script setup lang="ts">
const channels = ref(['email'])
</script>
```

### The parent checkbox

A checkbox with `options.parent` set stands for the whole group rather than for
a value of its own. It is ticked while every value in `allValues` is selected,
mixed while only some are, and activating it either selects everything or
clears the lot.

```vue
<template>
  <Checkbox.Group v-model="channels" :all-values="['email', 'sms']">
    <Checkbox.Root :options="{ parent: true }">
      <Checkbox.Indicator v-slot="{ indeterminate }">
        {{ indeterminate ? '–' : '✓' }}
      </Checkbox.Indicator>
    </Checkbox.Root>

    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>
</template>
```

The parent has no `value` and never appears in the array or in the submission.
`allValues` may hold values no `Checkbox.Root` renders, so a collapsed or
paginated list still resolves the parent against the whole set.

### Nesting

A group inside a group takes over for its own subtree, so the members between
them belong to the inner one. Point both at the same array and each parent
covers whatever its own `allValues` names, the outer one everything and the
inner one its section.

```vue
<template>
  <Checkbox.Group v-model="all" :all-values="['email', 'sms', 'ping']">
    <Checkbox.Root :options="{ parent: true }" />

    <Checkbox.Group v-model="all" :all-values="['email', 'sms']">
      <Checkbox.Root :options="{ parent: true }" />
      <Checkbox.Root :options="{ value: 'email' }" />
      <Checkbox.Root :options="{ value: 'sms' }" />
    </Checkbox.Group>

    <Checkbox.Root :options="{ value: 'ping' }" />
  </Checkbox.Group>
</template>

<script setup lang="ts">
const all = ref(['email'])
</script>
```

The outer parent is mixed here, because `ping` is out. Ticking the inner parent
adds `sms` and leaves the outer one mixed until `ping` follows.

### Disabling

A group that is disabled disables every member and every hidden input, whatever
the member says, and blocks changes from the keyboard, the pointer and the
composable alike.
`readOnly` blocks the same changes while keeping every member focusable.

```vue
<template>
  <Checkbox.Group v-model="channels" :options="{ disabled: true }">
    <Checkbox.Root :options="{ value: 'email' }" />
    <Checkbox.Root :options="{ value: 'sms' }" />
  </Checkbox.Group>
</template>
```

### Inside a form

The group renders one visually hidden input per selected value, all sharing its
`name`, so [the form sees it](/components/form-integration#form-participation). The members
render no hidden inputs of their own while they are in a group.

```vue
<template>
  <form @submit.prevent="submit">
    <Checkbox.Group v-model="channels" :options="{ name: 'channels' }">
      <Checkbox.Root :options="{ value: 'email' }" />
      <Checkbox.Root :options="{ value: 'sms' }" />
    </Checkbox.Group>
  </form>
</template>
```

Without a `name`, from either the group or the field, nothing is submitted at
all. A group rendered outside the `<form>` it belongs to points at it by `id`
through `form`, which reaches every hidden input.

### Inside a Field

The group registers as the field’s single control, so `validate` receives the
array and the four validity attributes reach the group and every member.

```vue
<template>
  <Field.Root :options="{ name: 'channels', validate }">
    <Label :native-label="false">Notifications</Label>
    <Checkbox.Group v-model="channels" :all-values="allValues">
      <Checkbox.Root id="channels-email" :options="{ value: 'email' }" />
      <Label id="channels-email-label" for="channels-email">Email</Label>
    </Checkbox.Group>
    <Field.Error />
  </Field.Root>
</template>

<script setup lang="ts">
function validate(value: unknown) {
  return (value as Array<string>).length > 0 ? null : 'Pick at least one'
}
</script>
```

### Styling from state

The group publishes the field state set and nothing else, so paint it from
validity and read the array as a slot prop where the markup has to branch.

```vue
<template>
  <Checkbox.Group v-slot="{ value, allChecked }" class="group">
    <p>{{ allChecked ? 'Everything' : `${value.length} selected` }}</p>
  </Checkbox.Group>
</template>

<style>
.group[data-invalid='true'] {
  color: var(--app-color-danger-fg-muted);
}
</style>
```

## API reference

**Module.** A bundled `options` object, and `useMirrorCheckboxGroup(id)` as the
programmatic API. `Checkbox.Group` is one part of the `Checkbox` namespace, and
the checkboxes it collects are the same
[`Checkbox.Root`](/components/checkbox) you render on its own.

### `Checkbox.Group`

Renders a `<div role="group">`, plus one visually hidden `<input type="hidden">`
per selected value.

#### Props

::docs-table
---
columns:
  - label: Prop
  - label: Type
  - label: Default
rows:
  - items:
      - label: id
        description: 'The instance ID, for [`useMirrorCheckboxGroup`](#composable).'
      - label: string
      - label: generated
        plaintext: true
  - items:
      - label: modelValue
        description: The selected values. `v-model`.
      - label: 'string[] | undefined'
      - label: undefined
  - items:
      - label: defaultValue
        description: Initial selection when [uncontrolled](#controlled-and-uncontrolled).
      - label: 'string[]'
      - label: '[]'
  - items:
      - label: allValues
        description: 'Every value the group covers, for [the parent checkbox](#the-parent-checkbox).'
      - label: 'string[]'
      - label: undefined
  - items:
      - label: options
        description: Everything else. Deep-merged over the defaults.
      - label: CheckboxGroupOptions
      - label: see below
        plaintext: true
---
::

`allValues` is the set the group covers rather than a piece of configuration,
so it stays a prop alongside the value itself.

#### Options

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: name
        description: 'Form field name, on [every hidden input](#inside-a-form).'
      - label: string
      - label: inherited from `Field`
        plaintext: true
  - items:
      - label: form
        description: The `id` of the form, when rendered outside it.
      - label: string
      - label: undefined
  - items:
      - label: disabled
        description: 'Blocks [changes](#disabling), on the group and every member.'
      - label: boolean
      - label: inherited from `Field`, else `false`
        plaintext: true
  - items:
      - label: readOnly
        description: Blocks changes but keeps every member focusable.
      - label: boolean
      - label: inherited from `Field`, else `false`
        plaintext: true
  - items:
      - label: required
        description: Marks the hidden inputs required.
      - label: boolean
      - label: inherited from `Field`, else `false`
        plaintext: true
---
::

#### Emits

::docs-table
---
columns:
  - label: Emit
  - label: Payload
rows:
  - items:
      - label: update:modelValue
        description: The selection changes.
      - label: 'string[]'
---
::

#### Slot props

The [field state set](/components/styling#the-field-state-set), plus the three below.

::docs-table
---
columns:
  - label: Prop
  - label: Type
rows:
  - items:
      - label: value
        description: The selected values, in the order they were added.
      - label: 'string[]'
  - items:
      - label: allChecked
        description: Every value in `allValues` is selected.
      - label: boolean
  - items:
      - label: anyChecked
        description: At least one value in `allValues` is selected.
      - label: boolean
---
::

### `Checkbox.Root` inside a group

Two options on [`Checkbox.Root`](/components/checkbox) only mean anything
inside a group.

::docs-table
---
columns:
  - label: Option
  - label: Type
  - label: Default
rows:
  - items:
      - label: value
        description: The value this checkbox stands for. Required unless `parent` is set.
      - label: string
      - label: '''on'''
  - items:
      - label: parent
        description: 'Stands for [the whole group](#the-parent-checkbox) rather than for a value.'
      - label: boolean
      - label: 'false'
---
::

Four options stop meaning anything: `name`, `form`, `required` and
`uncheckedValue`. The group owns submission and a member renders no input of
its own, so set them on the group instead. A member that sets any of them
warns `ignored_member_options` in development.

```vue
<template>
  <Checkbox.Group v-model="channels" :options="{ name: 'channels' }">
    <Checkbox.Root :options="{ value: 'email' }" />
  </Checkbox.Group>
</template>
```

`options.disabled` and `options.readOnly` still count. Each is merged with the
group’s, so either side can block a member and neither can unblock one the
other has stopped.

### Composable

`useMirrorCheckboxGroup(id)` reaches a group from anywhere in the app.

::docs-table
---
columns:
  - label: Key
  - label: Type
rows:
  - items:
      - label: value
        description: The selected values.
      - label: ComputedRef<string[]>
        escape: true
  - items:
      - label: allValues
        description: Every value the group covers.
      - label: ComputedRef<string[]>
        escape: true
  - items:
      - label: allChecked
        description: Every value is selected.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: anyChecked
        description: At least one value is selected.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: disabled
        description: Mirrors `data-disabled`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: readOnly
        description: Mirrors `data-readonly`.
      - label: ComputedRef<boolean>
        escape: true
  - items:
      - label: setValue
        description: Replaces the whole selection.
      - label: '(next: string[]) => void'
        escape: true
  - items:
      - label: setMember
        description: Adds or removes one value.
      - label: '(value: string, next: boolean) => void'
        escape: true
  - items:
      - label: toggleValue
        description: Flips one value.
      - label: '(value: string) => void'
        escape: true
  - items:
      - label: setAll
        description: Selects every value in `allValues`, or clears the group.
      - label: '(next: boolean) => void'
        escape: true
  - items:
      - label: clear
        description: Clears the group.
      - label: () => void
        escape: true
---
::

### Data attributes

The group writes the [field state set](/components/styling#the-field-state-set)
and nothing beyond it. Each member writes the same set plus the `Checkbox`
attributes.

::data-attributes
::

### CSS variables

None. The group renders no functional CSS, and its members keep the
[`Checkbox` variables](/components/checkbox#css-variables).

### Errors

::docs-table
---
columns:
  - label: Code
rows:
  - items:
      - label: missing_checkbox_value
        description: 'A `Checkbox.Root` inside a group has neither `options.value` nor `options.parent`.'
  - items:
      - label: missing_all_values
        description: 'A parent `Checkbox.Root` sits in a group with no `allValues`.'
  - items:
      - label: ignored_member_options
        description: 'A warning, not a throw. A `Checkbox.Root` inside a group sets `options.name`, `options.form`, `options.required` or `options.uncheckedValue`, and the group owns submission. Development only.'
---
::

## Accessibility

The group renders `role="group"`, takes its name from a `Field` label through
`aria-labelledby` or from an `aria-label` of your own, and reports
`aria-required` and `aria-invalid` from the field. Every member keeps
`role="checkbox"`, and the parent reports `aria-checked="mixed"` while the
group is partly selected.

Focus is not managed: each member is its own tab stop, which is the checkbox
pattern rather than the radio one.

::docs-table
---
columns:
  - label: Key
  - label: Action
rows:
  - items:
      - label: Tab
      - label: Moves to the next member, and out of the group after the last one.
        plaintext: true
  - items:
      - label: Space
      - label: Toggles the focused member, on `keyup`.
        plaintext: true
  - items:
      - label: Enter
      - label: Nothing. The `keydown` default is prevented, leaving Enter to the surrounding form.
        plaintext: true
---
::
