Table
A data table with sorting, row selection, and loading, error and empty states.
| Name | Visibility | Findings |
|---|---|---|
| payments-service | Private | 3 |
| auth-gateway | Private | 0 |
| public-docs | Public | 1 |
<template> <Table :data="repositories" :columns="columns" empty-title="No results" empty-description="Try adjusting or clearing your filters." /></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 | 15 |
| error | boolean | - |
| errorText | string | - |
| retryLabel | string | - |
| minWidth | number | 768 |
| emptyTitle | string | - |
| emptyDescription | string | - |
| ...props | UTable props (sorting, rowSelection, getRowId, empty, sortingOptions, ...) | - |
Slots
| Name | Description |
|---|---|
| empty | Overrides the built-in TableEmptyRow rendering entirely, for a table whose empty state needs more than 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. |
Guidelines
Structure
Unlike shadcn's Table/TableHeader/TableBody/TableRow/TableCell family, this is one component, not a set of subcomponents you nest - every semantic element (<table>, <thead>, <tbody>, <tr>, <th>, <td>) is rendered internally from columns/data. You build a table by assembling columns as a plain array, mixing this package's create*Column helpers (utils/table.ts) with plain TableColumn defs for anything with no helper yet:
const columns = [
createRowSelectColumn<Repo>(),
createAvatarColumn<Repo>('ownerName', 'Owner', { subtitle: row => row.original.name }),
createLinkColumn<Repo>('name', createSortableHeader('Repository'), row => row.original.repoUrl),
createBadgeColumn<Repo>('visibility', 'Visibility', {
variant: value => value === 'Public' ? 'success' : 'default',
}),
createNumericColumn<Repo>('findings', 'Findings'),
createTruncatedColumn<Repo>('description', 'Description'),
createSwitchColumn<Repo>('enabled', 'Enabled', (value, row) => { row.original.enabled = value }, { ariaLabel: row => `Enable ${row.original.name}` }),
createActionsColumn<Repo>(row => [
{ label: 'View', icon: 'i-lucide-eye' },
{ label: 'Remove', icon: 'i-lucide-trash', color: 'error' },
]),
]
<Table :data="repos" :columns="columns" />
Order in the array is column order left-to-right - createRowSelectColumn/createAvatarColumn first (selection and identity lead a row), createActionsColumn last (row actions trail it), matching every real table in apps/frontend. Each helper only needs an accessorKey plus a header (string or createSortableHeader(...)); the ones with no data behind them (createRowSelectColumn, createActionsColumn) take no accessorKey at all. See the "Combined columns" example below for this exact table rendered.
Every rendered element is addressed by a ui slot key (also present on the element itself as data-slot) - base (<table>), thead, tbody, tr, th, td, root (the outer wrapper, overflow-x-auto - see Layout below), and separator. This component's own ui prop exposes those eight keys (TableUiSlot); UTable itself also has caption, tfoot, empty, and loading slot keys, not restylable through this component's ui - reach them through the #caption/#empty/#loading slots or a column's own meta.class instead.
- Caption: pass a plain
captionstring (falls through to UTable directly - this component doesn't declare its owncaptionprop) or a#captionslot for richer content. UTable renders it visually hidden (sr-only) by default - it's for screen readers identifying the table's content, not a visible title; use a heading above the table for a visible one. - Footer: give any column in
columnsafooterproperty, same as a plain TanStack column def - UTable renders the<tfoot>automatically whenever at least one column defines one. There's no separate footer prop on this component. - Separator: UTable renders its own
<tr data-slot="separator">betweentheadandtbody(and again before a<tfoot>), styled by Nuxt UI's own base theme - a second divider stacked on top ofthead's ownborder-b, in a different color token.separatorishiddenby default here sothead'sborder-bis the only line between header and rows; pass your ownui.separatorto bring it back for a footer divider.
Layout
- The table is
table-fixed w-full- every column shares its width equally from its own<th>, not the natural content-width sizing a plain<table>would give it, and a TanStack column def'ssizeis never read as an inline width here (only pinning offsets use it). Getting a column's width right is one of three explicit choices, not the browser's own auto-layout guess: fill (the default - pass no width option, share whatever's left equally with every other unwidthed column),fixColumnWidth(width, extraClass?)(a static, author-declared width for a small structural/control column that must never grow or shrink -createRowSelectColumn/createActionsColumnboth use this to hug a checkbox/kebab button), andhugColumnWidth(accessorKey, headerLabel, options?)(sizes the column to the longest value actually present in the table's current data, inchunits, viameta.style- recomputes wheneverdatachanges, unlikefixColumnWidth's fixed value).createNumericColumnseparately uses plainmeta.classtext-rightto right-align without touching width at all. - Cell text never wraps - UTable's own base theme sets
whitespace-nowrapon everytdunconditionally, and neither density's owntdoverride toucheswhitespace-*, so it survives untouched (same single-line-by-default convention as Carbon/Ant Design/Material). That means a long, un-truncated value doesn't grow the row; boththandtdalso carryoverflow-hidden, so it never bleeds into the next column. overflow-hiddenalone clips at the outer edge of a padded box's padding (flush with the border) - a long value would still visually eat through the fullpx-4/px-3.5padding before finally getting clipped, leaving no clean space.NumericCell,Badge,LinkCell,LabeledAvatar's name/subtitle, andTruncatedCellall wrap their actual text in their own zero-paddingtruncate/min-w-0inner span instead, so the clip happens at that inner span's own edge - which lines up with the cell's content-box edge, i.e. right where the padding begins, not where it ends. A hand-rolled plain-stringcellhas no such wrapper and will still eat through its padding on a long value. UsecreateTruncatedColumn/TruncatedCellfor anything that might run long, so it also degrades as an ellipsis-plus-tooltip instead of a bare clip.- Every
<tr>isrelative, which is whatcreateRowLinkColumn's full-row overlay hangs off: its link'safter:absolute after:inset-0pseudo-element stretches over the whole row. Anything interactive in another cell of that row must carryrelative z-10to sit above the overlay -createRowSelectColumn's checkbox,createActionsColumn's trigger,createSwitchColumn's switch,createSelectColumn's select, andLinkCell's anchor all do. Display-only cells (TruncatedCell,NumericCell,DateCell,Badge,LabeledAvatar) stay below it, so clicking them still navigates the row. - The table has a
minWidthfloor (768px by default) and its outer wrapper (root) isoverflow-x-auto- a table with more columns than fit the viewport (or acompacttable packed with several narrow columns) scrolls horizontally within itself once its container goes below that width, rather than squeezing every column arbitrarily thin or overflowing the page. Fixed-width columns don't shrink, so on a table whose other columns are fixed the fill (name) column absorbs every lost pixel and can reach zero; pass:min-width="tableMinWidth(fixedWidthPx, fillColumnCount)"(the summed fixed widths,ROW_SELECT_COLUMN_WIDTH_PX/ACTIONS_COLUMN_WIDTH_PXfor the control columns, plus the number of unsized columns) so the first fill column keeps 240px and each other one 120px.
Do
- Use
density="compact"for a dashboard/admin table where per-row info is short (cost breakdowns, queue jobs); leave it at the default for everything else. - Use
clickablewhen a row navigates somewhere on click, so the cursor reflects it. - Pass a real
TableColumn<T>[]- column type safety comes from the genericT, don't widen it away withany.
- Pick a column's width deliberately: leave it unwidthed (fill) for a data column that should share leftover space,
fixColumnWidth(width, extraClass?)for a small control column that must stay an exact size (a checkbox, a kebab button), andhugColumnWidth(accessorKey, headerLabel, options?)for a data column that should size itself to its actual longest value (a date, a short status) instead of stretching or being guessed at with a static width. - Build column headers with
createSortableHeader(label, info?)/createHeader(label, info?), a checkbox column withcreateRowSelectColumn<T>(), and a "more options" column withcreateActionsColumn<T>(getItems, label?)(a ghostIconButtonkebab; a row whosegetItemsreturns nothing gets no kebab) (labelcan be arow => stringfunction so each row's kebab is named after its row) (all exported from this package's ownutils/table.ts) instead of hand-rolling any of them - every real table in apps/frontend does at least one.createSortableHeader's whole header cell is the click target (industry standard - Carbon, Ant Design), not just the sort icon. Passinfoto either for a column that needs explaining; it renders anInfoTooltipnext to the label (a sibling of the sort button, never inside it). A thirdinfoLabelargument names that tooltip's trigger (its default is a plain-English "More info"). - Never hand-build page controls or fold pagination into
Tableitself - pair withPaginationinstead; see the Tables UI pattern page for the full FilterBar → Table → footer composition this component is meant to sit inside. This component has no built-in virtualization - it assumes a bounded, paginateddataarray (dozens of rows, not thousands); pair withPaginationfor anything larger rather than passing an unbounded array. Render the count + Pagination row withTableFooter, which also carries the loading skeleton. - Always pass
getRowId(e.g.getRowId: row => row.id) whenevercreateRowSelectColumnis combined with pagination. Without it, TanStack falls back to each row's array index as its id - row 0 on page 1 and row 0 on page 2 then share the same id, so a selection made on one page silently also selects the next page's row in that same position once you paginate.RowSelectActionBar's selection state is only as correct as the ids feeding it. - Pass
createRowSelectColumn({ disabledReason })when some rows can't be selected and the user should know why - the row's checkbox disables, its reason shows in a tooltip, and the header's select-all skips it (so there's no need to repeat the rule inrowSelectionOptions.enableRowSelection). The header disables itself when no row on the page is selectable. A row TanStack itself marks unselectable (enableRowSelectionmakingrow.getCanSelect()false) also disables, with no tooltip. PassselectAllLabelto name the header checkbox for the table's content (e.g. "Select all repositories") andgetRowLabelto name each row's ("Select {label}"). An i18n-aware caller passesgetRowAriaLabelinstead, which returns each row checkbox's full translated name. - Use
emptyTitle/emptyDescriptionfor a table's zero-results state - it rendersTableEmptyRow(a plain two-line text stack, no icon) for you, so don't also nest one inside a#emptyslot yourself; don't reach for the page-levelEmptyStatehere either, that's for a page/section with no data, not a row inside an existing table. Useloading+loadingStyle="skeleton"for a table embedded in a Card that needs a cold-start placeholder instead of UTable's own thin loading bar. The real header renders immediately (correct column labels from the first paint) with each body cell replaced by a skeleton bar - not a generic block that replaces the whole table. The row-select and row-actions columns stay blank instead, since a bar the size of a checkbox or kebab reads as a broken control. - Use
error+errorTextfor a request that failed, not for a request that's still loading or one that genuinely returned nothing - whiledatais empty,erroris checked beforeloading/empty and takes over the whole table. With rows already loaded (a failed background refetch, which TanStack reports with the last gooddatastill set), the same message renders as a strip above the rows instead, so they stay visible: pass the query's rawisErrorand let the table decide. AddretryLabelto render a secondary Button after the text; it emitsretry, the caller does the refetch (@retry="refetch()"), and it shows its spinner whileloadingis set, so passisFetchingasloadingto give the click feedback. - Color cell text by role, matching the convention already live across apps/frontend's tables:
text-text-primaryfor the row's one headline/identifying value (a name, a title),text-text-secondaryfor real present metadata that supports it (a role, a date, a secondary name field),text-text-tertiaryfor de-emphasized supporting text (counts, relative timestamps) and every column header label regardless of that column's own importance. - Build a numeric/currency column with
createNumericColumn(accessorKey, header, { color?, format? })(fromutils/table.ts;colorcan also be a(value, row) => colorfunction, e.g. to highlight non-zero counts) instead of hand-rolling one - it right-aligns the header and cell and appliestabular-nums, the combinationdeveloper-usage-table.vue's own cost/reviews columns already get right but several other tables don't (see the Don't below). - Render a missing/empty cell value as a plain
-intext-text-tertiary, swapping the class conditionally on the value's presence (value ? 'text-text-primary' : 'text-text-tertiary') rather than hardcoding one color for both states - this is a strict, uniform rule across the real tables, not a per-table style choice. - For row actions: an overflow
createActionsColumnfor 3+ actions or any destructive one; inline hover-revealed icon buttons (members-table.vue's own pattern) for 1-2 lightweight, frequent actions. Both are correct - pick by action count, not by habit. - Use
createSwitchColumn(accessorKey, header, onToggle, { ariaLabel, disabledReason? })for a boolean toggle in a column (ariaLabelis required: a bare switch has no visible label, so each row's toggle needs its own screen-reader name) andcreateSelectColumn(accessorKey, header, items, onChange)for an inline value picker, instead of hand-rolling either -members-table.vue's own dev-seat Switch and role Select are the real precedents, both currently built on the raw Nuxt UI primitives rather than this design system's ownSwitch/Select. Both helpers hand the row to their callback rather than mutating anything themselves, so a caller can gate the change behind a confirm flow first (asmembers-table.vue's own role column already does) instead of applying it directly. - Use
cellLayout(content, { align?, disabledTooltip? })when hand-building a cell around a control that isn't already covered by acreate*Columnhelper - itsalignoption is required for anything whose root isdisplay: flex(this package's ownSwitch, for one), sincemeta.class'stext-alignonly affects inline-level content and silently does nothing otherwise. - Use
createTruncatedColumn(accessorKey, header, { color?, format? })for a value too long for its column - it's the most-repeated raw cell pattern found across apps/frontend's tables (members-table.vue,developer-usage-table.vue,repositories/table.vue), throughTruncatedCellrather than a hand-builtTooltip+truncatespan per table. - Use
createBadgeColumn(accessorKey, header, { variant?, label? })for a status/tag cell andcreateAvatarColumn(accessorKey, header, { subtitle?, src?, size? })for a user/avatar cell - both route through this design system's ownBadge/LabeledAvatarinstead of a hand-rolled color mapping or avatar+flex-column per table.createAvatarColumndefaults tosize: 'default', notLabeledAvatar's own default - itssize="sm"variant silently drops the subtitle line entirely, so passingsubtitlewithsize: 'sm'loses it. - Use
createRowLinkColumn(accessorKey, header, to, { subtitle?, color? })to make a whole row navigate to an in-app route - one realNuxtLinkper row (middle-click and open-in-new-tab work), its labelprimaryby default, with an optional tertiarysubtitleline under it (both truncate). Prefer it overclickable+onSelectwhen the row's destination is a route. Returnnullfromtofor a row with nowhere to go: the label and subtitle render as plain text, with no link or overlay. - Use
createLinkColumn(accessorKey, header, hrefFn)for an external link cell (repo/PR links) - an always-underlinedtext-text-tertiarylink that brightens totext-text-primaryon hover, opening in a new tab withnoopener noreferrer, throughLinkCellrather than a hand-built anchor per table. In a custom cell slot, use<LinkCell :href>for an external link (new tab, trailing arrow, screen-reader "(opens in new tab)") and<LinkCell :to>for an in-app route (same look, same tab, no arrow) that isn't the row's own destination. - Use
createDateColumn(accessorKey, header, { mode?, color? })for a date cell instead of hand-rolling one -members-table.vue's own createdAt/expiresAt cells (a plain formatted date, conditionally colored red when expired) are the real precedent, throughDateCell.modepicks which of apps/frontend's own standardized date rules to render:'date'(the default - a plain calendar date, no time, never relative),'date-at-time'(always absolute, with time),'relative-age'(always relative, ages through minutes/hours/days/months/years),'relative-or-date'(relative under 30 days, then the date), or'relative-or-date-at-time'(relative under an hour, then date+time).colormirrorscreateBadgeColumn's ownvariantcallback, defaulting to'secondary'- a date is supporting metadata, not a de-emphasized count. Passformat(date, row)to supply your own formatter (a translated or app-specific one);modeis ignored when it's given. PassemptyLabel(e.g. "Never", "Not run yet") to replace the-shown for a missing date; it stays tertiary. - Every
create*Columnhelper'sheaderaccepts a plain string or a pre-built header (e.g.createSortableHeader(...)) - passcreateSortableHeader('Repository')as the header tocreateLinkColumn/createBadgeColumn/etc. when a non-standard cell type also needs to be sortable, rather than picking one or the other.
Don't
- Don't reach for
uito change row height or padding as a matter of routine - only for a genuine one-off (e.g. a taller 2-line row). It replaces per slot: each slot string you pass replaces that slot's density default outright (so include every class the slot still needs), while slots you leave out keep their default. A customui.tralso dropsclickable'scursor-pointer. - Don't hand-roll the
data.length === 0header-hiding branch - this component already does it. - Don't use
loadingStyle="skeleton"for a background refetch/page change - that would hide data the user was already looking at. LeaveloadingStyleat itsbardefault there.
- Don't expect a per-column custom
#id-cellslot to render duringloadingStyle="skeleton"- every cell slot is suppressed in favor of the skeleton bar while loading, since the fake placeholder rows don't have real data for a custom cell to render. - Don't use raw Nuxt UI color aliases (
text-muted,text-highlighted) for cell text - use this design system's owntext-text-primary/secondary/tertiarytokens.automations/run-table.vueis the one real table that still does this; it's drift, not a pattern to copy. - Don't put a plain numeric count in
text-text-primaryjust because a similar-looking metric elsewhere in the app does - apps/frontend has real, unresolved inconsistency here (cost tables tier counts as tertiary;developer-usage-table.vuetiers similar metrics as primary). Default to tertiary for a count/metric unless it's genuinely the row's headline value. - Don't hand-roll a plain string
cellfor a value that could run long - with no innertruncate/min-w-0wrapper of its own, it eats through thetd's own padding beforeoverflow-hiddenclips it flush at the border, mid-character and with no ellipsis. UsecreateTruncatedColumn/TruncatedCellinstead, the same way every other cell type here goes through a dedicated builder rather than a barecellfunction. - Don't put an interactive control in a
createRowLinkColumnrow withoutrelative z-10on it - the row's link overlay sits on top and swallows the click, navigating instead. ExpectTruncatedCell's overflow tooltip not to open in such a row either: the overlay, not the cell, receives the hover.
Examples
Variants
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>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>States
Empty
Header hides automatically when data is empty. emptyTitle/emptyDescription render TableEmptyRow (a plain two-line text stack, no icon) 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-title="No repositories match your filters" empty-description="Try adjusting or clearing your filters." /></template>Loading (cold start)
loadingStyle set to skeleton keeps the real header and column widths, replacing each data cell with a one-line skeleton bar (row-select and row-actions columns stay blank) - 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
With no rows to show, 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. With rows already loaded, the same message renders as a strip above them instead.
Failed to load repositories. Try again.
<template> <Table :data="[]" :columns="columns" error error-text="Failed to load repositories. Try again." /></template>Error with retry
retryLabel adds a secondary Button after errorText that emits retry - the caller owns the refetch. Here retry shows the loading bar for a moment, then the error again.
Failed to load repositories.
| Name | Visibility | Findings |
|---|---|---|
| payments-service | Private | 3 |
| auth-gateway | Private | 0 |
| public-docs | Public | 1 |
<template> <Table :data="data" :columns="columns" :error="isError" :loading="isFetching" error-text="Failed to load repositories." retry-label="Retry" @retry="refetch()" /></template>Composition
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>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. 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, 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(opens in new tab) | Private | 3 | Handles card processing and payout reconciliation for the whole platform. | |||
p platform-team auth-gateway | auth-gateway(opens in new tab) | Private | 0 | Central authentication gateway shared by every internal service. | |||
d docs-team public-docs | public-docs(opens in new tab) | 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>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>Row link
createRowLinkColumn(accessorKey, header, to, { subtitle?, color? }) renders a NuxtLink whose after:absolute after:inset-0 overlay makes the whole row navigable, with an optional tertiary subtitle line. The checkbox and kebab trigger carry relative z-10 so they still work on top of the overlay; clicking the findings count navigates the row.
| Repository | Findings | ||
|---|---|---|---|
| payments-servicePrivate | 3 | ||
| auth-gatewayPrivate | 0 | ||
| public-docsPublic | 1 |
<template> <Table :data="data" :columns="[ createRowSelectColumn({ getRowLabel: (row) => row.name }), createRowLinkColumn('name', 'Repository', (row) => `/repositories/${row.original.id}`, { subtitle: (row) => row.original.visibility, }), createNumericColumn('findings', 'Findings'), createActionsColumn((row) => [ { label: 'View', icon: 'i-lucide-eye' }, { label: 'Remove', icon: 'i-lucide-trash', color: 'error' }, ]), ]" /></template>