Use it for
Reach for BbTable when you have rows of records with columns: an orders
listing, a members admin, an audit log. You declare the columns; the component
owns extraction, formatting, sorting indicators, selection controls, skeletons,
empty states, detail rows and keyboard navigation.
Use something else when
BbSelect,BbCheckboxGrouporBbRadioGroup— options are being picked inside a form; a selectable table is not a form fieldBbTree— the data is genuinely recursive: a file tree, nested categories. Flat rows with one level of detail stay hereBbSelectPopover— it is a picker that opens from a cellBbPagination— you are after the paging controls
Pass Through
Hover or tap a part to outline it. Toggles flip loading, errors and warnings when the component has them — only parts highlight.
It reports intent. Your application owns the rows. Sort, selection, page,
expansion and highlight arrive as v-models; apply them in a computed or send
them to your API.
Coming from v2
In v3 the table is a CSS grid of <div>s with ARIA roles, not a <table>.
The migration details are collected in the styling section, after
the first working application flow.
An async orders list
Start with the shape most application screens need: an API-backed order list with filtering and server ordering.
<template>
<div class="flex flex-col gap-3">
<BbSelect
id="order-status-filter"
v-model="status"
clearable
item-text="text"
item-value="value"
:items="statusOptions"
label="Status"
name="status"
placeholder="Every status"
/>
<BbTable
v-model:sort="sort"
caption="Orders, filtered by status"
:columns="columns"
:dependencies="[status, sort]"
:deps-debounce-time="200"
item-value="id"
:items="fetchOrders"
loading-text="Loading orders…"
no-data-text="No order has that status."
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect, BbTable } from 'bitboss-ui';
import type { BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { delay, orderStatusLabels, orders, orderStatuses, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference', sortable: true, width: 140 },
{
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
},
{
key: 'status',
label: 'Status',
sortable: true,
width: 120,
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{
key: 'total',
label: 'Total',
align: 'right',
sortable: true,
width: 120,
formatter: euro.format,
},
];
const statusOptions = orderStatuses.map((value) => ({
value,
text: orderStatusLabels[value],
}));
const status = ref<OrderStatus | null>('shipped');
const sort = ref<BbTableSortEntry[]>([['reference', 'desc']]);
const compareOrders = (left: Order, right: Order, key: string): number => {
const leftValue = left[key as keyof Order];
const rightValue = right[key as keyof Order];
if (typeof leftValue === 'number' && typeof rightValue === 'number') {
return leftValue - rightValue;
}
return String(leftValue).localeCompare(String(rightValue));
};
// Stands in for `api.orders.list({ status, orderBy: sort })`. Dependencies are
// the whole request wiring: no watcher, manual loading state, or race handling.
const fetchOrders = (): Promise<Order[]> =>
delay(
orders
.filter((order) => !status.value || order.status === status.value)
.toSorted((left, right) => {
for (const [key, direction] of sort.value) {
const comparison = compareOrders(left, right, key);
if (comparison !== 0) return direction === 'asc' ? comparison : -comparison;
}
return 0;
}),
700
);
</script>
The provider forwards status and sort to the backend. Listing both in
dependencies gives the table the request lifecycle: debounce, loading state,
refetch and out-of-order response protection. Do not add a parallel watcher.
Set item-value before adding row state. It is the stable key used by selection,
expansion and highlight. Without it, those models compare object references and
break after a refetch replaces the records.
caption names the table for assistive technology. It is visually hidden by
default; add display-caption when the page needs a visible title.
For a local array, the same contract is smaller:
<template>
<BbTable
caption="Workspace equipment catalogue"
:columns="columns"
item-value="id"
:items="catalogue"
/>
</template>
<script setup lang="ts">
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
// A column is `{ key, label }`. `key` pulls the cell out of the record, so the
// rows go in exactly as the API returned them — nothing is pre-mapped.
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'sku', label: 'SKU' },
{ key: 'category', label: 'Category' },
{ key: 'stock', label: 'Stock' },
];
const catalogue = products.slice(0, 6);
</script>
Every row in items mounts. A few hundred rows on a client-only screen is fine;
past roughly a thousand, either page it or add
virtual.
The column definition
Everything a cell needs is declared in the column object, not in markup — which is why most tables need no cell slots at all.
<template>
<BbTable
caption="Orders placed in June"
:columns="columns"
item-value="id"
:items="recentOrders"
/>
</template>
<script setup lang="ts">
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { orders, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const day = new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' });
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference', width: 140 },
{
// The row stores a foreign key; the formatter resolves it. Denormalising
// a customer name into every order would be data the table invented.
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
placeholder: 'Unknown customer',
},
{
key: 'placedAt',
label: 'Placed',
width: 130,
formatter: (value: string) => day.format(new Date(value)),
},
{
// A virtual column: `lines` is on the record but `key` names nothing the
// table can extract, so the formatter computes the cell from `item`.
key: 'lineCount',
label: 'Lines',
align: 'center',
width: 80,
formatter: (_content, _key, item: Order) => item.lines.length,
},
{ key: 'total', label: 'Total', align: 'right', width: 120, formatter: euro.format },
];
const recentOrders = orders.slice(0, 6);
</script>
key extracts the value, and dot paths reach nested fields
(customer.address.city). formatter(content, key, item) transforms it, and it
runs even when the extracted value is nullish — that is what makes a virtual
column work: give it a key that matches no field and compute the cell from
item. Pass formatOnNull: false when a formatter must only ever see real
values. placeholder fills the cell when the pipeline still ends nullish, and
align (left, center, right) sets the column's alignment; the table-level
align prop sets the default for all of them.
Two things are not columns: actions and select. The table renders both
itself: the selection column from the selectable prop, the actions column from
the #actions slot. Providing the slot is what creates the column; there is no
prop. Putting { key: 'actions' } in columns adds an ordinary data column
reading item.actions, so you get a stray empty cell next to the real one. Dev
builds warn.
Classes come from the same object. thClass styles the header cell, tdClass
the data cells, rowClass the whole row; tdClass and rowClass may be
functions of (content, key, item). The table-level thClass / tdClass /
rowClass / headerRowClass props apply everywhere, and a row accumulates the
table's classes plus every column's.
<template>
<BbTable
caption="Stock levels, with depleted rows flagged"
:columns="columns"
item-value="id"
:items="catalogue"
:row-class="rowClass"
/>
</template>
<script setup lang="ts">
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products, productStatusLabels } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'sku', label: 'SKU', width: 150 },
{
key: 'status',
label: 'Status',
width: 130,
formatter: (value: Product['status']) => productStatusLabels[value],
},
{ key: 'stock', label: 'On hand', align: 'right', width: 100 },
];
// `row-class` runs per record and lands on `.bb-table-data__row`. State-dependent
// styling belongs here, not in a cell slot that can only paint one column.
const rowClass = (product: Product) =>
product.stock === 0 ? 'bg-[color:var(--bb-muted)] opacity-70' : '';
const catalogue = products.filter((product) => product.stock < 20).slice(0, 7);
</script>
rowClass on a column is new in v3, and it collides. In v2 an unknown field
on a column object was inert, so applications parked their own data there — a
per-column width class named rowClass is the common one. ColumnClasses
already accepts a string, so a leftover rowClass: 'w-40' still type-checks and
is now merged onto the whole row instead of doing nothing. Grep your column
definitions before you upgrade; sortable has the same
problem, with a type error on top.
Column widths
Grid tracks are the width API. Numbers and numeric strings are pixels; values
with units pass through. Unspecified columns size to content and share the
remaining space. Use fixed to split that space equally. Declare width when
wrapping or proportions must stay predictable. The migration notes below cover
the sizing differences from v2.
Sortable columns
Mark a column sortable and bind v-model:sort. The model is an ordered array
of [columnKey, direction] entries, and the array order is the priority.
Sort model: [["category","asc"]]
<template>
<div class="flex flex-col gap-2">
<BbTable
v-model:sort="sort"
caption="Catalogue, sortable by product, category and price"
:columns="columns"
item-value="id"
:items="sorted"
/>
<p class="text-sm opacity-70">
Sort model: <code>{{ JSON.stringify(sort) }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product', sortable: true },
{ key: 'category', label: 'Category', sortable: true, width: 150 },
{
key: 'price',
label: 'Price',
align: 'right',
sortable: true,
width: 120,
formatter: euro.format,
},
];
// Seeded, so the table opens pre-sorted. The column-level `sorted` field would
// only set `aria-sort`; the model is what moves the indicator.
const sort = ref<BbTableSortEntry[]>([['category', 'asc']]);
const catalogue = products.slice(0, 8);
// The table reports the model and nothing else. Applying it is this code's job —
// here client-side, on a server-driven table by forwarding it as `orderBy`.
const sorted = computed(() => {
if (sort.value.length === 0) return catalogue;
return [...catalogue].sort((left, right) => {
for (const [key, direction] of sort.value) {
const a = left[key as keyof Product];
const b = right[key as keyof Product];
const compared =
typeof a === 'number' && typeof b === 'number'
? a - b
: String(a).localeCompare(String(b));
if (compared !== 0) return direction === 'asc' ? compared : -compared;
}
return 0;
});
});
</script>
Clicking anywhere on a sortable header — its whitespace included — cycles that column unsorted → ascending → descending → removed. A direction flip updates the entry in place, so a column keeps its priority in a multi-column sort and no other entry is touched. The indicator is one arrow: nothing at rest, faint while the header is hovered or focused, solid once the column is sorted.
The table reports the model and stops there. Apply it yourself — with a
computed over your array, as above, or by forwarding it to the server as
orderBy and refetching through dependencies. Nothing sorts
behind your back, which is also why the extra sort button described below is
visible rather than dangerous.
For assistive technology, a sortable header renders a real <button> and its
cell carries aria-sort (none, ascending or descending). A custom
#header:<key> slot replaces that button, so keep sorting reachable by
rendering your own button and calling the scope's toggleSort; to merely add
something next to the control, use the affix slots instead. The
column-level sorted field only seeds aria-sort when no live model entry
exists — to open pre-sorted, seed v-model:sort, as the demo does.
sortable is new on the column type in v3, and it breaks in two directions at
once. v2 had no such field, so an application that parked a backend sort key
there — type Column = BbTableColumn<Order> & { sortable?: string } — now
intersects boolean & string, which is never; every column literal fails with
a message that never mentions BbTableColumn. And the table coerces the field
(!!column.sortable), so a sort-key string is truthy and turns the built-in
sort UI on: an application with its own sortable headers draws two.
- type Column = BbTableColumn<Order> & { sortable?: string };
+ type Column = BbTableColumn<Order> & { sortKey?: string };
Omit<BbTableColumn<Order>, 'sortable'> silences the compiler and keeps the
second header. Rename the field, or strip it at the boundary
(columns.map(({ sortable, ...rest }) => rest)).
Selecting rows
Add selectable and bind v-model. Multiple is the default, and in multiple
mode the model must already be an array — the table throws otherwise.
Selected ids: none
<template>
<div class="flex flex-col gap-2">
<BbTable
v-model="selected"
v-model:select-all="allPages"
v-model:unselected-items="excluded"
caption="Catalogue"
:columns="columns"
item-value="id"
:items="catalogue"
legend="Select products to add to a purchase order"
name="productIds"
select-all-label="Select every product"
select-text="Select"
selectable
/>
<p class="text-sm opacity-70">
<template v-if="allPages">
Everything is selected, except {{ excluded.length }} row(s). Post
<code>{ all: true, except: [{{ excluded.join(', ') }}] }</code>.
</template>
<template v-else>
Selected ids: <code>{{ selected.length ? selected.join(', ') : 'none' }}</code>
</template>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'stock', label: 'On hand', align: 'right', width: 100 },
];
// `item-value="id"` is what puts ids in the model instead of whole rows, and
// what lets the selection survive a refetch that replaced every object.
const selected = ref<number[]>([]);
// The "all across pages" pair. In all-mode `selected` stays empty on purpose —
// read these two, never infer "everything is selected" from the model.
const allPages = ref(false);
const excluded = ref<number[]>([]);
const catalogue = products.slice(0, 6);
</script>
A selectable table renders as a <fieldset>, so it needs an accessible name.
legend is that name; it falls back to caption, then to a localized generic
string, so the grouping is never nameless — but a legend that says what the
selection is for beats both fallbacks. Each row's control carries a visually
hidden label built from the row's cell contents, prefixed with the localized
"select" word; select-text changes that word and accessible-label replaces
the whole label with your own function of (columns, item). The cells arrive in
render order, so look them up by key, never by position.
With name set, every selected value renders a hidden input and the table
submits inside a plain <form>. selectable also accepts a predicate
(item) => boolean to gate individual rows, and max caps the count — at the
cap the unselected checkboxes disable, so a user can always trade one choice for
another. item:selected and item:unselected fire per toggle with the row's
value; keep v-model as the state and use the events for side effects.
Select-all is designed for "everything across every page". The header
checkbox is bound with v-model:select-all; while it is on, every row reads as
selected and unchecking one adds its value to v-model:unselected-items, so
your bulk endpoint receives { all: true, except: [...] } rather than thousands
of ids. Two consequences follow, and both bite quietly. Turning select-all on
does not re-emit update:modelValue when the model is already empty, so never
infer "everything is selected" from modelValue — read select-all and
unselected-items. And the hidden inputs serialize modelValue only, so
all-mode posts nothing at all through a native form; submit it through a handler
instead.
In v3 the prop that hides the header checkbox inverted: v2's
allow-select-all (default true) is now disable-select-all (default
false). The old name lands in $attrs and does nothing, so this is silent.
- <BbTable selectable :allow-select-all="false" … />
+ <BbTable selectable disable-select-all … />
:multiple="false" renders radios and the model becomes a single value or
null:
Model: null
<template>
<div class="flex flex-col gap-2">
<BbTable
v-model="replacement"
caption="Replacement candidates"
:columns="columns"
item-value="id"
:items="candidates"
legend="Choose the replacement product"
:multiple="false"
select-text="Choose"
selectable
/>
<p class="text-sm opacity-70">
Model: <code>{{ replacement ?? 'null' }}</code>
</p>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'category', label: 'Category', width: 150 },
{
key: 'price',
label: 'Price',
align: 'right',
width: 120,
formatter: euro.format,
},
];
// `:multiple="false"` renders radios and the model holds one value, or `null`.
// `select-text` is the visually hidden word each radio's label starts with.
const replacement = ref<number | null>(null);
const candidates = products.filter((product) => product.category === 'Displays');
</script>
Selection tracked by item-value survives a refetch, but stale ids do not prune
themselves. enforce-coherence drops values whose row is no longer in the result
set after every load — see Server data and pages for the one case
where you must leave it off.
Loading and empty
loading means two different things and the table tells them apart.
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap gap-2">
<BbButton :disabled="loading" size="sm" variant="outline" @click="firstLoad">
First load (no rows yet)
</BbButton>
<BbButton :disabled="loading" size="sm" variant="outline" @click="refetch">
Refetch (rows on screen)
</BbButton>
</div>
<BbTable
caption="Catalogue"
:columns="columns"
item-value="id"
:items="rows"
:loading="loading"
loading-text="Loading the catalogue…"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { delay, products } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product', skeleton: 'text' },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'stock', label: 'On hand', align: 'right', width: 100 },
];
const catalogue = products.slice(0, 5);
const rows = ref<Product[]>([...catalogue]);
const loading = ref(false);
// Nothing to keep on screen: the table draws skeleton rows.
const firstLoad = async () => {
rows.value = [];
loading.value = true;
rows.value = await delay([...catalogue], 1600);
loading.value = false;
};
// Rows already on screen: they stay, dimmed and `inert`, under a progress bar.
// Try clicking a row while it runs — the body does not respond, on purpose.
const refetch = async () => {
loading.value = true;
await delay(null, 1600);
loading.value = false;
};
</script>
With nothing to show yet — a first load, or a filter that emptied the table —
it draws skeleton rows, between four and ten of them, shaped per column by the
skeleton field (text, avatar, avatar-text, image, badge). With rows
already on screen — a refetch, a poll, a save-then-reload — the rows stay put,
dimmed, under an indeterminate bar along the header. Blanking a populated table
on every refetch loses the reader's place, so it does not happen any more. That
is a v3 behaviour change: v2 replaced the rows with skeletons every time.
While loading, the header and the rows are inert. A refetch is about to
replace the rows, so editing, selecting or sorting them races the incoming data.
interactive-while-loading is the escape hatch for a table that must stay usable
during a background refresh. The loading announcement is never inert, so the busy
state is always conveyed: aria-busy goes on the table and loading-text is
announced through a polite live region. It is sr-only — never painted, the
skeleton carries the visual signal — and it has a localized default, so omitting
it still announces. Set it when a specific message beats the generic one.
The #loading slot replaces the skeleton, so it follows the skeleton's rule:
in v3 it renders on the first load only, never on a refetch. If you used it in v2
to style refetches, that role is gone — the built-in dim and progress bar covers
them now.
When the result is empty, the table shows its no-data row. no-data-text
replaces the localized default text; #no-data replaces the whole content.
<template>
<div class="flex flex-col gap-3">
<BbTextInput
id="catalogue-filter"
v-model="query"
label="Filter the catalogue"
name="q"
placeholder="Try monitor, or keyboard"
/>
<BbTable
caption="Catalogue, filtered"
:columns="columns"
item-value="id"
:items="filtered"
>
<!--
The slot fills the table's own full-width cell, already centred
and already spanning every column. Pass content, never a row.
-->
<template #no-data>
<div class="flex flex-col items-center gap-2 py-4">
<p class="m-0 text-sm">No product matches "{{ query }}".</p>
<BbButton size="sm" variant="outline" @click="query = ''">
Clear the filter
</BbButton>
</div>
</template>
</BbTable>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbButton, BbTable, BbTextInput } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { matches, products } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'category', label: 'Category', width: 160 },
{ key: 'stock', label: 'On hand', align: 'right', width: 100 },
];
const query = ref('bookshelf');
const filtered = computed(() =>
products.filter((product) => matches(product.name, query.value)).slice(0, 6)
);
</script>
That slot fills the table's own full-width cell — already centred, already
spanning every column with an aria-colspan that counts the selection and
actions columns. Pass content, not a row. In v2 the slot replaced the empty-state
<tr>, so anything that was not a row rendered loose and left-aligned; that same
content now centres correctly, and a hand-rolled <tr> / <td> must be deleted
rather than kept.
There is no error-text prop and no table-level error state. A failed server
visit is a full error page; a rejected items provider is logged, the previous
rows are kept, and the failure is yours to surface — through #no-data if the
table should say why it is empty.
Server data and pages
Set items to a function and the table calls it, tracks the loading state and
discards out-of-order responses.
<template>
<div class="flex flex-col gap-3">
<BbSelect
id="order-status-filter"
v-model="status"
clearable
item-text="text"
item-value="value"
:items="statusOptions"
label="Status"
name="status"
placeholder="Every status"
/>
<BbTable
v-model:sort="sort"
caption="Orders, filtered by status"
:columns="columns"
:dependencies="[status, sort]"
:deps-debounce-time="200"
item-value="id"
:items="fetchOrders"
loading-text="Loading orders…"
no-data-text="No order has that status."
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbSelect, BbTable } from 'bitboss-ui';
import type { BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { delay, orderStatusLabels, orders, orderStatuses, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference', sortable: true, width: 140 },
{
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
},
{
key: 'status',
label: 'Status',
sortable: true,
width: 120,
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{
key: 'total',
label: 'Total',
align: 'right',
sortable: true,
width: 120,
formatter: euro.format,
},
];
const statusOptions = orderStatuses.map((value) => ({
value,
text: orderStatusLabels[value],
}));
const status = ref<OrderStatus | null>('shipped');
const sort = ref<BbTableSortEntry[]>([['reference', 'desc']]);
const compareOrders = (left: Order, right: Order, key: string): number => {
const leftValue = left[key as keyof Order];
const rightValue = right[key as keyof Order];
if (typeof leftValue === 'number' && typeof rightValue === 'number') {
return leftValue - rightValue;
}
return String(leftValue).localeCompare(String(rightValue));
};
// Stands in for `api.orders.list({ status, orderBy: sort })`. Dependencies are
// the whole request wiring: no watcher, manual loading state, or race handling.
const fetchOrders = (): Promise<Order[]> =>
delay(
orders
.filter((order) => !status.value || order.status === status.value)
.toSorted((left, right) => {
for (const [key, direction] of sort.value) {
const comparison = compareOrders(left, right, key);
if (comparison !== 0) return direction === 'asc' ? comparison : -comparison;
}
return 0;
}),
700
);
</script>
The provider receives (prefill, selected): prefill is true on mount and
false on a dependency-driven refetch, and selected is the current selection,
which is where you reconcile it against the incoming rows. List your filters in
dependencies and never write a watch(filter, refetch) — dependencies are
compared by value, not by reference, so [filters] refetches when the contents
change and not merely when the array identity does. deps-debounce-time throttles
the burst. A plain array bypasses all of this.
enforce-coherence prunes the row-keyed models — v-model,
v-model:unselected-items, v-model:highlighted, v-model:expanded-items — of
values whose row is gone after each load. Single-value models reset to null.
Never combine it with pagination fed through dependencies: every page turn
is a refetch, so a cross-page selection, and the whole select-all plus
unselected-items pattern, would be pruned away. On a paginated table, reconcile
in the provider instead, where both the new rows and the current selection are in
hand.
The table never slices. Hand it the current page as items and give it the
paging facts:
<template>
<div class="flex flex-col gap-3">
<BbTable
caption="Catalogue, eight products per page"
:columns="columns"
item-value="id"
:items="pageItems"
:page="page"
:per-page="perPage"
:total-items="products.length"
/>
<BbPagination
v-model="page"
label="Catalogue pages"
:per-page="perPage"
:total-items="products.length"
/>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbPagination, BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'category', label: 'Category', width: 160 },
{ key: 'stock', label: 'On hand', align: 'right', width: 100 },
];
const perPage = 8;
const page = ref(1);
// The table never slices. You hand it the current page; `page` / `per-page` /
// `total-items` are what let it announce the 26 rows behind the 8 on screen.
const pageItems = computed(() =>
products.slice((page.value - 1) * perPage, page.value * perPage)
);
</script>
page, per-page and total-items are what let the table announce
aria-rowcount and each row's absolute aria-rowindex, so a screen-reader user
hears "row 12 of 26" rather than "row 4 of 8". With an array items, per-page
and total-items default to its length, so a single-page table is accessible
with page alone. Note that aria-rowcount now counts the header row —
totalItems + 1 — so a test asserting the old number needs one added.
On a server-driven screen the provider forwards the filters, the sort model and
the page, and returns the slice. Do not list the page in dependencies: a
provider table already refetches when the shared page moves, so list only your
own filters and sort.
Custom cells and headers
Override a column's body with a #<key> slot when the value needs markup rather
than a string.
<template>
<BbTable
caption="Catalogue with status badges"
:columns="columns"
item-value="id"
:items="catalogue"
>
<!--
`#<key>` replaces the cell body. The column stays in `columns`: its
label, alignment and width still come from there.
-->
<template #status="{ content, item }">
<BbBadge size="xs" :variant="statusVariant[item.status]">
{{ content }}
</BbBadge>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { BbBadge, BbTable } from 'bitboss-ui';
import type { BadgeVariantType, BbTableColumn } from 'bitboss-ui';
import type { Product, ProductStatus } from '~/demo-data';
import { products, productStatusLabels } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'sku', label: 'SKU', width: 150 },
{
// The formatter still runs: `content` reaches the slot already formatted,
// so the badge shows the label and the row keeps storing the code.
key: 'status',
label: 'Status',
width: 140,
formatter: (value: ProductStatus) => productStatusLabels[value],
},
];
// A lookup object, not a template-composed name — the icons policy rule, and it
// keeps the variant names type-checked against the registry.
const statusVariant: Record<ProductStatus, BadgeVariantType> = {
'in-stock': 'secondary',
'low-stock': 'outline',
backorder: 'outline',
discontinued: 'destructive',
};
const catalogue = products.slice(0, 7);
</script>
Keep the column in columns even when you fully slot it — its label, alignment
and width still come from there, and so does the formatter, whose output reaches
the slot as content. The scope also carries item (your raw record), value
(resolved through item-value), classes, and the row's whole interaction
surface: selected / toggleSelected, highlighted / toggleHighlighted,
expanded / toggleExpanded / expandProps, and sortOrder. The flags are
one-way state; the toggle* callbacks are the write path.
The slot name is the key, lowercased, with every run of non-word characters
turned into _, dashes included. So address.city is #address_city,
issued-at is #issued_at, and issuedAt is #issuedat, which is why
snake_case keys read better here. Building slot names in code? Import the
normalizer, slotKey, rather than restating the rule: a hand copy that differs
by one character makes the slot silently never match.
#header:<key> replaces a header, but that takes the built-in sort button with
it. To add beside the header instead, use #header:<key>:prepend and
#header:<key>:append:
<template>
<BbTable
v-model:sort="sort"
caption="Catalogue with an annotated header"
:columns="columns"
item-value="id"
:items="catalogue"
>
<!--
`:append` renders AFTER the column's existing header content, so the
built-in sort button survives. Replacing `#header:stock` outright
would take the button with it.
-->
<template #header:stock:append="{ items }">
<span class="ms-1 text-xs font-normal opacity-60">
({{ items?.length ?? 0 }} rows)
</span>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn, BbTableSortEntry } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product', sortable: true },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'stock', label: 'On hand', align: 'right', sortable: true, width: 150 },
];
const sort = ref<BbTableSortEntry[]>([]);
const catalogue = products.slice(0, 5);
</script>
The table-wide #header:prepend / #header:append apply one template to every
data column's header, never to the structural select and actions cells, and
receive columnKey so they can branch. A per-column affix wins over the
table-wide one for that column; they never stack. Header scope is label,
items, sortable, sortOrder, toggleSort and classes.
The structural regions have their own slots: #header:select, #header:actions,
and #select to replace a row's checkbox or radio (scope: item, value,
checked, disabled, readonly, inputName, toggleSelected). Replacing the
selection control is the supported way to change it — rebuilding selection with
your own checkbox in a cell slot is not.
Detail rows
Track the open rows in v-model:expanded-items and render their detail in
#expand, a full-width row below the record it belongs to.
<template>
<BbTable
v-model:expanded-items="expanded"
actions-text="Details"
caption="Orders, with their lines on demand"
:columns="columns"
item-value="id"
:items="recentOrders"
>
<!-- The #actions slot creates the actions column.
`toggleExpanded` is a callback; `expanded` is read-only state. -->
<template #actions="{ expanded: isOpen, toggleExpanded }">
<BbButton size="xs" variant="ghost" @click="toggleExpanded">
{{ isOpen ? 'Hide lines' : 'Lines' }}
</BbButton>
</template>
<!-- One full-width row below the record it belongs to. -->
<template #expand="{ item }">
<div class="bg-[color:var(--bb-muted)] px-3 py-2.5 text-sm">
<ul class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="line in item.lines" :key="line.productId" class="flex gap-2">
<span class="grow">{{ productById[line.productId]?.name }}</span>
<span class="opacity-70">× {{ line.quantity }}</span>
<span class="w-20 text-right">{{ euro.format(line.unitPrice) }}</span>
</li>
</ul>
</div>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { BbButton, BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order } from '~/demo-data';
import { orders, productById, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference', width: 140 },
{
key: 'customerId',
label: 'Customer',
formatter: (id: number) => userById[id]?.fullName,
},
{ key: 'total', label: 'Total', align: 'right', width: 120, formatter: euro.format },
];
// The open rows, keyed by `item-value` — ids, so expansion survives a refetch.
const expanded = ref<number[]>([]);
const recentOrders = orders.slice(0, 5);
</script>
The trigger here is the actions column, created by the #actions slot and
labelled with actions-text (visually hidden, and localized by default). Its scope hands you
toggleExpanded — a callback — plus the read-only expanded flag. Drive
open and close through the callback; mutating the flag does nothing.
For a control that is not a plain button, v-bind the scope's expandProps onto
it: you get aria-expanded and aria-controls kept in sync, a click handler
that stops propagation so the row underneath does not also toggle its highlight,
and Enter/Space handling for non-button elements. expandProps, expanded and
toggleExpanded reach every cell slot, not just #actions, so a drill-down
can put its chevron in the identity column where the eye already is; keep the
actions column for actions.
Set item-value so expansion is keyed by id and survives a refetch. A BbTable
inside #expand is independent by default; inherit-column-widths snaps its
columns onto the parent's tracks so a breakdown reads as a continuation of the
row above it, with a column's snap remapping individual tracks. An inheriting
child never scrolls on its own — the parent's scrollport is the only one, which
is what keeps the two grids locked together.
Opening a record
v-model:highlighted marks the row whose details are open. It is parallel to
selection, not part of it, and it is single by design: one row is "the open one".
<template>
<div class="flex flex-col gap-3 sm:flex-row">
<!-- `min-width: 0` lets this flex item shrink so the table scrolls itself. -->
<div class="min-w-0 grow">
<BbTable
v-model:highlighted="openOrder"
caption="Orders — click or arrow to a row to preview it"
:columns="columns"
item-value="id"
:items="recentOrders"
keyboard-navigation
:row-class="rowClass"
/>
</div>
<aside class="w-full shrink-0 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)] p-3 text-sm sm:w-56">
<template v-if="active">
<p class="m-0 font-medium">{{ active.reference }}</p>
<p class="mt-1 mb-0 text-xs opacity-70">
{{ userById[active.customerId]?.fullName }} · {{ active.channel }} ·
{{ active.lines.length }} line(s)
</p>
</template>
<p v-else class="m-0 text-xs opacity-70">No order is open.</p>
</aside>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Order, OrderStatus } from '~/demo-data';
import { orderById, orderStatusLabels, orders, userById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Order>[] = [
{ key: 'reference', label: 'Reference', width: 140 },
{
key: 'status',
label: 'Status',
width: 120,
formatter: (value: OrderStatus) => orderStatusLabels[value],
},
{ key: 'total', label: 'Total', align: 'right', width: 120, formatter: euro.format },
];
// `ref(null)`, never `ref()`. The value is the switch: `undefined` leaves the
// whole highlight mechanic off, and warns in dev.
const openOrder = ref<number | null>(null);
// A neutral fill plus a primary accent bar already ships for `--highlighted`.
// `row-class` is how you replace that look — here with a tinted open row that
// says "this is the record the panel is showing", not merely "this is current".
const rowClass = (order: Order) =>
order.id === openOrder.value
? 'bg-[color:color-mix(in_oklab,var(--bb-primary)_10%,var(--bb-panel))]'
: '';
const active = computed(() =>
openOrder.value === null ? null : (orderById[openOrder.value] ?? null)
);
const recentOrders = orders.slice(0, 6);
</script>
Highlight is opt-in, and the model's value is the switch. undefined means
"never initialised" and leaves the mechanic off; null means "initialised and
currently empty" and turns it on. So write ref(null), never ref() — a
bound-but-undefined model warns in dev and stays inert. Unbound, a row click
emits click:row and does nothing else: no class, no aria-current, no state.
That is deliberate. A table has nowhere to put a highlight it invented, and in v2
it drove one anyway, into a state the application could neither read nor clear.
A highlighted row carries aria-current="true" and the
bb-table-data__row--highlighted class, which the shipped stylesheet paints with
a neutral fill and a brand accent bar. row-class replaces that look, as the demo
does. Clicking the highlighted row again clears it back to null; clicks inside
interactive elements and clicks that end a text selection are ignored, so
dragging to copy a cell never mutates state.
keyboard-navigation makes the body a single tab stop with a roving tabindex:
Arrow keys move the focused row, PageUp/PageDown stride ten, Home/End jump, and
Enter or Space activate through the same path as a click. Focus and highlight
stay independent — arrowing over rows opens nothing until the reader commits.
Screen readers in browse mode intercept the arrows, so the reader takes the usual
focus-mode step first.
Row events fire with (event, item): the native event and your record exactly
as you passed it in items. In v2 the second argument was the table's internal
row wrapper, with your record buried at row.item, and a third selected
boolean followed it. Both are gone. row.item is now undefined, the third
parameter is undefined, and a handler typed with three parameters is a type
error.
- <BbTable @click:row="(event, row, selected) => open(row.item, selected)" />
+ <BbTable @click:row="(event, item) => open(item, selectedIds.includes(item.id))" />
Reserve click:row, dblclick:row and contextmenu:row for side effects that
are not navigation. Row navigation goes through a real link, never a row
handler: render a BbButton with href (or to in a router app) in a cell or
the actions column, and the reader gets middle-click and open-in-new-tab for
free. Overlays opened from inside a row — a popover, a dropdown, a picker, a
dialog — swallow their own clicks, so no @click.stop wrapper is needed around
them.
Wide, long and dense tables
compact tightens the row padding. fixed-headers sticks the header row group
to the top of the scrollport, and fixed: 'left' | 'right' on a column definition
pins that column while the rest scrolls horizontally.
<template>
<!--
The bound height goes on the table: `.bb-table` is already a scroll box,
so once its height is capped it becomes the scrollport the stuck header
and the pinned column resolve against.
-->
<BbTable
caption="Catalogue — the header sticks, the first column is pinned"
class="max-h-72 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
:columns="columns"
compact
fixed-headers
item-value="id"
:items="products"
/>
</template>
<script setup lang="ts">
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product, ProductStatus } from '~/demo-data';
import { products, productStatusLabels } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
// Enough columns to overflow the page's content column, so both the sticky
// header and the pinned identity column have something to do.
const columns: BbTableColumn<Product>[] = [
// The pin lives on the column, so it follows it through a reorder.
{ key: 'name', label: 'Product', width: 240, fixed: 'left' },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'category', label: 'Category', width: 160 },
{ key: 'price', label: 'Price', align: 'right', width: 120, formatter: euro.format },
{ key: 'stock', label: 'On hand', align: 'right', width: 110 },
{
key: 'status',
label: 'Status',
width: 140,
formatter: (value: ProductStatus) => productStatusLabels[value],
},
{ key: 'updatedAt', label: 'Updated', width: 130 },
];
</script>
The table is its own scroll box. .bb-table ships overflow-x: auto and
min-width: 0, so a table wider than its container scrolls without a wrapper —
and once you cap its height, it is also the vertical scrollport the stuck header
and the pinned cells resolve against. Put the max-height on the table, as the
demo does, rather than on a <div> around it.
What that still cannot do is make room for itself. Inside a flex row an item's
automatic minimum size is its own content, so an unconstrained ancestor between
the page and the table widens the row instead of letting the table scroll. Give
that ancestor min-width: 0 — and only on the flex row axis; a cross-axis child
of a column-direction flex parent already has a definite width.
The pin belongs to the column, so it follows the column through a reorder and a
hidden pinned column pins nothing. Pin a run from the edge and the offsets add
up, the second left-pinned column sticking right after the first. The selection
and actions columns have no definition to carry the field, so they pin through
the table with fixed-select and fixed-actions. Pinned cells and the stuck header band are opaque and paint the table's --bg
local, so on a surface that is not the panel colour, set that one
knob.
When content cannot fit, the table keeps every column at its measured minimum —
its header label included — and overflows rather than clipping. That is by
design. Truncating cells yourself with overflow: hidden removes that column's
content floor, so declare a width on any column you truncate.
On a phone, collapse rather than scroll: a ten-column ops table as a horizontal
scroller loses the identity column first, which is the one thing the reader is
looking for. Drop the secondary keys from columns at the breakpoint and stack
what survives into the identity cell. Do not hide cells with display: none
— a hidden grid item leaves its track in place and every following cell shifts
one column over.
Once the list is long, virtual windows the body:
5000 rows; a few dozen are in the DOM at any moment.
<template>
<div class="flex flex-col gap-2">
<BbTable
caption="Every stock movement of the year"
class="max-h-80 rounded-[var(--bb-radius)] border border-[color:var(--bb-border)]"
:columns="columns"
compact
item-value="id"
:items="movements"
virtual
/>
<p class="text-sm opacity-70">
{{ movements.length }} rows; a few dozen are in the DOM at any moment.
</p>
</div>
</template>
<script setup lang="ts">
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import { products } from '~/demo-data';
interface Movement {
id: number;
reference: string;
product: string;
sku: string;
quantity: number;
}
const columns: BbTableColumn<Movement>[] = [
{ key: 'reference', label: 'Movement', width: 130 },
{ key: 'product', label: 'Product', width: 260 },
{ key: 'sku', label: 'SKU', width: 150 },
{ key: 'quantity', label: 'Qty', align: 'right', width: 90 },
];
// Built once, from the catalogue, with no clock and no random source — the
// prerendered HTML and the hydrated markup have to be identical.
const movements: Movement[] = Array.from({ length: 5000 }, (_, index) => {
const product = products[index % products.length]!;
return {
id: index,
reference: `MOV-${String(index + 1).padStart(5, '0')}`,
product: product.name,
sku: product.sku,
quantity: (index % 12) + 1,
};
});
</script>
Only the rows in the scrollport plus a few on each side are mounted; two spacer
rows hold the scrollport at the full height. Give it a scroller by bounding the
height, as above; left unbounded, the page becomes the scroller. virtual
implies a stuck header, because a windowed body without column names is a
scroller rather than a table. Rows keep their absolute aria-rowindex, focus is
never lost to an unmount, and row heights stay dynamic.
One constraint is worth designing around: columns are sized once. A windowed
body only ever holds a few rows, so the first rendered batch is measured and
those widths become floors. The cells of those columns then clip with an ellipsis
instead of wrapping, so a longer value arriving later never spills into its
neighbour. A column that must wrap — prose, notes — needs a declared width,
which wins as always. Nested tables inside expand rows are never windowed, and
virtual is ignored on a table that inherits its widths or that replaces
#tbody. Browser find only reaches mounted rows, which was a deliberate call.
If the reader needs to move or size the columns themselves, reorderable makes
each header a drag handle writing v-model:order, and resizable puts a grip on
each header's trailing edge reporting through @resize:column. Both are
documented on the API page.
Driving the table from outside
Give the table an id and useBbTableContext(id) hands you a live, two-way
handle on its state from anywhere on the page — no prop drilling, no template
refs.
<template>
<div class="flex flex-col gap-3">
<!-- Outside the table's subtree. No props and no refs travel between them. -->
<div class="flex min-h-8 flex-wrap items-center gap-2">
<span class="text-sm">{{ selectedCount }} selected</span>
<BbButton
:disabled="selectedCount === 0"
size="sm"
variant="outline"
@click="clearSelection"
>
Clear selection
</BbButton>
</div>
<BbTable
id="catalogue-table"
caption="Catalogue"
:columns="columns"
item-value="id"
:items="catalogue"
legend="Select products for a bulk price update"
selectable
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { BbButton, BbTable, useBbTableContext } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { Product } from '~/demo-data';
import { products } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<Product>[] = [
{ key: 'name', label: 'Product' },
{ key: 'price', label: 'Price', align: 'right', width: 120, formatter: euro.format },
];
// The table's `id` is the rendezvous key. A typo here does not error — it
// silently creates a second state node that syncs with nothing.
const table = useBbTableContext('catalogue-table');
const selectedCount = computed(() => table.selected.value?.length ?? 0);
const clearSelection = () => (table.selected.value = []);
const catalogue = products.slice(0, 6);
</script>
The handle mirrors selected, unselected, all, highlighted, sort,
page, perPage, totalItems and totalPages, each a writable computed: read
.value, assign .value to drive the table. It works alongside the v-models
rather than instead of them, so a table can be bound normally and still be
readable from a toolbar. Every field returns a stable empty baseline — [],
false, null, 0 — even before the table mounts, so nothing needs a guard.
BbPagination joins the same context through its table-id prop, which is the
zero-wiring alternative to sharing a page ref: the paging props are symmetric
across the pair, so the server's numbers can go to whichever component is
convenient.
Two things to know before you debug it. The id is a rendezvous key and a typo
does not error — it silently creates a second state node, so your toolbar looks
alive while nothing reaches the table. If a bulk bar "works" but the table never
reacts, check the spelling first; isReady only tells you whether paging data is
real, not whether a table is attached. And the context is client-side only:
during SSR the handle operates on detached state, and cross-component wiring
starts at hydration.
If you wrote against the v3 alphas, the row count on the handle was renamed:
ctx.total is now ctx.totalItems, everywhere including the initial seed.
Reading the old name yields undefined and assigning to it throws.
The grid substrate and its styling hooks
Style the table through its classes and its ARIA roles. Element names match nothing.
Coming from v2
BbTable now renders role-complete <div> elements as a CSS grid. Props,
slots, events and models did not change, but markup-dependent behavior did:
- Element selectors (
table,thead,tbody,tr,th,td) and global table CSS no longer match. #thead,#tbodyand#tfootrequire<div>rows with the correct ARIA roles. A<tr>inside the grid cannot land on its tracks.- Browser copy produces one value per line. Build “copy as TSV” from
items. - Printed grids do not repeat their header. Use a print-specific report view.
captionis now a<div>before the grid root, with the same accessible name.- Flexible surplus is shared equally, not by content. The measured 748px/52px pair becomes 544px/256px. In narrow containers, the longest flexible column can be about 22% narrower; mixing fixed and flexible tracks can move a column by about 19px.
- A cell that scrolls no longer floors its track. Ellipsis works, but the old
max-width: 0trick collapses the cell to its padding. - Percent widths in overflow resolve against the grid's min-content width. The measured 30% column changes from 181px to 303px. Non-overflowing tables do not change.
- With
fixed, undeclared widths split the available space equally. If every column has a width, remaining space follows those proportions; selection and actions stay at content width.
The layout requires subgrid (Chrome/Edge 117, Firefox 71, Safari 16). Vertical
centering through block align-content requires Chrome 123, Safari 17.4 or
Firefox 125; older versions top-align cell content. There is no @supports
fallback.
The surface is --bg, a local on .bb-table defaulting to
var(--bb-panel). Cells are transparent at rest so the row can tint them, but
anything that slides over scrolled content — the stuck header band, pinned cells
— is opaque and paints --bg. Set it once when the table sits on another
surface and everything pinned follows:
.my-card .bb-table {
--bg: var(--card-bg);
}
The other locals on .bb-table are --padding-x (12px, 8px when
compact), --padding-y (6px, 4px), --cell-h (36px, 32px) and
--actions-spacing (8px). Note that these tightened in v3 from v2's 16px /
8px / 42px. Everything the table publishes — the per-track bridge a nested
table reads, the edge offsets — lost its bb- prefix in v3
(--bb-table-offset-start → --offset-start, and so on), and --fill and
--natural-width were removed outright. All of them fail silently, so grep your
CSS and any style-scanning JavaScript for --bb-table-; no name with that prefix
exists any more.
| Region | Class | Role |
|---|---|---|
| container | .bb-table | — |
| grid root | .bb-table__table | table |
| caption | .bb-table-caption | — (before the root) |
| header group | .bb-table__head | rowgroup |
| header row | .bb-table-header-row | row |
| header cell | .bb-table-header (+ --select, --actions) | columnheader |
| body group | .bb-table__body | rowgroup |
| data row | .bb-table-data__row (+ --highlighted) | row |
| data cell | .bb-table-data__cell (+ --select) | cell |
| actions cell | .bb-table__cell--actions | cell |
| expand row | .bb-table-expand__row > .bb-table-expand__cell | row > cell |
| empty state | .bb-table-no-data__row > .bb-table-no-data__cell | row > cell |
| skeleton row | .bb-table-skeleton__row > .bb-table-skeleton__cell | row > cell |
| footer group | .bb-table__foot | rowgroup only with #tfoot |
[role='row'], [role='cell'] and [role='columnheader'] are supported hooks,
not incidental markup — use them when you mean "every row kind", and the classes
when you mean one kind. Cells are direct children of their row, so :first-child
and :nth-child(n) address columns.
Translating a v2 stylesheet: table → .bb-table__table, thead →
.bb-table__head, tbody → .bb-table__body, tfoot → .bb-table__foot,
caption → .bb-table-caption, tr → [role='row'] (or .bb-table-data__row
when you meant data rows only — a bare tr matched every row kind), th →
.bb-table-header, td → .bb-table-data__cell. :first-child and
:last-child carry over verbatim, td:nth-of-type(n) becomes :nth-child(n)
since the children are type-uniform now, and colgroup / col are gone —
declare width on the column instead.
Watch the specificity while you translate. [role='cell'] is an attribute
selector, td was a type selector, so the translated rule is stronger than the
one it replaces and an override that used to lose by one point can now win. The
library holds its own body-cell rules at two classes deliberately, so an override
that beat .bb-table tbody tr td at that weight still wins; the one band that
loses ground is a rule of exactly one class plus type selectors. Give it one more
class.
Some geometry is not yours to change: display: grid and gap: 0 on the root,
the row groups and the rows; grid-column: 1 / -1 and contain: inline-size
on the full-width cells, without which expand-row content resizes the parent's
columns; white-space: nowrap on the actions cell, which is what makes its
max-content track the width of the controls side by side; and position: sticky on .bb-table__head under fixed-headers — sticky lives on the row
group, because a sticky cell inside a subgrid cannot stick in any engine.
Replacing a whole row group
#thead, #tbody and #tfoot hand you a region, and the markup you pass is
grid markup:
<template>
<BbTable
caption="Order lines with a total row"
:columns="columns"
item-value="productId"
:items="lines"
>
<!--
A replaced row group is grid markup: `<div role="row">` holding
`<div role="cell">`. A `<tr>` here would be blockified into one grid
item and its cells would never reach the tracks — and a div without
a role renders fine while being invisible to a screen reader.
-->
<template #tfoot="{ columnCount }">
<div class="font-medium" role="row">
<div
role="cell"
:aria-colspan="columnCount - 1"
:style="{ gridColumn: `span ${columnCount - 1}` }"
>
Total
</div>
<div class="text-right" role="cell">{{ euro.format(total) }}</div>
</div>
</template>
</BbTable>
</template>
<script setup lang="ts">
import { BbTable } from 'bitboss-ui';
import type { BbTableColumn } from 'bitboss-ui';
import type { OrderLine } from '~/demo-data';
import { orders, productById } from '~/demo-data';
const euro = new Intl.NumberFormat('en-IE', {
style: 'currency',
currency: 'EUR',
});
const columns: BbTableColumn<OrderLine>[] = [
{
key: 'productId',
label: 'Product',
formatter: (id: number) => productById[id]?.name,
},
{ key: 'quantity', label: 'Qty', align: 'right', width: 90 },
{
key: 'unitPrice',
label: 'Unit price',
align: 'right',
width: 130,
formatter: euro.format,
},
];
const lines = orders.flatMap((order) => order.lines).slice(0, 5);
const total = lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);
</script>
One <div role="row"> per row, holding one <div role="cell"> — or
role="columnheader" in the head — per column. Rows are subgrid rows, so a cell
takes its column track without a width of its own, and a spanning cell uses
grid-column plus aria-colspan rather than colspan. #tfoot receives
columnCount, the total rendered columns with the selection and actions columns
included. A full-width row is grid-column: 1 / -1.
Nothing lints this. A slot ported from <tr>/<td> to plain <div>s
without roles renders perfectly and ships a table that a screen reader reads as a
pile of generic boxes. The role chain table → rowgroup → row → columnheader | cell is the whole of the table semantics here; if you replace a region, you own
it.
Two side effects of replacing the header specifically: the table measures its
column tracks off the header cells, so with a custom #thead it falls back to
the first row that has one child per track — which means a custom header over
an empty table publishes no tracks, and a nested table cannot inherit from it
until rows arrive. And under fixed-headers a multi-row #thead now sticks as a
block, where the old cell-level sticky stacked its rows on top of each other.