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, each with its own label and, where it has one, its identity in value (dropdown rows, tabs, breadcrumbs); tree nodes carry your meta and render through the default slot |
| Caption | read from an own label (or text) and value; any other field you point at with item-text, item-value | it is on the entry itself |
| This page | applies | mostly does not |
<!-- Accessor mode: customers came from an API; keep their fields. -->
<BbSelect
v-model="customerId"
:items="customers"
item-text="name"
item-value="id"
/>
<!-- Authored mode: a menu you typed out. The caption is on the entry. -->
<BbDropdown :items="[{ value: 'edit', label: '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. Rows
that already carry label and value need no accessors in either mode: those
two keys are the defaults.
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. A path has nothing to read on a string,
so it is ignored. To label a list of codes, pass a function:
:item-text="(code) => labels[code]" shows the labels, and the codes stay the
values.
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.
The three selects below render alike. Only the item shape differs, and the model under each one shows what it stores.
Model: "High"
Model: "cus_0003"
Model: 5
<template>
<div class="grid gap-6 sm:grid-cols-3">
<!-- A string[]: no accessor props at all. The value is the string. -->
<div class="grid content-start gap-2">
<BbSelect id="shapes-priority" v-model="priority" :items="priorities" label="Priority" name="priority" />
<p class="m-0 text-xs opacity-70">Model: <code>{{ JSON.stringify(priority) }}</code></p>
</div>
<!-- Nested API rows: dot paths reach into them. -->
<div class="grid content-start gap-2">
<BbSelect
id="shapes-customer"
v-model="customer"
item-text="profile.displayName"
item-value="uuid"
:items="customers"
label="Customer"
name="customer"
/>
<p class="m-0 text-xs opacity-70">Model: <code>{{ JSON.stringify(customer) }}</code></p>
</div>
<!-- A caption no single field holds: an extractor function. -->
<div class="grid content-start gap-2">
<BbSelect
id="shapes-assignee"
v-model="assignee"
:item-text="lastFirst"
item-value="id"
:items="users"
label="Assignee"
name="assignee"
/>
<p class="m-0 text-xs opacity-70">Model: <code>{{ JSON.stringify(assignee) }}</code></p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { priorities, users } from '~/demo-data';
// Stands in for rows as a CRM API returns them, field names and all.
const customers = users.slice(0, 8).map((user) => ({
uuid: `cus_${String(user.id).padStart(4, '0')}`,
profile: { displayName: user.fullName, email: user.email },
}));
const priority = ref<string | null>('High');
const customer = ref<string | null>(customers[2]!.uuid);
const assignee = ref<number | null>(users[4]!.id);
const lastFirst = (user: User) => `${user.lastName}, ${user.firstName}`;
</script>
Object items with the default keys need no props. The text is the item's own
label (else text) and the value its own value, whatever it holds. Any
other shape sets both props. Without them the value is the whole item and the
text is JSON.stringify(item): legible, but not what you meant.
A second line or an icon on each option
A second line or an icon on a row comes from
item-props, and from nowhere
else: open the select to see the job title and the team icon.
<template>
<!--
`item-props` points each extra row field at the data: a path, or a
function of the row. Nothing on the row is read unless it is named here.
-->
<BbSelect
id="row-fields-owner"
v-model="ownerId"
class="max-w-sm"
:item-props="rowFields"
item-text="fullName"
item-value="id"
:items="users"
label="Owner"
name="owner"
placeholder="Pick an owner"
/>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { Team, User } from '~/demo-data';
import { users } from '~/demo-data';
const teamIcons: Record<Team, string> = {
Engineering: 'lucide:code',
Design: 'lucide:pen-tool',
Product: 'lucide:compass',
Marketing: 'lucide:megaphone',
Sales: 'lucide:handshake',
Support: 'lucide:life-buoy',
Finance: 'lucide:landmark',
};
const rowFields = {
description: 'jobTitle',
'prepend:icon': (user: User) => teamIcons[user.team],
};
const ownerId = ref<number | null>(null);
</script>
Each field is a path (description: 'jobTitle') or a function of the item, as
the icon is here. To compute every field at once, pass a function instead of
the object. null, undefined or '' renders nothing. A description or
prepend:icon field on your data does nothing until item-props points at it.
| Component | Fields |
|---|---|
BbCheckboxGroup, BbRadioGroup, BbSwitchGroup | description |
BbSelect, BbSelectPopover | description, prepend:icon, append:icon |
BbDropdown fetched groups | the hand-written row fields (description, icons, variant, href / to, onClick), as itemProps in the group config |
The extra fields decorate the option rows only, never the field or its chips.
There is no disabled field either: see Disabling a choice.
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 follow:
- Every option needs a unique value. When two items resolve to the same value, the first stays and the later ones are dropped without a warning.
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
// throws: "multiple … but modelValue is not an array"
const channels = ref();
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.
{ label: 'All locations', value: null } needs no accessors: an own value
key is the value even when it holds null.
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, with no UI
meaning on any options component. Options are gated through props, so a flag
your data already carries goes into a predicate:
:selectable="(item) => !item.disabled".
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.
<template>
<!--
Left: `selectable` renders the rejected rows disabled (inactive members).
Right: `max` disables the unpicked rows once two are chosen.
A `disabled` field on the row does nothing by itself.
-->
<div class="grid max-w-2xl gap-6 sm:grid-cols-2">
<BbSelect
id="availability-assignee"
v-model="assigneeId"
hint="Inactive members are greyed out."
item-text="fullName"
item-value="id"
:items="users"
label="Assignee"
name="assignee"
persistent-hint
placeholder="Pick a member"
:selectable="(user: User) => user.active"
/>
<BbSelect
id="availability-reviewers"
v-model="reviewerIds"
hint="Two at most."
item-text="fullName"
item-value="id"
:items="users"
label="Reviewers"
:max="2"
multiple
name="reviewers"
persistent-hint
placeholder="Pick up to two"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { users } from '~/demo-data';
const assigneeId = ref<number | null>(null);
const reviewerIds = ref<number[]>([]);
</script>
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 you write are buttons rather than options, so disabled
there is an ordinary button prop and works as you expect. The rows of a fetched
dropdown group have no disabled: gate them with the group's selectable
predicate, which receives the raw fetched item.
Wrapping a select in your own component
A select that only fixes its items, accessors, label or option row is a
preset, not a wrapper: one definePreset call, no SFC.
export const PriceListSelect = definePreset(
BbSelect<PriceList>,
{ items: getPriceLists, itemText: 'name', itemValue: 'id' },
{ name: 'PriceListSelect', defaults: { label: 'Price list' } }
);
// <PriceListSelect v-model="priceListId" clearable />
// v-model: number | null
definePreset is a helper you copy into your app, not a bitboss-ui export:
Preset components has its source and its rules.
Every BbSelect prop, event and slot reaches the select untouched, typed for
the row, and v-model is typed from itemValue.
Write a single-file wrapper only when the component has logic of its own: state, derived props, extra props it reads, markup around the select. Five rules keep it typed and working:
BbSelectProps<Row>takes the row type, not the value type. WithBbSelectProps<PriceList['id']>, a caller's:item-text="(p) => p.name"fails with "Property 'name' does not exist on type 'number'". Type the model yourself:defineModel<PriceList['id'] | null>().Omitwhat the wrapper sets (items,itemText,itemValue,modelValue), so a caller cannot pass a second value that loses.- Forward every prop you declare, or leave it undeclared. A declared prop
never reaches
$attrs, so a wrapper that declares all ofBbSelectPropsand forwards seven drops the caller'sdisabledorhintin silence. Declare only what the wrapper reads and let$attrscarry the rest;npx bitboss-ui checkreports the dropped ones aswrapper-drops-props. - Declaring
filterBy? Default it:withDefaults(defineProps<…>(), { filterBy: () => [] }). It isstring[] | false, and Vue casts an absent prop with abooleanmember tofalse, so a wrapper that declares it (directly, or through aPick/Omitthat keeps it) forwardsfalsewhenever its caller leavesfilter-byout, and typing stops filtering in silence. Left to$attrs, it needs nothing. - Import types as types:
import { BbSelect, type BbSelectProps } from 'bitboss-ui'. UnderverbatimModuleSyntaxa plain import compiles and then fails in the browser.
A wrapper that follows them declares only what it reads, and the root
BbSelect takes everything else through $attrs:
<!-- PriceListSelect.vue -->
<template>
<BbSelect
v-model="model"
item-text="name"
item-value="id"
:items="getPriceLists"
:label="label"
>
<template #option="{ item, text }">
{{ text }}
<BbBadge v-if="item.isDefault" variant="primary">
Default
</BbBadge>
</template>
</BbSelect>
</template>
<script setup lang="ts">
import { BbBadge, BbSelect } from 'bitboss-ui';
type PriceList = { id: number; name: string; isDefault: boolean };
const getPriceLists = async (): Promise<PriceList[]> => [
{ id: 1, name: 'Base', isDefault: true },
];
// Everything else (clearable, errors, pt, …) arrives in $attrs.
defineProps<{ label: string }>();
const model = defineModel<PriceList['id'] | null>({ default: null });
</script>
A wrapper that reads or merges pt itself declares it as
pt?: BbSelectProps<PriceList>['pt'] and binds :pt="pt", so the caller keeps
the parts, the states and the row type: see
Passthrough.
The same rules hold for BbCheckboxGroup, BbRadioGroup and BbSwitchGroup
with their own <Component>Props<Row>.
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.