Skip to content

Options and items

How every component with an items prop turns your domain objects into options.

On this page

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 dataAuthored mode — your UI
ComponentsBbSelect, BbSelectPopover, BbCheckboxGroup, BbRadioGroup, BbSwitchGroupBbDropdown, BbDropdownButton, BbTabs, BbBreadcrumbs, BbTree
Shapeany shape — raw API rowsentries you write by hand
Captionyou point at a field: item-text, item-valueit is on the entry itself
This pageappliesmostly does not
vue
<!-- 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.

vue
<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-value per option.
  • modelValue holds the resolved value, never the option wrapper. Slots receive the raw item plus the resolved text and value. 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.

ts
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:

Componentmultiple defaultSeed with
BbSelect / BbSelectPopoverfalseref(null), or ref([]) with multiple
BbCheckboxGroup / BbSwitchGrouptrueref([]), unless you set :multiple="false"
BbRadioGroupsingle onlyref<string | null>(null)
BbTabletruedefaults 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.

vue
<!-- 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:

BehaviourConsequence
clear emits nullThe clear affordance selects "All"
empty is always coherentenforce-coherence never prunes the "All" value
single-mode enforcement emits nullPruning some other stale value widens to "All"
null reads as emptyA 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:

  • disabled turns off the whole control.
  • selectable gates per option, on BbCheckboxGroup, BbRadioGroup, BbSwitchGroup, BbSelect, BbSelectPopover and BbTable. It takes a global boolean or a predicate (item) => boolean; rejected options render disabled. It defaults to true on the option components. On BbTable it is unset until you pass it, and unset there means "no selection column", not "every row selectable".
  • max caps 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.

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.