Every component that takes an items prop shares one contract. You hand it your
domain objects as they are, and you tell it which field to display and which to
store. Nothing in your code maps rows into option objects.
This is the page the other playbooks build on.
Fetching data covers items as a function, and
Coherence covers what happens to a bound value when the
options underneath it change.
Two item vocabularies
items means two different things in this library, and picking the wrong one is
the most common early mistake. The question that settles it is whose data is
it?
| Accessor mode — your data | Authored mode — your UI | |
|---|---|---|
| Components | BbSelect, BbSelectPopover, BbCheckboxGroup, BbRadioGroup, BbSwitchGroup | BbDropdown, BbDropdownButton, BbTabs, BbBreadcrumbs, BbTree |
| Shape | any shape — raw API rows | entries you write by hand |
| Caption | you point at a field: item-text, item-value | it is on the entry itself |
| This page | applies | mostly does not |
<!-- Accessor mode: customers came from an API, so keep their field names. -->
<BbSelect v-model="customerId" :items="customers" item-text="name" item-value="id" />
<!-- Authored mode: a menu you typed out. The caption lives on the entry. -->
<BbDropdown :items="[{ text: 'Edit', value: 'edit' }]" />
The split is deliberate. A picker lists records that arrive with their own field
names, full_name and uuid. Forcing them into { label, value } would mean a
pointless .map() over every list. For the async form it cannot work at all: the
component fetches the items itself and never sees your array. A dropdown or a tab
strip is the opposite. You are writing those captions by hand
anyway, so an accessor would be indirection with nothing to point at.
BbTable sits between the two. Rows stay raw data and
item-value is row identity, but the captions live on columns[].label rather
than on the rows.
Mapping text and value
Everything below is accessor mode. There are three ways to resolve a field, and they are in order of preference.
Primitives need nothing. For a string[] or a number[], omit both props
and the value is the primitive itself.
Dot paths cover nested objects: item-text="profile.fullName",
item-value="profile.id".
Extractor functions are for what a path cannot express: a name assembled from two columns, a computed label.
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { Member } from '@/types';
const assignee = ref<string | null>(null);
const members = ref<Member[]>([]); // raw API rows
const memberText = (m: Member) => `${m.firstName} ${m.lastName}`;
</script>
<template>
<BbSelect
v-model="assignee"
label="Assignee"
:items="members"
:item-text="memberText"
item-value="uuid"
/>
</template>
For object items, always set both props. The default item-value is the whole
item and the default item-text is JSON.stringify(item). That is a legible
failure rather than a silent one, but a failure all the same.
How a value is matched
Selection compares hash(itemValue(item)) against hash(modelValue). That is a
deep value comparison, not reference identity, so an object value still
matches after a refetch replaces the array.
Prefer primitive ids anyway: they are stable across refetches, cheap to submit, and they make the hash trivial.
Two consequences worth knowing before they surprise you:
- Duplicate values are silently dropped. The first occurrence wins.
Guarantee a unique
item-valueper option. modelValueholds the resolved value, never the option wrapper. Slots receive the rawitemplus the resolvedtextandvalue. A custom row still has everything the record carried.
Clicking the selected option never clears a single select. A second click
re-picks it and closes the panel; the value does not change. Emptying always
takes an explicit control: clearable on
BbSelect, or the clear callback on
BbSelectPopover's slots. Multiple mode
still toggles, because there removing a pick is the interaction.
Seeding the model
A component in multiple mode throws at setup when modelValue is not an
array. This is loud on purpose: the alternative is a control that silently does
nothing.
const channels = ref<string[]>([]); // correct
const channels = ref(); // throws: "multiple … but modelValue is not an array"
The default differs per component, which is the part people get wrong:
| Component | multiple default | Seed with |
|---|---|---|
BbSelect / BbSelectPopover | false | ref(null), or ref([]) with multiple |
BbCheckboxGroup / BbSwitchGroup | true | ref([]), unless you set :multiple="false" |
BbRadioGroup | single only | ref<string | null>(null) |
BbTable | true | defaults to []; throws only on a non-array v-model |
An "All" option
A single select whose top row means everything: "All locations", "Any status", the no-filter row. This is not a select-all button, which fills a multiple array with every value.
Model it as a real option with value: null, returned by your provider or
present in your array like any other option. The components never synthesise it.
It resolves and renders as the selection, because matching is on hash(value)
and selection is marked with !== undefined, so null is a legitimate value.
<!-- null means "All", and items is a provider → say so -->
<BbSelect v-model="location" :items="loadLocations" :prefill="true" />
<!-- array items: nothing needed, arrays are never lazy -->
<BbSelect v-model="location" :items="locations" />
Overloading null is a supported pattern, and you inherit four behaviours with
it, because the component cannot tell your two meanings apart:
| Behaviour | Consequence |
|---|---|
clear emits null | The clear affordance selects "All" |
| empty is always coherent | enforce-coherence never prunes the "All" value |
single-mode enforcement emits null | Pruning some other stale value widens to "All" |
null reads as empty | A null model stays lazy even with enforce-coherence |
Sometimes "everything" and "nothing" have to stay distinct: a required field, or
a filter where silently widening is unsafe. Use a sentinel such as
value: 'all' there. null then keeps meaning empty. The sentinel has to appear
in every provider response or coherence prunes it,
and it counts as a value, so it triggers the eager load by itself.
Disabling a choice
A disabled field on the item object is plain domain data. Every options
component ignores it. The per-item behaviour that existed in v2 was removed, and
nothing warns, so a row you expected to be greyed is simply pickable.
The supported surface is three props:
disabledturns off the whole control.selectablegates per option, onBbCheckboxGroup,BbRadioGroup,BbSwitchGroup,BbSelect,BbSelectPopoverandBbTable. It takes a global boolean or a predicate(item) => boolean; rejected options render disabled. It defaults totrueon the option components. OnBbTableit is unset until you pass it, and unset there means "no selection column", not "every row selectable".maxcaps a multiple selection. At the cap, unselected options auto-disable while selected ones never do, so the user can always deselect.
Feed your own availability flag to the predicate:
:selectable="(item) => !item.disabled", or
:selectable="(user) => user.active". Filter the rows out entirely when a dead
one would only add noise.
BbDropdown items are buttons rather than options, so disabled there is an
ordinary button prop and works as you expect.
Items that navigate
When an item is a link, the component navigates, never your code. BbDropdown
items take href or to; a table row link is a
BbButton with href/to in a cell or the
#actions slot.
Never write onClick: () => router.push(…) on an item, and never a row
@click that sets window.location. Both throw away middle-click, open in a
new tab, and copy address. Neither is any shorter.
Once an array-backed control works, keep the same item-text, item-value and
model shape when moving to a provider. Only items changes from an array to a
function. Continue with Fetching data, then add
coherence if another field can invalidate the current
selection.