Table
A data table with sorting, row selection, column-driven cells, and its own loading/error/empty states.
| Name | Visibility | Findings |
|---|---|---|
| payments-service | Private | 3 |
| auth-gateway | Private | 0 |
| public-docs | Public | 1 |
<template>
<Table :data="repositories" :columns="columns" empty-icon="i-lucide-search-x" empty-title="No results" />
</template>Props
| Name | Type | Default |
|---|---|---|
| data | T[] | — |
| columns | TableColumn<T>[] | — |
| density | "default" | "compact" | "default" |
| clickable | boolean | false |
| ui | Partial<Record<'base' | 'root' | 'thead' | 'tbody' | 'tr' | 'td' | 'th', string>> | — |
| loading | boolean | — |
| loadingStyle | "bar" | "skeleton" | "bar" |
| loadingRows | number | 3 |
| error | boolean | — |
| errorText | string | — |
| emptyIcon | string | — |
| emptyTitle | string | — |
| emptyDescription | string | — |
| ...props | UTable props (sorting, rowSelection, getRowId, empty, sortingOptions, ...) | — |
Slots
| Name | Description |
|---|---|
| empty | Overrides the built-in emptyIcon/emptyTitle/emptyDescription state entirely, for a table whose empty state needs more than icon+title+description. |
| * | Every other UTable slot (per-column *-header/*-cell/*-footer, loading, caption, body-top, body-bottom, ...) forwards through unchanged — column ids are arbitrary per table instance, so these can't be enumerated here. |
Usage
Examples
Empty
Header hides automatically when data is empty. emptyIcon/emptyTitle/emptyDescription render a centered empty state in place of UTable's own plain text.
| Name | Visibility | Findings |
|---|---|---|
No repositories match your filters Try adjusting or clearing your filters. | ||
<template>
<Table
:data="[]"
:columns="columns"
empty-icon="i-lucide-search-x"
empty-title="No repositories match your filters"
empty-description="Try adjusting or clearing your filters."
/>
</template>Row selection
createRowSelectColumn() (from this package's utils/table.ts) is a ready-made indeterminate select-all + per-row checkbox column.
| Name | Visibility | Findings | |
|---|---|---|---|
| payments-service | Private | 3 | |
| auth-gateway | Private | 0 | |
| public-docs | Public | 1 |
<template>
<Table :data="data" :columns="[createRowSelectColumn(), ...columns]" />
</template>Sortable header
createSortableHeader(label, info?) builds a clickable header — hover highlights both the label and the sort icon to text-primary as one unit, and the optional info tooltip explains a non-obvious column.
| payments-service | Private | 3 |
| auth-gateway | Private | 0 |
| public-docs | Public | 1 |
<template>
<Table
:data="data"
:columns="[
{ accessorKey: 'name', header: createSortableHeader('Name') },
{ accessorKey: 'visibility', header: createSortableHeader('Visibility') },
{ accessorKey: 'findings', header: createSortableHeader('Findings', 'Open findings on the default branch') },
]"
/>
</template>Row actions
createActionsColumn(getItems, label?) adds a right-aligned kebab button opening a DropdownMenu of row actions — this design system's own DropdownMenu, not the raw Nuxt UI one.
| Name | Visibility | Findings | |
|---|---|---|---|
| payments-service | Private | 3 | |
| auth-gateway | Private | 0 | |
| public-docs | Public | 1 |
<template>
<Table
:data="data"
:columns="[
...columns,
createActionsColumn((row) => [
{ label: 'View', icon: 'i-lucide-eye' },
{ label: 'Remove', icon: 'i-lucide-trash', color: 'error', onSelect: () => remove(row.original) },
]),
]"
/>
</template>Numeric columns
createNumericColumn(accessorKey, header, { color?, format? }) right-aligns the header and cell and applies tabular-nums — defaults to tertiary since a plain count isn't the row's headline; scanCostUsd opts into primary here since cost is this table's own headline metric. A null value always renders as a plain '-' in tertiary, overriding color.
| Name | Findings | Scan cost |
|---|---|---|
| payments-service | 3 | $4.82 |
| auth-gateway | 0 | $1.15 |
| public-docs | 1 | - |
<template>
<Table
:data="data"
:columns="[
{ accessorKey: 'name', header: 'Name' },
createNumericColumn('findings', 'Findings'),
createNumericColumn('scanCostUsd', 'Scan cost', {
color: 'primary',
format: (value) => `$${value.toFixed(2)}`,
}),
]"
/>
</template>Switch column
createSwitchColumn(accessorKey, header, onToggle, { ariaLabel, disabledReason? }) — ariaLabel names each row's switch for screen readers (the switch has no visible label of its own); the disabled reason (when given) both disables the Switch and becomes its tooltip, same shape as members-table.vue's own dev-seat toggle. Centers via cellLayout's real flex wrapper, since meta.class's text-align only works on inline-level content and Switch's own root is display:flex.
| Name | Enabled |
|---|---|
| payments-service | |
| auth-gateway | |
| public-docs |
<template>
<Table
:data="data"
:columns="[
{ accessorKey: 'name', header: 'Name' },
createSwitchColumn(
'enabled',
'Enabled',
(value, row) => { row.original.enabled = value },
{
ariaLabel: (row) => 'Enable ' + row.original.name,
disabledReason: (row) => row.original.canToggle ? null : 'Requires an SCM connection to toggle',
},
),
]"
/>
</template>Select column
createSelectColumn(accessorKey, header, items, onChange) — items can be a plain array or a function of the row (e.g. narrowing choices by permission). onChange receives the row so the caller can gate the change behind a confirm flow first, same as members-table.vue's own role picker does, rather than mutating directly.
| Name | Role |
|---|---|
| jane | |
| alex | |
| sam |
<template>
<Table
:data="data"
:columns="[
{ accessorKey: 'name', header: 'Name' },
createSelectColumn(
'role',
'Role',
[
{ value: 'admin', label: 'Admin' },
{ value: 'member', label: 'Member' },
{ value: 'viewer', label: 'Viewer' },
],
(value, row) => { row.original.role = value },
),
]"
/>
</template>Combined columns
Every column-builder helper composed in one table: row selection, an avatar+name+subtitle cell, a sortable link cell, a status badge, a numeric column, a truncated-with-tooltip cell, a Switch cell, and row actions — the way a real table actually mixes cell types, not each shown in isolation.
| Owner | Visibility | Findings | Description | Enabled | |||
|---|---|---|---|---|---|---|---|
s security-team payments-service | payments-service | Private | 3 | Handles card processing and payout reconciliation for the whole platform. | |||
p platform-team auth-gateway | auth-gateway | Private | 0 | Central authentication gateway shared by every internal service. | |||
d docs-team public-docs | public-docs | Public | 1 | Public-facing documentation site, deployed on every merge to main. |
<template>
<Table
:data="data"
:columns="[
createRowSelectColumn(),
createAvatarColumn('ownerName', 'Owner', { subtitle: (row) => row.original.name }),
createLinkColumn('name', createSortableHeader('Repository'), (row) => row.original.repoUrl),
createBadgeColumn('visibility', 'Visibility', {
variant: (value) => value === 'Public' ? 'success' : 'default',
}),
createNumericColumn('findings', 'Findings'),
createTruncatedColumn('description', 'Description'),
createSwitchColumn(
'enabled',
'Enabled',
(value, row) => { row.original.enabled = value },
{
ariaLabel: (row) => 'Enable ' + row.original.name,
disabledReason: (row) => row.original.canToggle ? null : 'Requires an SCM connection to toggle',
},
),
createActionsColumn((row) => [
{ label: 'View', icon: 'i-lucide-eye' },
{ label: 'Remove', icon: 'i-lucide-trash', color: 'error' },
]),
]"
/>
</template>Compact density
For a dashboard/admin table where per-row info is short.
| Name | Visibility | Findings |
|---|---|---|
| payments-service | Private | 3 |
| auth-gateway | Private | 0 |
| public-docs | Public | 1 |
<template>
<Table :data="data" :columns="columns" density="compact" />
</template>Clickable rows
Appends cursor-pointer to the row — wire actual navigation through UTable's own onSelect.
| Name | Visibility | Findings |
|---|---|---|
| payments-service | Private | 3 |
| auth-gateway | Private | 0 |
| public-docs | Public | 1 |
<template>
<Table :data="data" :columns="columns" clickable :onSelect="(row) => navigateTo(row.original.name)" />
</template>Loading (cold start)
loadingStyle set to skeleton keeps the real header and column widths, replacing each body cell with a skeleton bar — for a first load with nothing to show yet, distinct from the bar default (a thin bar over stale data during a refetch).
<template>
<Table :data="data" :columns="columns" :loading="loading" loading-style="skeleton" />
</template>Error
error replaces the table with errorText — checked before loading/empty, for a request that failed rather than one that's still loading or genuinely returned nothing.
Failed to load repositories. Try again.
<template>
<Table :data="data" :columns="columns" error error-text="Failed to load repositories. Try again." />
</template>