Use it for
Use BbSelect when a form needs a searchable choice from known values, such as
an assignee, city, plan or set of labels. It owns the label, validation,
selection display and options panel.
Use something else when
BbRadioGroup: a short exclusive choice should stay visibleBbCheckboxGroup: a short multiple choice should stay visibleBbTag: users invent values instead of choosing from a known setBbDropdown: the rows run actionsBbSelectPopover: the trigger is a pill, badge or table cell instead of a form field
Also see: Options and items, Fetching data, Coherence, Validated forms.
The field props follow BbTextInput. This page
focuses on option data, search and selection.
Passthrough
Hover or tap a part to outline it. The toggles turn on loading, errors and warnings where the component has them; only parts get a row.
Pick from application data
Pass your API rows as they are and name the label and the stored value with item-text and item-value, each a field path or a function. Rows that already have label and value (or text and value) need neither.
Model: 4 (the id, not the person)
<template>
<div class="flex max-w-sm flex-col gap-2">
<!--
`users` are the API rows, passed exactly as they arrive. The two
accessors say which field is shown and which one is stored, so nothing
is mapped into `{ label, value }` first.
-->
<BbSelect
id="issue-assignee"
v-model="assignee"
clearable
item-text="fullName"
item-value="id"
:items="users"
label="Assignee"
name="assignee"
placeholder="Choose a teammate"
:selectable="isActive"
/>
<p class="text-sm opacity-70">
Model: <code>{{ assignee ?? 'null' }}</code> (the id, not the person)
</p>
</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 assignee = ref<number | null>(4);
// Deactivated members render disabled and keyboard navigation skips them. A
// `disabled` field on the item itself would do nothing — this predicate is the
// supported surface.
const isActive = (user: User) => user.active;
</script>
Without item-text an option of any other shape reads as JSON.stringify(item), and without item-value the model holds the whole object. Disable single options with selectable (a disabled field on the item is plain data).
For a list of strings, label, v-model and items are enough.
Model: null
<template>
<div class="flex max-w-sm flex-col gap-2">
<BbSelect
id="ticket-priority"
v-model="priority"
:items="priorities"
label="Priority"
name="priority"
placeholder="Choose a priority"
/>
<p class="text-sm opacity-70">
Model: <code>{{ priority ?? 'null' }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { priorities } from '~/demo-data';
// A string array needs no `item-text` or `item-value`: the option's text and
// its value are the string itself.
const priority = ref<string | null>(null);
</script>
Keep label meaningful even with hide-label; it is the accessible name. Add clearable when the value may be emptied: clicking the selected option again keeps it.
Coming from v2
In single mode, clicking the selected option now keeps it selected and closes
the panel; v2 emitted null. Multiple mode still toggles the option.
Form state and validation
Add guidance with description and hint, and let people empty the field with clearable.
<template>
<div class="max-w-sm">
<BbSelect
id="workspace-plan"
v-model="plan"
clearable
description="You can change plan at any time."
hint="Yearly billing saves 20%."
:items="plans"
label="Workspace plan"
name="plan"
persistent-hint
placeholder="Choose a plan"
prepend:icon="lucide:credit-card"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
type Plan = { value: string; label: string };
const plans: Plan[] = [
{ value: 'starter', label: 'Starter' },
{ value: 'team', label: 'Team' },
{ value: 'scale', label: 'Scale' },
];
const plan = ref<string | null>('team');
</script>
Inside a BbForm, the select joins the form by name and checks its rules when focus leaves both the field and its panel. Press Save billing details with no country to see it.
<template>
<!--
Inside a `BbForm` each select joins the form by its `name`, validates
itself when focus leaves the control and its panel, and is submitted
with the rest. No `@inactive` handler, no errors ref.
-->
<BbForm
class="flex max-w-sm flex-col gap-4"
@invalid="status = 'Not saved: the country is missing.'"
@submit="onSubmit"
>
<BbSelect
id="billing-country"
v-model="billing.country"
item-text="name"
item-value="code"
:items="countries"
label="Billing country"
name="country"
placeholder="Required for invoicing"
rules="required"
/>
<!-- Disabled until the field it depends on has a value. -->
<BbSelect
id="billing-vat-scheme"
v-model="billing.vatScheme"
:disabled="!billing.country"
hint="Pick a billing country first."
:items="vatSchemes"
label="VAT scheme"
name="vatScheme"
placeholder="Choose a VAT scheme"
:persistent-hint="!billing.country"
/>
<!-- Accepted, but worth a second look: amber, and no aria-invalid. -->
<BbSelect
id="billing-currency"
v-model="billing.currency"
:items="currencies"
label="Invoice currency"
name="currency"
:warnings="currencyWarnings"
/>
<BbButton class="self-start" type="submit" variant="primary">
Save billing details
</BbButton>
<p class="m-0 text-sm opacity-70" role="status">{{ status }}</p>
</BbForm>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue';
import { defineRule } from 'vee-validate';
import { required } from '@vee-validate/rules';
import { BbButton, BbForm, BbSelect } from 'bitboss-ui';
import { countries } from '~/demo-data';
// In an app this lives once in a validators module, not in the component.
defineRule('required', (value: unknown) => required(value) || 'Required.');
const currencies = ['EUR', 'GBP', 'USD'];
const vatSchemes = ['Standard', 'Reverse charge', 'Exempt'];
const billing = reactive({
country: null as string | null,
vatScheme: null as string | null,
currency: 'USD' as string | null,
});
const currencyWarnings = computed(() =>
billing.currency === 'USD'
? ['This account is billed in EUR. USD invoices are converted at payment.']
: []
);
const status = ref('Nothing saved yet.');
/** Runs only once every rule passes. */
const onSubmit = () => {
const country = countries.find((entry) => entry.code === billing.country);
status.value = `Saved: ${country?.name}, ${billing.vatScheme ?? 'no VAT scheme'}, ${billing.currency}.`;
};
</script>
Errors make the field invalid; warnings keep it valid. Outside a form, rules do nothing: pass your own messages to errors, and compute them on the inactive event, not on blur. Add formless to keep a filter inside a form out of its data.
To require a choice on a select joined to a form, write rules="required", as the demo above does, or require the key in the form's schema. The rule blocks the submit, and the asterisk follows (Validated forms). The required prop there is a hint: it draws the asterisk and blocks nothing, for a form the server checks. Outside a form it also marks the field but blocks nothing, so pair it with your own errors.
Coming from v2
bitboss-ui/validated is gone: import BbSelect from bitboss-ui, and it
joins the enclosing BbForm by itself. validate-on is now validation-mode
(delete it for the old default) and vid is validation-label or name. An
empty selection omits the form field; v2 posted the string "null".
showChevron and #chevron are removed: use append:icon.
Multiple selection
Add multiple and seed the model as an array (any other initial shape throws); the placeholder shows while it is empty.
2 of 3 chosen
<template>
<div class="flex max-w-sm flex-col gap-2">
<BbSelect
id="pilot-hubs"
v-model="hubs"
clearable
description="Three hubs for the pilot route."
item-text="name"
item-value="id"
:items="cities"
label="Distribution hubs"
:max="3"
:max-selected-labels="2"
multiple
name="hubs"
:selected-labels-fn="(count) => `${count} hubs selected`"
/>
<p class="text-sm opacity-70">{{ hubs.length }} of 3 chosen</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { cities } from '~/demo-data';
// `multiple` needs an array from the very first render — a scalar or an
// unseeded ref throws at setup rather than guessing.
const hubs = ref<string[]>(['milan', 'berlin']);
</script>
max disables new choices at the limit while selected options remain removable. When the chips no longer fit, the whole row turns into a count such as “3 hubs selected”; max-selected-labels sets that point by number instead. Pick a third hub to see it.
comma prints the selection as text, with no per-value close buttons.
<template>
<div class="max-w-sm">
<!--
`comma` prints the selection as one string. There is no per-value close
button in this mode, so `clearable` — which empties everything — is the
only removal affordance left outside the panel.
-->
<BbSelect
id="digest-recipients"
v-model="recipients"
clearable
comma
hint="Sent every Monday at 08:00."
item-text="fullName"
item-value="id"
:items="analysts"
label="Weekly digest recipients"
multiple
name="recipients"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { users } from '~/demo-data';
const analysts = users.filter((user) => user.role === 'Analyst');
const recipients = ref<number[]>([7, 12]);
</script>
Use the chip slot when each selected value needs its own colour, avatar or remove control.
<template>
<div class="max-w-sm">
<BbSelect
id="issue-labels"
v-model="labels"
clearable
item-text="name"
item-value="name"
:items="labelItems"
label="Labels"
multiple
name="labels"
placeholder="Add labels"
>
<!--
`chip` replaces the default badge for each selection. `item` is the
row from `items` (that is where the colour lives) and `deselect`
drops just this value.
-->
<template #chip="{ item, text, disabled, deselect }">
<BbBadge
:clearable="!disabled"
:style="{
'--bg': `color-mix(in oklab, ${item.color} 15%, transparent)`,
'--fg': 'var(--bb-text)',
}"
@click:clear="deselect"
>
<span
class="inline-block size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
{{ text }}
</BbBadge>
</template>
<!-- The same dot in the rows, so the colour is there while picking. -->
<template #option="{ item, text }">
<span class="flex items-center gap-2 text-sm">
<span
class="inline-block size-2 shrink-0 rounded-full"
:style="{ background: item.color }"
/>
{{ text }}
</span>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbSelect } from 'bitboss-ui';
import { tags } from '~/demo-data';
const PALETTE = [
'#dc2626',
'#2563eb',
'#7c3aed',
'#16a34a',
'#d97706',
'#0d9488',
];
// A colour joined onto each label by position — fixed data in, fixed data out.
const labelItems = tags.slice(0, 6).map((name, index) => ({
name,
color: PALETTE[index % PALETTE.length]!,
}));
const labels = ref<string[]>(['accessibility', 'performance']);
</script>
Search and new options
Search matches the display text. Set filter-by to search other fields instead; every listed path must exist on every item.
<template>
<div class="max-w-sm">
<!--
The panel searches `name` and `country` instead of the display text, so
typing "portugal" finds Lisbon and Porto. Every listed path must exist
on every item, which is why `country` is joined on below rather than
read straight from the city row.
-->
<BbSelect
id="shipping-destination"
v-model="destination"
:filter-by="['name', 'country']"
hint="Search by city or by country."
item-text="name"
item-value="id"
empty-text="No city or country matches that search."
:items="destinations"
label="Destination"
name="destination"
persistent-hint
placeholder="Search a destination"
>
<!-- Show the matched field, so a hit on "portugal" is explained. -->
<template #option="{ item, text }">
<span class="flex w-full items-center justify-between gap-2">
<span class="truncate">{{ text }}</span>
<span class="shrink-0 text-xs opacity-60">{{ item.country }}</span>
</span>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { cities, countryByCode } from '~/demo-data';
const destinations = cities.map((city) => ({
...city,
country: countryByCode[city.countryCode]?.name ?? '',
}));
const destination = ref<string | null>(null);
</script>
An array of fewer than six options opens without a search field, unless you listen to option:add; a provider always keeps it. disable-writing hides it on any list, or on one platform only ('mobile').
Listen to option:add to let people create a value: Enter on a query with no highlighted option emits the trimmed text. Create the record, append it to items and select it in your handler, and ignore an empty string.
<template>
<div class="max-w-sm">
<!--
`option:add` fires when Enter is pressed with no option highlighted. The
component creates nothing: appending to `items` and selecting the new
value is this handler's job.
-->
<BbSelect
id="article-tags"
v-model="selected"
clearable
item-text="name"
item-value="id"
:items="available"
label="Tags"
multiple
name="tags"
empty-text="No matching tag. Press Enter to create it."
placeholder="Search or invent a tag"
@option:add="createTag"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { tags } from '~/demo-data';
type Tag = { id: string; name: string };
const toId = (name: string) => name.toLowerCase().replace(/\s+/g, '-');
const available = ref<Tag[]>(tags.map((name) => ({ id: toId(name), name })));
const selected = ref<string[]>(['accessibility']);
const createTag = (text: string) => {
// The query can be empty — Enter on an empty search field still fires.
const name = text.trim();
if (!name) return;
const id = toId(name);
if (!available.value.some((tag) => tag.id === id)) {
available.value = [...available.value, { id, name }];
}
if (!selected.value.includes(id)) {
selected.value = [...selected.value, id];
}
};
</script>
Coming from v2allowWriting → disableWriting
filter-by accepts string[] or false (no function, no bare string). disable-writing replaces and inverts
allow-writing; it also disables filtering and option:add.
Fetching options
Pass a provider to items for large lists. It receives
one object, { query, reason, modelValue, signal }; on the calls the component makes by itself (every reason but 'search'),
return the selected rows too, so their labels stay resolvable.
<template>
<div class="max-w-sm">
<BbSelect
id="pr-reviewers"
v-model="reviewers"
clearable
hint="Type at least two letters to search the directory."
item-text="fullName"
item-value="id"
:items="searchMembers"
label="Reviewers"
loading-text="Searching the directory…"
multiple
name="reviewers"
empty-text="Nobody matches that name or email."
:filter-by="false"
persistent-hint
placeholder="Add reviewers"
:prefill="false"
:pt:input="{ attrs: { placeholder: 'Search by name or email' } }"
:query-debounce-time="300"
stash
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { BbSelectItemsContext } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { delay, matches, users } from '~/demo-data';
// One reviewer is already saved, so the component loads on mount to show the chip.
const reviewers = ref<number[]>([3]);
// Stands in for `api.members.search(...)`. The component owns the debounce, the
// loading flag and the discarding of out-of-order responses; none of that is
// rebuilt here.
const searchMembers = ({
query,
reason,
modelValue,
}: BbSelectItemsContext<number[]>): Promise<User[]> => {
// A call the component makes by itself: resolve the saved reviewers.
if (reason !== 'search') {
return delay(
users.filter((user) => modelValue.includes(user.id)),
450
);
}
const trimmed = query.trim();
if (trimmed.length < 2) return Promise.resolve([]);
// The server matches the email too, which the row does not show:
// `:filter-by="false"` keeps the select from filtering the answer again.
// `stash` keeps picked members resolvable when a later search leaves them
// out, so this branch only answers the current query.
return delay(
users.filter(
(user) => matches(user.fullName, trimmed) || matches(user.email, trimmed)
),
450
);
};
</script>
The component debounces typing, discards stale responses and shows its own loading state. By default it still filters the rows you return by the typed text; a server that matches the query itself (on the email above, or without accents) needs :filter-by="false", so its answer is the list. Then apply the query on every call, not only on 'search'.
Set empty-text for the no-results line. Use stash only when selections must survive later result sets.
Coming from v2
prefill="focus" is now 'interaction'. prefill: false now means
search-first; remove it to load on first open. no-data-text and #no-data
are empty-text and #empty. Coherence also runs on the first load in v3.
null can represent an “All” option, but a provider then needs prefill: true
because null also means empty. Use a sentinel such as 'all' when clearing,
pruning and selecting everything must remain distinct.
Filtering by: All locations
<template>
<div class="flex max-w-sm flex-col gap-2">
<!--
`prefill` is what makes "All locations" visible. The model is `null`,
which the component also reads as "nothing selected", so without an
eager load there is nothing to resolve and the field falls back to its
placeholder while holding a real choice.
-->
<BbSelect
id="invoice-location"
v-model="location"
:items="loadLocations"
label="Office"
name="location"
placeholder="Select an office"
prefill
/>
<p class="text-sm opacity-70">
Filtering by:
<strong>{{ locationLabel }}</strong>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { cities, delay } from '~/demo-data';
type Location = { label: string; value: string | null };
// "Everything" is a real option with a `null` value, returned by the provider
// like any other row. The component never synthesises it.
const offices: Location[] = [
{ label: 'All locations', value: null },
...cities
.filter((city) => city.countryCode === 'IT')
.map((city) => ({ label: city.name, value: city.id })),
];
// `null` here means "All locations", not "empty".
const location = ref<string | null>(null);
const locationLabel = computed(
() => offices.find((office) => office.value === location.value)?.label
);
const loadLocations = (): Promise<Location[]> => delay(offices, 400);
</script>
Dependent fields
List upstream values in dependencies, then enable enforce-coherence to
remove a choice the refreshed options no longer contain.
Model: milan
<template>
<div class="flex max-w-sm flex-col gap-4">
<BbSelect
id="shipping-country"
v-model="country"
item-text="name"
item-value="code"
:items="shippingCountries"
label="Country"
name="country"
placeholder="Pick a country"
/>
<!--
`dependencies` re-runs the provider whenever the country changes;
`enforce-coherence` then drops a city the new list no longer contains.
No watcher, and no blind reset that would throw away a still-valid pick.
-->
<BbSelect
id="shipping-city"
v-model="city"
:dependencies="[country]"
:deps-debounce-time="150"
enforce-coherence
item-text="name"
item-value="id"
:items="loadCities"
label="City"
name="city"
empty-text="No cities for this country."
:placeholder="country ? 'Pick a city' : 'Choose a country first'"
/>
<p class="text-sm opacity-70">
Model: <code>{{ city ?? 'null' }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { City } from '~/demo-data';
import { cities, countries, delay } from '~/demo-data';
const shippingCountries = countries.filter(
(entry) => entry.region === 'Europe'
);
// Single-mode enforcement resolves to `null`, so both models tolerate it.
const country = ref<string | null>('IT');
const city = ref<string | null>('milan');
// Calls the component starts itself (`reason !== 'search'`) must keep the
// current selection resolvable — returning the whole pool for the country does
// that, so a still-valid city survives the reload.
const loadCities = (): Promise<City[]> =>
delay(
cities.filter((entry) => entry.countryCode === country.value),
400
);
</script>
Multiple mode keeps the choices that are still valid instead of clearing the whole array. Switch the project to Mobile app and watch Mei Lin's chip go.
<template>
<div class="flex max-w-sm flex-col gap-4">
<BbSelect
id="review-project"
v-model="project"
item-text="name"
item-value="id"
:items="projects"
label="Project"
name="project"
/>
<!--
In multiple mode enforcement emits the filtered array, so switching the
project keeps the reviewers the new one still allows and drops the rest.
Watch the chip row: Amara survives both projects, Mei Lin does not.
-->
<BbSelect
id="review-reviewers"
v-model="reviewers"
:dependencies="[project]"
:deps-debounce-time="150"
enforce-coherence
hint="Only people on the project's teams can review."
item-text="fullName"
item-value="id"
:items="loadReviewers"
label="Reviewers"
multiple
name="reviewers"
persistent-hint
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { Team, User } from '~/demo-data';
import { delay, users } from '~/demo-data';
type Project = { id: string; name: string; teams: Team[] };
const projects: Project[] = [
{ id: 'web', name: 'Web app', teams: ['Engineering', 'Product'] },
{ id: 'mobile', name: 'Mobile app', teams: ['Engineering', 'Design'] },
];
const project = ref<string>('web');
// Amara (Engineering) is on both projects; Mei Lin (Product) is only on the web
// one, so she is the entry enforcement prunes.
const reviewers = ref<number[]>([4, 19]);
const teamsFor = computed(
() => projects.find((entry) => entry.id === project.value)?.teams ?? []
);
const loadReviewers = (): Promise<User[]> =>
delay(
users.filter((user) => teamsFor.value.includes(user.team)),
400
);
</script>
With enforce-coherence, a provider that leaves the selection out of a call whose reason is not 'search' prunes it. stash does not stand in for it: enforcement checks the fetched rows alone. A provider that fails must throw, because returning [] counts as an empty result and prunes every pick.
Option rows and groups
Add a second line and an icon to each row with item-props: description, prepend:icon and append:icon, each a field path or a function of the item.
<template>
<div class="max-w-sm">
<!--
`item-props` names the extra fields of each row: the email becomes a
muted second line and the role picks the leading icon. Nothing is read
off the item unless it is named here.
-->
<BbSelect
id="project-owner"
v-model="owner"
:item-props="{ description: 'email', 'prepend:icon': roleIcon }"
item-text="fullName"
item-value="id"
:items="users"
label="Project owner"
name="owner"
placeholder="Choose an owner"
:selectable="(user: User) => user.active"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { User, UserRole } from '~/demo-data';
import { users } from '~/demo-data';
const ROLE_ICONS: Record<UserRole, string> = {
Owner: 'lucide:crown',
Admin: 'lucide:shield',
Developer: 'lucide:code',
Designer: 'lucide:pen-tool',
Analyst: 'lucide:chart-line',
Editor: 'lucide:pencil',
Viewer: 'lucide:eye',
};
const roleIcon = (user: User) => ROLE_ICONS[user.role];
const owner = ref<number | null>(2);
</script>
A description makes every row two lines tall and is matched by the search. None of them appears in the closed select or in the chips.
Use group-by and the group slot for sections.
Rows with no group (null or undefined) are listed first, before every
header, so they never read as part of the group above.
<template>
<div class="max-w-sm">
<BbSelect
id="handover-owner"
v-model="owner"
clearable
group-by="team"
:header-height="36"
item-text="fullName"
item-value="id"
:items="users"
label="Handover owner"
name="owner"
placeholder="Pick a teammate"
>
<!--
`group` styles the header only. `length` is the size of the group,
which is what makes a count possible without recomputing the buckets.
Headers are fixed-height like the rows, so this padded one needs
`header-height` raised from its 32px default to match: the number
drives the virtual scroll, so set it here, not in CSS.
-->
<template #group="{ text, length }">
<div
class="flex w-full items-center justify-between py-2 text-xs font-semibold uppercase opacity-60"
>
<span>{{ text }}</span>
<span>{{ length }}</span>
</div>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { users } from '~/demo-data';
// `group-by` is a field path on the item, so the buckets follow the data. The
// list virtualises whole groups: many small groups window well, one enormous
// group does not.
const owner = ref<number | null>(null);
</script>
Use the option slot for anything richer. Match taller markup with item-height and
header-height (heights set only in CSS break virtual scrolling).
<template>
<div class="max-w-sm">
<!--
Taller rows need `item-height` to match, because the list is
virtualised: the number drives both the paint and the scroll maths.
-->
<BbSelect
id="review-assignee"
v-model="assignee"
:item-height="44"
item-text="fullName"
item-value="id"
:items="engineers"
label="Assignee"
name="assignee"
placeholder="Choose a reviewer"
>
<!-- The closed field shows the same face as the row that was picked. -->
<template #prepend>
<BbAvatar
v-if="selected"
:alt="selected.fullName"
size="20"
:src="selected.avatar"
/>
</template>
<template #option="{ item, text }">
<span class="flex w-full items-center gap-2 text-sm">
<BbAvatar :alt="text" size="28" :src="item.avatar" />
<span class="min-w-0 flex-1 truncate">{{ text }}</span>
<span class="shrink-0 text-xs opacity-60">{{ item.jobTitle }}</span>
</span>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbAvatar, BbSelect } from 'bitboss-ui';
import { users } from '~/demo-data';
const engineers = users.filter((user) => user.team === 'Engineering');
// The selection check renders outside the slot, so a custom row keeps it —
// there is no need to draw a second one from `selected`.
const assignee = ref<number | null>(4);
const selected = computed(() =>
engineers.find((user) => user.id === assignee.value)
);
</script>
Grouped lists virtualize by group, so do not group a list whose single group would hold thousands of rows: leave it flat or split it.
When the same rows, accessors and option row repeat on many pages, make them a preset: one CustomerSelect instead of the same BbSelect everywhere.
Panel actions and the phone sheet
Use the footer slot for actions over the model, such as select all, clear and done.
<template>
<div class="max-w-sm">
<BbSelect
id="alert-channels"
v-model="channels"
:items="alertChannels"
label="Notify via"
:max-selected-labels="2"
multiple
name="channels"
>
<!--
Select-all is not a prop, because the model is yours: assigning
every value is the whole implementation. `clear` and `close` come
from the slot scope; the count comes from the model.
-->
<template #footer="{ clear, close }">
<div class="flex w-full items-center gap-1.5">
<BbButton size="xs" variant="ghost" @click="selectAll">
Select all
</BbButton>
<BbButton size="xs" variant="ghost" @click="clear">Clear</BbButton>
<BbButton class="ms-auto" size="xs" variant="primary" @click="close">
Done ({{ channels.length }})
</BbButton>
</div>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbSelect } from 'bitboss-ui';
type Channel = { value: string; label: string };
const alertChannels: Channel[] = [
{ value: 'email', label: 'Email' },
{ value: 'sms', label: 'SMS' },
{ value: 'push', label: 'Push notification' },
{ value: 'slack', label: 'Slack' },
{ value: 'pagerduty', label: 'PagerDuty' },
];
const channels = ref<string[]>(['email']);
const selectAll = () => {
channels.value = alertChannels.map((channel) => channel.value);
};
</script>
Use header for context above search. Derive summaries from your model rather
than selectedOptions, which is empty until provider data loads.
<template>
<div class="max-w-sm">
<BbSelect
id="itinerary-stops"
v-model="stops"
item-text="name"
item-value="id"
:items="europeanCities"
label="Itinerary"
:max-selected-labels="2"
multiple
name="stops"
placeholder="Add a city"
>
<!--
`header` pins content above the search field. The summary is
computed from our own model rather than from `selectedOptions`, so
it reads the same whether or not the panel has ever been opened.
-->
<template #header="{ clear }">
<div class="flex w-full items-center justify-between gap-2 text-xs">
<span class="opacity-70">{{ summary }}</span>
<BbButton
:disabled="stops.length === 0"
size="xs"
variant="ghost"
@click="clear"
>
Reset
</BbButton>
</div>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbSelect } from 'bitboss-ui';
import { cities, countryByCode } from '~/demo-data';
const europeanCities = cities.filter(
(city) => countryByCode[city.countryCode]?.region === 'Europe'
);
const stops = ref<string[]>(['lisbon', 'porto']);
const summary = computed(() => {
if (stops.value.length === 0) return 'No stops yet';
const chosen = europeanCities.filter((city) => stops.value.includes(city.id));
const countries = new Set(chosen.map((city) => city.countryCode));
const stopsLabel = chosen.length === 1 ? 'stop' : 'stops';
const countriesLabel = countries.size === 1 ? 'country' : 'countries';
return `${chosen.length} ${stopsLabel} in ${countries.size} ${countriesLabel}`;
});
</script>
On a phone the panel opens as a bottom sheet, with the same header and footer; here disable-writing="mobile" drops the search on the phone only. Narrow the window to see it.
<template>
<div class="max-w-sm">
<!--
Narrow the window below the mobile breakpoint and the same panel opens
as a bottom sheet, with the same options and the same footer. No
`adaptive` here: the plugin's `config.adaptive` already turns it on.
`disable-writing="mobile"` drops the search field on the phone only.
-->
<BbSelect
id="order-status"
v-model="status"
disable-writing="mobile"
:items="statusOptions"
label="Order status"
name="status"
:off-canvas-props="{ draggable: true, size: 'sm' }"
placeholder="Any status"
>
<template #footer="{ close }">
<BbButton block size="sm" variant="primary" @click="close">
Done
</BbButton>
</template>
</BbSelect>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbSelect } from 'bitboss-ui';
import { orderStatusLabels, orderStatuses } from '~/demo-data';
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
const status = ref<string | null>('processing');
</script>
Set adaptive only to override the global choice. Use off-canvas-props for
sheet behaviour and pt:sheet for sheet-only styling.
Coming from v2
header and footer replace options:prepend, options:append and their
:outer variants. #prepend-outer and #append-outer are
#prepend:outer and #append:outer.
Sizing and styling
The select fills its container, and the panel takes the field's width. compact lowers the field and the rows; layout puts the label beside the field.
Regional settings
<template>
<section class="flex max-w-md flex-col gap-3">
<h3 class="m-0 text-sm font-semibold">Regional settings</h3>
<!--
A settings list: `compact` for the dense height, `layout` puts the
label beside the field at a 3:4 ratio, wide enough for "Week starts
on" on a phone. The panel always takes the width of the field.
-->
<BbSelect
id="regional-language"
v-model="language"
compact
:items="languages"
label="Language"
layout="xxx xxxx"
name="language"
/>
<BbSelect
id="regional-time-zone"
v-model="timeZone"
compact
item-value="id"
:items="timeZones"
label="Time zone"
layout="xxx xxxx"
name="timeZone"
/>
<BbSelect
id="regional-week-start"
v-model="weekStart"
compact
:items="['Monday', 'Saturday', 'Sunday']"
label="Week starts on"
layout="xxx xxxx"
name="weekStart"
/>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { languages } from '~/demo-data';
// People read the city and the offset; the IANA id is what gets saved.
const timeZones = [
{ id: 'Europe/Rome', label: 'Rome (CET)' },
{ id: 'Europe/London', label: 'London (GMT)' },
{ id: 'Europe/Lisbon', label: 'Lisbon (WET)' },
{ id: 'America/New_York', label: 'New York (EST)' },
{ id: 'America/Sao_Paulo', label: 'São Paulo (BRT)' },
{ id: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ id: 'Australia/Sydney', label: 'Sydney (AEST)' },
];
const language = ref<string | null>('Italian');
const timeZone = ref<string | null>('Europe/Rome');
const weekStart = ref<string | null>('Monday');
</script>
layout is ignored when label-mode is floating or inside.
Pick a variant to match the surface: outline for forms, secondary
to sit back on a busy panel, ghost for inline edits in a table cell.
| Marta Villoresi | ItalianItalian |
|---|---|
| Lukas Brandt | EnglishEnglish |
<template>
<div class="flex max-w-xl flex-col gap-5">
<!-- `outline`, the default: a bordered field in a form. -->
<BbSelect
id="variant-outline"
v-model="status"
class="max-w-xs"
:items="statusOptions"
label="Order status"
name="orderStatus"
/>
<!-- `secondary`: a soft tinted box that sits back in a busy toolbar. -->
<div class="flex items-center gap-3 border-y border-(--bb-border) py-2">
<span class="text-sm opacity-70">24 open tickets</span>
<BbSelect
id="variant-secondary"
v-model="priority"
class="ms-auto w-40"
compact
hide-label
:items="priorities"
label="Priority"
name="priorityFilter"
variant="secondary"
/>
</div>
<!-- `ghost`: no border in any state, so the cell reads as text. -->
<table class="w-full text-sm">
<caption class="sr-only">Team languages</caption>
<tbody>
<tr v-for="member in members" :key="member.id" class="border-b border-(--bb-border)">
<th class="py-1 text-start font-normal" scope="row">{{ member.fullName }}</th>
<td class="w-44 py-1">
<BbSelect
:id="`member-${member.id}-language`"
v-model="spoken[member.id]"
compact
hide-label
:items="languages"
:label="`Language for ${member.fullName}`"
:name="`language-${member.id}`"
variant="ghost"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import {
languages,
orderStatusLabels,
orderStatuses,
priorities,
users,
} from '~/demo-data';
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
const members = users.slice(0, 2);
const status = ref<string | null>('processing');
const priority = ref<string | null>('High');
const spoken = ref<Record<number, string | null>>({
[members[0]!.id]: 'Italian',
[members[1]!.id]: 'English',
});
</script>
variant="none" is a blank field you style yourself, focus ring included, like the flat search field on BbTextInput.
For a one-off, reach a part with pt: pt:activator is the button that opens the list, pt:input the search field in the panel, pt:badge each chip. Chip and option parts also take a function of the item. On a phone, the sheet's own chip row does not apply pt:badge.
<template>
<div class="max-w-sm">
<!--
`pt:activator` is the button that opens the list, and the place for a
test id. `pt:input` is the search field inside the panel, `pt:badge`
each chip. A function on `pt:badge` receives that chip's row, so a
member who has left can be flagged without replacing the chip through
the `chip` slot.
-->
<BbSelect
id="release-approvers"
v-model="approvers"
hint="Struck-through approvers have left the team."
item-text="fullName"
item-value="id"
:items="engineers"
label="Release approvers"
multiple
name="approvers"
persistent-hint
:pt:activator="{ attrs: { 'data-test': 'release-approvers' } }"
:pt:badge="({ item }) => !item.active && 'line-through opacity-70'"
:pt:input="{ attrs: { placeholder: 'Search engineers' } }"
:selectable="(user: User) => user.active"
/>
</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 engineers = users.filter((user) => user.team === 'Engineering');
// Tomas Delgado (11) was deactivated after he was saved as an approver: the
// value stays, the option is no longer selectable.
const approvers = ref<number[]>([2, 11]);
</script>
pt:panel, pt:option, pt:header and pt:footer style the options surface, its rows and the bands around the header and footer slots (on a phone, the sheet's own).
Coming from v2
pt:input on BbSelect used to be the field's button; that part is now
pt:activator, and pt:input is the search field. Option classes moved from
autocomplete-option* to bb-listbox__option*, .bb-select__chip is
.bb-select__badge and .bb-select__comma-item is .bb-select__comma. --bb-select-option-px and
--bb-select-option-py are removed: set row heights with props, not CSS.