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, 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
Captionread from an own label (or text) and value; any other field you point at with item-text, item-valueit is on the entry itself
This pageappliesmostly does not
vue
<!-- 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.

High

Model: "High"

Sofia Marchetti

Model: "cus_0003"

Ferreira, Hugo

Model: 5

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.

Pick an owner

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.

ComponentFields
BbCheckboxGroup, BbRadioGroup, BbSwitchGroupdescription
BbSelect, BbSelectPopoverdescription, prepend:icon, append:icon
BbDropdown fetched groupsthe 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.
  • 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
// throws: "multiple … but modelValue is not an array"
const channels = ref();

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. { 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.

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

  • 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.
Pick a memberInactive members are greyed out.
Pick up to twoTwo at most.

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.

ts
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. With BbSelectProps<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>().
  • Omit what 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 of BbSelectProps and forwards seven drops the caller's disabled or hint in silence. Declare only what the wrapper reads and let $attrs carry the rest; npx bitboss-ui check reports the dropped ones as wrapper-drops-props.
  • Declaring filterBy? Default it: withDefaults(defineProps<…>(), { filterBy: () => [] }). It is string[] | false, and Vue casts an absent prop with a boolean member to false, so a wrapper that declares it (directly, or through a Pick / Omit that keeps it) forwards false whenever its caller leaves filter-by out, and typing stops filtering in silence. Left to $attrs, it needs nothing.
  • Import types as types: import { BbSelect, type BbSelectProps } from 'bitboss-ui'. Under verbatimModuleSyntax a 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:

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

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.