Use it for
Use BbSelectPopover when someone chooses a value from a trigger you design: a
status pill, filter chip, avatar stack or table cell. It provides the select
engine without drawing a form field.
Use something else when
BbSelect: you need a labelled field with guidance and validationBbRadioGroup: a short exclusive choice should remain visibleBbDropdown: the rows run commandsBbPopover: the panel contains arbitrary content instead of options
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
Item mapping, custom rows and option identity follow
BbSelect.
Build a reliable trigger
Spread the activator props onto the focusable element, give it an explicit
accessible name and derive its label from application state.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Assignee</span>
<BbSelectPopover
id="board-assignee"
v-model="assignee"
compact
item-text="fullName"
item-value="id"
:items="engineers"
:width="240"
>
<!--
`props` carries the popover reference and the open/close handlers, so
it has to land on the focusable element itself — here the badge's
button, not the badge around it. `shown` is how the trigger shows it
is expanded.
-->
<template #activator="{ props, shown }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:user"
size="lg"
:variant="shown ? 'secondary' : 'outline'"
>
<BbBadgeButton aria-label="Filter by assignee" v-bind="props">
{{ assigneeLabel }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import { users } from '~/demo-data';
const engineers = users.filter((user) => user.team === 'Engineering');
const assignee = ref<number | null>(null);
// The label is resolved from our own data, never from the slot's `text`. It is
// the same amount of code, and it stays correct the day `items` becomes a
// provider that has not run yet.
const assigneeLabel = computed(
() =>
engineers.find((user) => user.id === assignee.value)?.fullName ??
'Unassigned'
);
</script>
Do not label the trigger from slot text or selectedOptions: provider-backed
options are unresolved until they load, so the trigger can start blank. Reflect
shown visually when the panel is open.
Coming from v2
As on BbSelect, clicking the current single value now keeps it selected and
closes the panel. Use the slot clear callback for an explicit reset.
Multiple selection
Seed an array when using multiple, then summarize it on the trigger you own.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Filters</span>
<BbSelectPopover
id="board-reviewers"
v-model="reviewers"
compact
item-text="fullName"
item-value="id"
:items="reviewerPool"
:max="3"
multiple
:width="240"
>
<!--
There are no chips here: the trigger is yours, so summarising the
selection is yours too. A count is usually enough.
-->
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:users"
size="lg"
:variant="reviewers.length > 0 ? 'secondary' : 'outline'"
>
<BbBadgeButton aria-label="Filter by reviewer" v-bind="props">
Reviewers{{ reviewers.length > 0 ? ` · ${reviewers.length}` : '' }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import { users } from '~/demo-data';
const reviewerPool = users.filter((user) => user.role === 'Developer');
// `multiple` needs an array from the first render, exactly as on BbSelect.
const reviewers = ref<number[]>([4]);
</script>
max limits the count without locking selected options.
Panel actions
Use header to name the panel and footer for clear or done actions.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Destination</span>
<BbSelectPopover
id="trip-destination"
v-model="destination"
compact
item-text="name"
item-value="id"
:items="europeanCities"
no-data-text="No city matches that name."
:width="260"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:map-pin"
size="lg"
variant="outline"
>
<BbBadgeButton aria-label="Choose a destination" v-bind="props">
{{ destinationLabel }}
</BbBadgeButton>
</BbBadge>
</template>
<template #header>
<span class="text-sm font-medium">Popular destinations</span>
</template>
<!--
There is no field to hang a clear button on, so `clear` from the
panel scope is how a popover empties itself.
-->
<template #footer="{ clear, close }">
<div class="flex w-full items-center gap-2">
<BbButton class="me-auto" size="xs" variant="ghost" @click="clear">
Clear
</BbButton>
<BbButton size="xs" variant="primary" @click="close">Done</BbButton>
</div>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton, BbSelectPopover } from 'bitboss-ui';
import { cities, countryByCode } from '~/demo-data';
const europeanCities = cities.filter(
(city) => countryByCode[city.countryCode]?.region === 'Europe'
);
const destination = ref<string | null>('lisbon');
const destinationLabel = computed(
() =>
europeanCities.find((city) => city.id === destination.value)?.name ??
'Add destination'
);
</script>
The panel slots expose clear, close, focus, query and
selectedOptions. The clearable prop instead puts a clear button inside the
search field.
Coming from v2
header and footer replace options:prepend, options:append and their
:outer variants.
Search and short lists
Use disable-writing when a short list is faster to scan than search.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Status</span>
<!--
Six options do not need a search field. `disable-writing` removes it —
and with it the query, the local filter and `option:add`.
-->
<BbSelectPopover
id="order-status-pill"
v-model="status"
compact
disable-writing
item-text="label"
item-value="value"
:items="statusOptions"
:width="180"
>
<template #activator="{ props, shown }">
<BbBadge
append:icon="lucide:chevron-down"
size="lg"
:variant="shown ? 'secondary' : 'outline'"
>
<BbBadgeButton aria-label="Change order status" v-bind="props">
{{ statusLabel }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import { orderStatusLabels, orderStatuses } from '~/demo-data';
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
const status = ref<string | null>('processing');
const statusLabel = computed(
() =>
statusOptions.find((option) => option.value === status.value)?.label ??
'Set status'
);
</script>
Removing the field also removes filtering and option:add. Provider results
still pass through the local substring filter.
Coming from v2allowWriting → disableWriting
disable-writing replaces and inverts allow-writing. filter-by now accepts
only string[]; short local arrays also hide search automatically below the
configured threshold.
Fetching options
A provider receives (query, prefill, modelValue). Return selected rows during
component-initiated loads so values remain resolvable.
<template>
<div class="flex items-center gap-2">
<span class="text-xs font-medium opacity-60">Ship to</span>
<BbSelectPopover
id="shipping-city"
v-model="city"
compact
item-text="name"
item-value="id"
:items="searchCities"
loading-text="Searching…"
no-data-text="No city matches that name."
:query-debounce-time="300"
stash
:width="260"
>
<template #activator="{ props, loading }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:truck"
size="lg"
variant="outline"
>
<BbBadgeButton aria-label="Choose a shipping city" v-bind="props">
{{ cityLabel }}{{ loading ? ' …' : '' }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import type { City } from '~/demo-data';
import { cities, delay, matches } from '~/demo-data';
const city = ref<string | null>('milan');
// The pill reads from the same fixture the provider draws on, so it is right on
// first paint — before the provider has run even once.
const cityLabel = computed(
() => cities.find((entry) => entry.id === city.value)?.name ?? 'Pick a city'
);
// Stands in for `api.cities.search(...)`. Component-initiated calls arrive with
// `prefill === true`; the user typing arrives with `false`.
const searchCities = (query: string, prefill: boolean): Promise<City[]> => {
const trimmed = query.trim();
if (prefill || !trimmed) return delay(cities.slice(0, 8), 400);
return delay(
cities.filter((entry) => matches(entry.name, trimmed)),
400
);
};
</script>
Unlike BbSelect, a seeded model does not force an eager provider call. Keep the
trigger independent of loaded options instead of adding prefill: true just to
render its label.
Coming from v2
prefill="focus" becomes 'interaction'. prefill: false is search-first;
remove it to load on first open.
Dependent pickers
Use dependencies to refetch and enforce-coherence to remove values absent
from the refreshed options.
<template>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs font-medium opacity-60">Filters</span>
<BbSelectPopover
id="filter-team"
v-model="team"
compact
disable-writing
:items="filterTeams"
:width="180"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:folder"
size="lg"
variant="outline"
>
<BbBadgeButton aria-label="Filter by team" v-bind="props">
{{ team }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
<!--
No `prefill` prop anywhere: `enforce-coherence` over a seeded model has
to load to judge the value, so the first fetch happens on mount by
itself. That also means one request per instance — cheap for a filter
bar, expensive for fifty table rows.
-->
<BbSelectPopover
id="filter-owner"
v-model="owner"
compact
:dependencies="[team]"
:deps-debounce-time="150"
enforce-coherence
item-text="fullName"
item-value="id"
:items="loadOwners"
no-data-text="Nobody on this team matches."
:width="220"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
prepend:icon="lucide:user-check"
size="lg"
variant="outline"
>
<BbBadgeButton aria-label="Filter by owner" v-bind="props">
{{ ownerLabel }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbSelectPopover } from 'bitboss-ui';
import type { Team, User } from '~/demo-data';
import { delay, users } from '~/demo-data';
const filterTeams: Team[] = ['Engineering', 'Design', 'Support'];
const team = ref<Team>('Engineering');
const owner = ref<number | null>(4);
const ownerLabel = computed(
() => users.find((user) => user.id === owner.value)?.fullName ?? 'Owner'
);
const loadOwners = (): Promise<User[]> =>
delay(
users.filter((user) => user.team === team.value),
400
);
</script>
Enforcement validates the seeded model on its first load. Each provider-backed instance therefore makes its own request; an empty response can clear a valid selection.
One picker per table row
Resolve shared options once, pass the same array to every row and keep edits in a draft map keyed by row id.
| Reference | Status |
|---|---|
| ORD-2026-0417 | |
| ORD-2026-0418 | |
| ORD-2026-0421 | |
| ORD-2026-0426 |
<template>
<div class="flex flex-col gap-3">
<table class="w-full text-sm">
<caption class="sr-only">
Orders, with an editable status column
</caption>
<thead>
<tr class="text-left text-xs uppercase opacity-60">
<th class="py-1" scope="col">Reference</th>
<th class="py-1" scope="col">Status</th>
</tr>
</thead>
<tbody>
<tr v-for="order in rows" :key="order.id">
<td class="py-1 font-mono text-xs">{{ order.reference }}</td>
<td class="py-1">
<!--
The same `statusOptions` array for every row, resolved once
here: with a provider each row would own a request. The value
is bound down and committed up into `drafts` — the row object
is never written to, so a refetch cannot discard an edit.
-->
<BbSelectPopover
:id="`order-${order.id}-status`"
compact
disable-writing
item-text="label"
item-value="value"
:items="statusOptions"
:model-value="statusOf(order)"
:width="180"
@update:model-value="drafts[order.id] = $event as OrderStatus"
>
<template #activator="{ props }">
<BbBadge
append:icon="lucide:chevron-down"
:variant="draft(order) ? 'secondary' : 'outline'"
>
<BbBadgeButton
:aria-label="`Change status of ${order.reference}`"
v-bind="props"
>
{{ orderStatusLabels[statusOf(order)] }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
</td>
</tr>
</tbody>
</table>
<div class="flex items-center gap-2">
<BbButton :disabled="dirtyCount === 0" size="sm" @click="save">
Save{{ dirtyCount > 0 ? ` (${dirtyCount})` : '' }}
</BbButton>
<span class="text-xs opacity-60">
The draft map is the dirty set and the save payload.
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton, BbSelectPopover } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { clone, orderStatusLabels, orders, orderStatuses } from '~/demo-data';
const statusOptions = orderStatuses.map((value) => ({
value,
label: orderStatusLabels[value],
}));
const rows = ref<Order[]>(clone(orders.slice(0, 4)));
// Page-level, keyed by row id. Never `v-model="order.status"`.
const drafts = ref<Record<number, OrderStatus>>({});
const statusOf = (order: Order): OrderStatus =>
drafts.value[order.id] ?? order.status;
const draft = (order: Order): OrderStatus | undefined => drafts.value[order.id];
const dirtyCount = computed(() => Object.keys(drafts.value).length);
const save = () => {
rows.value = rows.value.map((order) => {
const next = drafts.value[order.id];
return next ? { ...order, status: next } : order;
});
drafts.value = {};
};
</script>
Give each picker its own model. One shared ref lets every instance judge and overwrite the same value; one provider per row makes request count follow row count.
Width, placement and mobile
Set a concrete width for compact triggers; avoid 'auto', which expands
against the viewport on this fixed-positioned panel.
<template>
<div class="flex flex-wrap items-center gap-3">
<!-- A chip trigger: 160px matches the labels, where the 200 default would float. -->
<BbSelectPopover
id="width-chip"
v-model="priority"
compact
disable-writing
:items="priorities"
:width="160"
>
<template #activator="{ props }">
<BbBadge append:icon="lucide:chevron-down" size="lg" variant="outline">
<BbBadgeButton aria-label="Set priority" v-bind="props">
{{ priority ?? 'Priority' }}
</BbBadgeButton>
</BbBadge>
</template>
</BbSelectPopover>
<!-- Long labels, opened upwards, and no flipping back down. -->
<BbSelectPopover
id="width-wide"
v-model="language"
disable-flip
:items="languages"
:offset="8"
placement="top-start"
:width="280"
>
<template #activator="{ props }">
<BbButton
append:icon="lucide:chevron-up"
aria-label="Choose an interface language"
size="sm"
v-bind="props"
variant="outline"
>
{{ language ?? 'Interface language' }}
</BbButton>
</template>
</BbSelectPopover>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbBadge, BbBadgeButton, BbButton, BbSelectPopover } from 'bitboss-ui';
import { languages, priorities } from '~/demo-data';
const priority = ref<string | null>('High');
const language = ref<string | null>(null);
</script>
Below the mobile breakpoint the panel becomes a sheet and placement props stop
applying. Style the visible surface with pt:panel and sheet-only corrections
with pt:sheet.
Coming from v2flip → disableFlip
Use disable-flip instead of flip. hide-arrow and arrow-padding are
removed because this panel has no arrow. offcanvasProps becomes
offCanvasProps.