Use it for
Reach for BbCheckboxGroup when the answer is several choices out of one set:
which teams get the alert, which scopes the token carries. You hand it your
rows, it renders one box per row.
Use something else when
BbCheckbox: it is one yes/no standing aloneBbRadioGroup: exactly one choice out of the setBbSwitchGroup: they are on/off states, not answersBbSelect: the set is long, searchable or paged. Forty checkboxes are a scroll, not a control
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Do not hand-stack BbCheckbox. The group adds a fieldset, shared validation,
item mapping, and selection limits.
Options from your data
Pass your domain objects as they are, and name the two fields that matter: the one the user reads, and the one you want in the model.
Model: [2, 5]
<template>
<div class="flex max-w-sm flex-col gap-3">
<!--
`items` takes the User objects exactly as the fixture holds them.
`item-text` and `item-value` say which field is shown and which one
lands in the model — here the id, not the whole record.
-->
<BbCheckboxGroup
id="notify-engineers"
v-model="notified"
description="They get an email whenever a deploy fails."
item-text="fullName"
item-value="id"
:items="engineers"
legend="Notify on failed deploys"
name="notify"
/>
<p class="text-sm opacity-70">
Model: <code>[{{ notified.join(', ') }}]</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { usersByTeam } from '~/demo-data';
const engineers = usersByTeam[0]!.users.slice(0, 5);
// One array for the whole set. `multiple` defaults to true, so the model must
// be an array — the component throws on mount if it is not.
const notified = ref<number[]>([2, 5]);
</script>
Use item-text and item-value for objects. Prefer a unique primitive id for
item-value; duplicate values are dropped silently.
The default model is an array. For one exclusive value, use
BbRadioGroup instead of
:multiple="false".
The fieldset and the legend
The group renders a real <fieldset> with a real <legend>, which is what
makes the boxes one thing rather than several.
<template>
<div class="flex flex-wrap gap-8">
<!-- The legend in the normal flow, above the options. -->
<BbCheckboxGroup
id="legend-outside"
v-model="outside"
description="Sent on the first Monday of every month."
:items="categories.slice(0, 4)"
legend="Newsletter sections"
name="legend-outside"
/>
<!-- The same legend, overlaid on the fieldset border. -->
<BbCheckboxGroup
id="legend-inside"
v-model="inside"
:items="categories.slice(0, 4)"
legend="Newsletter sections"
legend-mode="inside"
name="legend-inside"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { categories } from '~/demo-data';
const outside = ref<string[]>(['Engineering']);
const inside = ref<string[]>(['Engineering']);
</script>
legend is required and names the set for assistive technology. Use
hide-legend when a nearby heading already shows the same name. hide-label
instead hides every option label.
The group takes one description and one hint. Use separate checkboxes when
individual options need different explanations.
Locked options
An option that exists but cannot be chosen is a predicate over the item, not a flag inside it.
1 on the rotation
<template>
<div class="flex max-w-sm flex-col gap-3">
<!--
Availability is read off the row with a predicate. Nothing on the User
object marks it: `selectable` asks the question, the item just answers.
-->
<BbCheckboxGroup
id="oncall-members"
v-model="onCall"
description="Deactivated members cannot take a shift."
item-text="fullName"
item-value="id"
:items="engineers"
legend="On-call rotation"
name="oncall"
:selectable="isActive"
/>
<p class="text-sm opacity-70">{{ onCall.length }} on the rotation</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { usersByTeam } from '~/demo-data';
const engineers = usersByTeam[0]!.users;
const isActive = (member: User) => member.active;
const onCall = ref<number[]>([4]);
</script>
selectable locks all options or selected items through a predicate. Rejected
options stay visible and disabled.
Migrating from v2: a disabled field on the item object now does nothing.
v2's groups honoured it and warned; v3 treats it as ordinary domain data and
ignores it silently. Options that used to render locked become tickable. Grep
the code that builds your items for disabled: and move each one into
selectable:
- :items="channels.map((c) => ({ ...c, disabled: !c.configured }))"
+ :items="channels"
+ :selectable="(c) => c.configured"
readonly keeps values focusable and submitted while blocking changes.
disabled currently leaves selected options removable, so use readonly to
freeze a populated group.
Capping the selection
max is the cap on how many options may be on at once.
2 of 3 selected
<template>
<div class="flex max-w-md flex-col gap-3">
<BbCheckboxGroup
id="digest-topics"
v-model="topics"
description="Pick up to three. Untick one to make room for another."
input-direction="horizontal"
:items="categories"
legend="Topics in your digest"
:max="3"
name="topic"
/>
<p class="text-sm opacity-70">{{ topics.length }} of 3 selected</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { categories } from '~/demo-data';
// Primitive items need no accessors at all: the string is both the text and
// the value.
const topics = ref<string[]>(['Engineering', 'Design']);
</script>
At the cap, unchecked options disable while checked ones stay removable. State
the limit in description and show a count; the component renders neither.
Validating the whole set
There is no required prop here. "At least one" is an application rule, so it
lives in the one errors channel the fieldset already has.
<template>
<form class="flex max-w-sm flex-col gap-4" @submit.prevent="submit">
<!--
There is no `required` prop on a checkbox group. "At least one" is an
application rule, so it lives in a computed `errors` that covers the
whole fieldset — one message, not one per option.
-->
<BbCheckboxGroup
id="alert-teams"
v-model="recipients"
:errors="errors"
:items="teams"
legend="Teams this alert reaches"
name="alert-team"
/>
<BbButton class="self-start" type="submit" variant="primary">
Create alert
</BbButton>
<p class="text-sm opacity-70" role="status">{{ status }}</p>
</form>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbCheckboxGroup } from 'bitboss-ui';
import { teams } from '~/demo-data';
const recipients = ref<string[]>([]);
const attempted = ref(false);
const status = ref('Submit with nothing ticked to see the message.');
// The message appears only after a failed submit, and clears itself the moment
// the value becomes valid.
const errors = computed(() =>
attempted.value && recipients.value.length === 0
? 'Choose at least one team.'
: []
);
function submit() {
attempted.value = true;
if (recipients.value.length === 0) return;
status.value = `Alert created for ${recipients.value.join(', ')}.`;
}
</script>
One errors message covers the set. There is no required prop; validate the
array when at least one choice is mandatory.
Inside a validated form, import the group from bitboss-ui/validated and give
it rules. The array is validated as a single field, and the field's name comes
from legend.
<BbCheckboxGroup
v-model="scopes"
:items="available"
legend="Token scopes"
rules="required"
/>
validate-on defaults to ['inactive'], after focus leaves the group.
When the options change
Give items a function instead of an array and the group loads its own options.
dependencies says what makes it load them again.
Model: [milan]
<template>
<div class="flex max-w-sm flex-col gap-5">
<BbRadioGroup
id="coherence-country"
v-model="country"
input-direction="horizontal"
item-text="name"
item-value="code"
:items="shippingCountries"
legend="Country"
/>
<!--
`items` is a function, so the group loads its own options. It reloads
whenever anything in `dependencies` changes, and `enforce-coherence`
drops any selected city the new list no longer contains — switch the
country and watch the model empty itself.
-->
<BbCheckboxGroup
id="coherence-cities"
v-model="hubs"
:dependencies="[country]"
enforce-coherence
item-text="name"
item-value="id"
:items="loadCities"
legend="Distribution hubs"
loading-text="Loading hubs…"
name="hub"
no-data-text="No hubs in this country."
/>
<p class="text-sm opacity-70">
Model: <code>[{{ hubs.join(', ') }}]</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup, BbRadioGroup } from 'bitboss-ui';
import type { City } from '~/demo-data';
import { cities, countries, delay } from '~/demo-data';
const shippingCountries = countries.slice(0, 3);
const country = ref<string>('IT');
const hubs = ref<string[]>(['milan']);
// A local fixture behind a timer stands in for the request. The provider is
// called on mount and again on every dependency change.
const loadCities = (): Promise<City[]> =>
delay(
cities.filter((city) => city.countryCode === country.value),
500
);
</script>
The provider runs on mount and when dependencies change.
deps-debounce-time groups rapid changes. Add enforce-coherence when stale
selections must be removed after options reload.
Decorating an option
The label slot replaces the text of every option, and receives enough to
decide what each one looks like.
1 items in the bundle
<template>
<div class="flex max-w-md flex-col gap-3">
<BbCheckboxGroup
id="bundle-items"
v-model="bundle"
item-text="name"
item-value="id"
:items="desks"
legend="Items in the starter bundle"
name="bundle"
>
<!--
One template for every option. `item` is the row you passed in, so
the badge is decided from the data rather than from a second array
kept in step by hand.
-->
<template #label="{ item, text }">
<span>{{ text }}</span>
<BbBadge
v-if="item.status === 'low-stock'"
class="ms-2"
variant="secondary"
>
Low stock
</BbBadge>
</template>
</BbCheckboxGroup>
<p class="text-sm opacity-70">{{ bundle.length }} items in the bundle</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbCheckboxGroup } from 'bitboss-ui';
import { products } from '~/demo-data';
const desks = products.filter((product) => product.category === 'Furniture');
const bundle = ref<number[]>([101]);
</script>
The scope is { item, text, checked }. Keep text in the rendered label so the
option retains an accessible name. Use separate checkboxes when only one option
needs unique markup.
Coming from v2
#option:prepend and #option:append are gone. Everything they did moves into
#label, which can put content on either side of the text. Note that #prepend
and #append still exist and are unrelated. Those inject once into the options
container, before the first option and after the last.
Layout and CSS
input-direction arranges options. direction arranges the legend and options.
<template>
<div class="flex flex-col gap-8">
<!-- Options across one line, in the compact density. -->
<BbCheckboxGroup
id="layout-row"
v-model="row"
compact
input-direction="horizontal"
:items="teams"
legend="Teams in the report"
name="layout-row"
/>
<!--
`direction` splits the legend from the options rather than laying the
options out. The two-token pattern gives the legend one share of the
width and the options four.
-->
<BbCheckboxGroup
id="layout-beside"
v-model="beside"
direction="x xxxx"
input-direction="horizontal"
:items="teams"
legend="Teams in the report"
name="layout-beside"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbCheckboxGroup } from 'bitboss-ui';
import { teams } from '~/demo-data';
const row = ref<string[]>(['Engineering']);
const beside = ref<string[]>(['Engineering']);
</script>
input-direction="vertical" stacks options. Use direction, alignment props,
reverse, and compact for the outer fieldset layout.
Coming from v2
labelPosition is now legendPosition. It is a plain rename with no
behaviour change, and it is silent: an unknown attribute falls through to the
DOM, so the alignment stops applying.
The other v3 change here is bigger and just as quiet. In v2 one CSS block,
bb-cr-container, served all three groups, so a rule written for checkboxes hit
radios too. Each group now has its own block, with otherwise identical suffixes:
| v2 | v3 |
|---|---|
.bb-cr-container | .bb-base-checkbox-group, .bb-base-radio-group, .bb-base-switch-group |
.bb-cr-container--horizontal | .bb-base-…-group--horizontal |
.bb-cr-container--vertical | .bb-base-…-group--vertical |
.bb-cr-container--errors | .bb-base-…-group--errors |
.bb-cr-container__container | .bb-base-…-group__container |
.bb-cr-container__loading-container | .bb-base-…-group__loading-container |
.bb-cr-container__no-data-container | .bb-base-…-group__no-data-container |
.bb-cr-container-option | .bb-base-…-group-option |
.bb-cr-container-option__text | .bb-base-…-group-option__text |
| — | .bb-base-…-group--warnings and .bb-base-…-group-option--selected, both new |
Nothing in v3 renders bb-cr-container. Search styles and test selectors, then
list all three new blocks where a rule applies to every group.
.bb-base-checkbox-group-option__text,
.bb-base-radio-group-option__text,
.bb-base-switch-group-option__text {
font-weight: 500;
}
For control colors and geometry, use the box variables documented on
BbCheckbox.