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
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
The field props follow BbTextInput. This page
focuses on option data, search and selection.
Pick from application data
Pass domain objects unchanged, then set item-text and item-value; this keeps
the raw object available to slots while the model stores a stable id.
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>
Use selectable to disable individual options. An item-level disabled field
is inert in v3, and duplicate resolved values are silently dropped.
Smallest useful select
For primitive items, a 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 combobox's accessible
name. Add clearable when the value may be emptied.
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
Use the shared field props for guidance, required state and a clear action.
<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%."
item-text="label"
item-value="value"
:items="plans"
label="Workspace plan"
name="plan"
persistent-hint
placeholder="Choose a plan"
prepend:icon="lucide:credit-card"
required
/>
</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>
Errors make the field invalid; warnings keep it valid. Validate on inactive,
not blur, because focus moves into the options panel during selection.
<template>
<div class="flex max-w-sm flex-col gap-4">
<!--
Validated on `inactive`, not on `blur`: focus legitimately moves into
the panel while the user is picking, and `blur` fires there.
-->
<BbSelect
id="invoice-country"
v-model="country"
:errors="countryErrors"
item-text="name"
item-value="code"
:items="countries"
label="Billing country"
name="country"
placeholder="Required for invoicing"
required
@inactive="validateCountry"
@update:model-value="countryErrors = []"
/>
<!-- Accepted, but worth a second look: amber, and no aria-invalid. -->
<BbSelect
id="invoice-currency"
v-model="currency"
:items="currencies"
label="Invoice currency"
name="currency"
:warnings="currencyWarnings"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { countries } from '~/demo-data';
const currencies = ['EUR', 'GBP', 'USD'];
const country = ref<string | null>(null);
const currency = ref<string | null>('USD');
// Seeded empty, so the message appears the first time focus leaves the field
// and disappears as soon as a country is picked.
const countryErrors = ref<string[]>([]);
const validateCountry = () => {
countryErrors.value = country.value ? [] : ['Pick a country to continue.'];
};
const currencyWarnings = computed(() =>
currency.value === 'USD'
? ['This account is billed in EUR. USD invoices are converted at payment.']
: []
);
</script>
name adds hidden form inputs. required marks the field but does not invoke
native browser validation, so render required errors through your validation
layer.
Coming from v2
An empty v3 selection omits the form field. v2 posted the string "null" in
single mode. showChevron and #chevron are removed; use append:icon.
Multiple selection
Add multiple and seed the model as an array; any other initial shape throws.
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.
Use max-selected-labels for a predictable summary.
<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>
comma removes per-value close buttons. Use the chip slot when each selected
value needs its own color, 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)`,
'--color': 'var(--bb-text)',
}"
@click:clear="deselect"
>
<span
class="size-2 rounded-full"
:style="{ background: item.color }"
/>
{{ text }}
</BbBadge>
</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 filtering
Search matches display text by default. Set filter-by to search other fields;
those paths replace the text match and 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"
:items="destinations"
label="Destination"
name="destination"
persistent-hint
placeholder="Search a destination"
/>
</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>
Provider results pass through the same local substring filter, so fuzzy server matches can disappear. Keep both matchers compatible.
Coming from v2allowWriting → disableWriting
filter-by now accepts only string[]. disable-writing replaces and inverts
allow-writing; it also disables filtering and option:add. Short local arrays
hide search automatically under autoDisableWritingThreshold (default 6).
Fetching options
Pass a provider to items for large lists. It receives
(query, prefill, modelValue); on component-initiated calls, return the selected
rows so their labels remain 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"
no-data-text="Nobody matches that name."
persistent-hint
:prefill="false"
:query-debounce-time="300"
stash
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import type { User } from '~/demo-data';
import { delay, matches, users } from '~/demo-data';
const reviewers = ref<number[]>([]);
// 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. `stash` keeps picked members resolvable across later searches,
// so the provider only ever answers the current query.
const searchMembers = (query: string): Promise<User[]> => {
const trimmed = query.trim();
if (trimmed.length < 2) return Promise.resolve([]);
return delay(
users.filter((user) => matches(user.fullName, trimmed)),
450
);
};
</script>
The component debounces typing, discards stale responses and owns fetch loading.
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. 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"
item-text="label"
item-value="value"
:items="loadLocations"
label="Office"
name="location"
placeholder="Select an office"
prefill
/>
<p class="text-sm opacity-70">
Filtering by:
<strong>{{ location === null ? 'All locations' : location }}</strong>
</p>
</div>
</template>
<script setup lang="ts">
import { 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 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"
no-data-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');
// Component-initiated calls arrive with `prefill === true` and 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 preserves still-valid choices instead of clearing the whole array.
<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>
On prefill === true, providers must return the current selection. An empty
response can prune a valid model; stash avoids that but can also keep stale
values coherent.
Panel actions
Use footer 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"
item-text="label"
item-value="value"
: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.
-->
<template #footer="{ clear, close, selectedOptions }">
<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 ({{ selectedOptions.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));
return `${chosen.length} stops across ${countries.size} countries`;
});
</script>
Coming from v2
header and footer replace options:prepend, options:append and their
:outer variants.
Creating options
option:add reports the trimmed query; create the record, append it to items
and select it in your handler. Guard the 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"
no-data-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>
Custom option rows
Use group-by and the group slot for sections.
<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.
-->
<template #group="{ text, length }">
<div
class="flex w-full items-center justify-between 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 option for richer rows. Match taller markup with item-height and
header-height; CSS-only heights 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"
>
<template #option="{ item, text }">
<span class="flex w-full items-center gap-2">
<BbAvatar :alt="text" size="24">{{ item.initials }}</BbAvatar>
<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 { 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);
</script>
Grouped lists virtualize by group, so keep very large groups flat or split them.
Mobile behavior
Below the configured breakpoint, the panel becomes a bottom sheet by default. The same options, search, header and footer remain available.
<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. The
search field is dropped on that surface only, because the list is short.
-->
<BbSelect
id="order-status"
v-model="status"
adaptive
disable-writing="mobile"
item-text="label"
item-value="value"
: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 behavior and pt:sheet for sheet-only styling.
Sizing and styling
compact changes control and row density. Size the component with its wrapper;
the panel follows the control width.
<template>
<div class="flex max-w-md flex-col gap-5">
<!-- Label beside the field, a quarter of the row wide, in the dense height. -->
<BbSelect
id="layout-team"
v-model="team"
compact
direction="xx xxxxxx"
:items="teams"
label="Team"
label-position="right"
name="layout-team"
/>
<!-- `reverse` swaps the two columns; the label keeps its own alignment. -->
<BbSelect
id="layout-role"
v-model="role"
compact
direction="xx xxxxxx"
:items="userRoles"
label="Role"
name="layout-role"
reverse
/>
<!-- A label inside the field forces the vertical layout: `direction` is ignored. -->
<BbSelect
id="layout-language"
v-model="language"
:items="languages"
label="Language"
label-mode="floating"
name="layout-language"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect } from 'bitboss-ui';
import { languages, teams, userRoles } from '~/demo-data';
const team = ref<string | null>('Design');
const role = ref<string | null>('Editor');
const language = ref<string | null>(null);
</script>
Use pt:panel for the visible options surface and pt:option for rows.
Coming from v2
Option classes moved from autocomplete-option* to bb-listbox__option*.
--bb-select-option-px and --bb-select-option-py are removed. Set row heights
with props, not CSS; the desktop panel remains inside the component subtree.