Components / Data display
DataTable
Sortable, resizable, filterable grid with selection, tree rows, pagination or virtual scrolling with infinite loading, typed columns, row actions, copy and export.
@ruckc/truss-ui Data display
Import
import { DataTable } from '@ruckc/truss-ui'; Playground
Recipe: custom cells for some columns
Give a column its own cell snippet to render only that column; it receives the row and wins over the table-level cell. Every other column keeps the default rendering.
Status | Due | Score | |
|---|---|---|---|
Alpha | Live | Mar 1, 2024 | 1,234.5 |
Beta | Draft | Mar 2, 2024 |
const columns: Column<Row>[] = [
{ key: 'name', header: 'Name' },
{ key: 'status', header: 'Status', cell: statusCell }, // only this column
{ key: 'due', header: 'Due', type: 'date' } // default rendering
];
{#snippet statusCell(row)}<Badge intent="success">{row.status}</Badge>{/snippet} Recipe: a table-level cell with a default fallback
The table-level cell snippet is called for every column without its own cell. Its third argument, text, is the default display text, so you customise some columns and render {text} for the rest instead of
reformatting numbers and dates yourself. Custom cells are display only: sorting, filtering and CSV / JSON export keep using the raw value and Column.format.
Status | Due | Score | |
|---|---|---|---|
Alpha | Live | Mar 1, 2024 | 1,234.5 |
Beta | Draft | Mar 2, 2024 |
<DataTable {columns} {rows} {rowKey}>
{#snippet cell(row, column, text)}
{#if column.key === 'name'}
<a href="/projects/{row.id}">{row.name}</a>
{:else}
{text} <!-- the default: Column.type, Column.format and locale applied -->
{/if}
{/snippet}
</DataTable> Recipe: fill the parent's height
maxHeight takes any CSS length, but a percentage such as maxHeight="100%" has nothing to resolve against while the
table itself is as tall as its content, so the scroll area is unbounded and a virtual table renders every row. Use height="fill" instead: the table takes the full height of its parent and the rows scroll inside it, also when there are only a few rows
(maxHeight is ignored). The parent must have a definite height: a fixed size, a flex or grid item sized by its container
(give a flex item min-height: 0), or a height: 100% chain up to a sized ancestor. With virtual, the
window and keyboard paging follow the parent as it resizes.
<!-- the parent needs a definite height; a flex item needs min-height: 0 -->
<div style="display:flex; flex-direction:column; height:100vh">
<header>…</header>
<div style="flex:1; min-height:0">
<DataTable caption="Orders" {columns} {rows} {rowKey} virtual pagination={false} height="fill" />
</div>
</div> Recipe: inline cell editing
Mark columns editable (or give a function to decide per row). Press the Right Arrow on a row to move into its cells, then Enter or F2
(or double-click) to edit. Enter saves, Escape cancels, Tab and Shift+Tab save and jump to the next or previous editable cell, and clicking
elsewhere saves. The default editor is an Input, a NumberInput for type: 'number' (the value is a number or null), or a
Select when the column has options (strings or { value, label }). Each editor is labelled by the column header and the
grid announces edit mode to screen readers. The table never changes rows: apply the value in oncommit(row, key, value),
which is not called when the value is unchanged. Row focus, onactivate, rowMenu, copy and selection behave as before.
Name | Qty | Status |
|---|---|---|
Bolts | 120 | open |
Nuts | 80 | done |
Washers | open |
Last commit: none yet
const columns: Column<Item>[] = [
{ key: 'name', header: 'Name', editable: true }, // Input
{ key: 'qty', header: 'Qty', type: 'number', editable: (r) => !r.locked }, // NumberInput, per row
{ key: 'status', header: 'Status', editable: true, options: ['open', 'done'] } // Select
];
<DataTable {columns} {rows} {rowKey}
oncommit={(row, key, value) => (rows = rows.map((r) => (r === row ? { ...r, [key]: value } : r)))} /> For other editors, pass an editCell snippet. It receives { row, column, value, set, commit, cancel }: call set(v) as the user types so Enter, Tab and click-outside save it, commit(v) to save immediately, or cancel(). The first
focusable element in the snippet gets focus.
<DataTable {columns} {rows} {rowKey} {oncommit}>
{#snippet editCell({ row, column, value, set, commit, cancel })}
<input aria-label={column.header} {value} oninput={(e) => set(e.currentTarget.value.trim())} />
<button type="button" onclick={() => commit(null)}>Clear</button>
{/snippet}
</DataTable> Recipe: displayValue
displayValue(row, column, locale?) is the function the table uses for default cell text: Column.format if set,
otherwise the value formatted by Column.type (numbers, dates and datetimes follow locale; null shows nothing). It is
exported from the package, so a snippet can call it directly, for example to build a label that mixes default text with your own markup: 1,234.5.
import { displayValue } from '@ruckc/truss-ui';
{displayValue(row, column, locale)} // same text the table shows by default API
| Prop | Type | Default | Description |
|---|---|---|---|
columns required | Column<T>[] | — | Column definitions: key, header, width, sortable, resizable, filter… |
rows required | T[] | — | The data rows. |
rowKey required | (row: T) => string | — | Returns a unique, stable id for a row. |
getChildren | (row: T) => T[] | undefined | — | Provide to render a tree-grid; first column gets indentation + expander. |
cell snippet | Snippet<[row: T, column: Column<T>, text: string]> | — | Custom cell renderer for every column, e.g. status badges. A column's own `cell` wins over it. The third argument is the default text for the cell (what `displayValue(row, column, locale)` returns: `Column.type`, `Column.format` and `locale` applied), so a snippet can customise some columns and render `{text}` for the rest. Display only: sorting, filtering and export keep using the raw value. |
selected bindable | string | — | Active (highlighted) row id. |
selectable | boolean | false | Show checkbox column; `checked` holds the checked row ids. |
checked bindable | string[] | [] | Whether it is checked. Bind it with `bind:checked`. |
columnMenu | boolean | false | Show a column visibility menu. |
toolbar snippet | Snippet | — | Extra controls, as a Svelte snippet. |
caption required | string | — | Accessible caption (visually hidden). |
maxHeight | string | '420px' | Height cap of the scrolling area; any CSS length (`'420px'`, `'60vh'`). Virtual mode needs it bounded. A percentage only resolves when the table itself has a height, so to fill a parent use `height="fill"` instead. |
height | 'auto' | 'fill' | 'auto' | `'auto'` (default): as tall as the rows, up to `maxHeight`. `'fill'`: the table takes the full height of its parent (`maxHeight` is ignored) and the rows scroll inside it, also when the content is shorter. The parent needs a definite height: a fixed size, a flex or grid item that is sized by its container (give a flex item `min-height: 0`), or a `height: 100%` chain up to a sized ancestor. Works with `virtual`: the window follows the parent as it resizes. |
pagination | boolean | true | Page the rows with a floating pager. Turn off for one continuous (optionally virtual) list. |
pageSize | number | 10 | Rows per page (when paginated). |
virtual | boolean | false | Render only the rows in view (fixed `rowHeight`). Needs a bounded `maxHeight`. Use with `pagination={false}`. |
rowHeight | number | — | px; every row is exactly this tall when `virtual`. Defaults to the `--tr-row-h` density token (32px). |
overscan | number | 6 | Extra rows rendered above/below the viewport. |
hasMore | boolean | false | Infinite scroll: more rows exist beyond `rows`. |
loading | boolean | false | Infinite scroll: a fetch is in flight (shows a spinner row). |
loadThreshold | number | 8 | Request more when within this many rows of the bottom. |
onloadmore event | () => void | Promise<void> | — | Called near the end of the list. Append to `rows` (or flip `hasMore` off); it is not called again until `rows.length` changes. |
stale | boolean | false | The rows are out of date while a refresh loads: dims them but keeps them visible and interactive, and marks the table `aria-busy`. |
status snippet | Snippet<[info: { count: number; hasMore: boolean; loading: boolean }]> | — | Status text in the bar (paged or continuous), e.g. "1,240 rows · streaming…". Replaces the default row count. |
oncancel event | () => void | — | Shows a Cancel button in the loading row; call it to abort the in-flight fetch. |
onactivate event | (row: T) => void | — | Enter or double-click on a row (Space only selects). |
onfocusrow event | (row: T) => void | — | A row received focus (mouse or keyboard); not called for focus inside a row's inner controls. |
rowMenu | (row: T) => MenuEntry[] | — | Context menu for a row: right-click, the Menu key or Shift+F10. Return an empty list to keep the browser's menu. |
editCell snippet | Snippet<[edit: { row: T; column: Column<T>; value: unknown; set: (value: unknown) => void; commit: (value?: unknown) => void; cancel: () => void }]> | — | Custom editor for an editable cell (see `Column.editable`), replacing the default Input / NumberInput / Select. `value` is the cell's current value; call `set(v)` as the user types so Enter, Tab and click-outside save it, or `commit(v)` to save right away (`commit()` saves the last `set` value); `cancel()` discards. Escape always cancels. The first focusable element inside the snippet receives focus. Label it with `column.header`. |
oncommit event | (row: T, key: Column<T>['key'], value: unknown) => void | — | An edit was saved. The table never changes `rows`: apply `value` to your data. Not called when the value is unchanged. |
locale | string | — | BCP 47 locale for default number and date formatting (default: the runtime locale). |
headerExtra snippet | Snippet<[column: Column<T>]> | — | Rendered under each column's label, e.g. a type glyph, null % or a sparkline. |
exporters | { label: string; serialize: ExportSerializer<T> }[] | — | Extra entries in the toolbar's Copy / Export menu, e.g. SQL `VALUES`. |
filterable | boolean | 'row' | 'menu' | 'both' | false | Show a filter row; columns opt in with `filter`. Column filtering UI (columns opt in with `filter`): `'row'` (or `true`) adds a filter row under the header; `'menu'` has no row — filters live in each column heading's menu (funnel button, right-click, or Shift+F10); `'both'` offers both. `false` turns filtering off. |
filters bindable | ColumnFilters | {} | Active filters by column key: text → string, select → string[], number → { min, max }. |
sort bindable | SortState | null | Current sort `{ key, dir }` or null. Bind it with `bind:sort`. |
remote | boolean | false | Your data source filters and sorts: the table only reports `filters`/`sort` (bind them) and shows `rows` as-is. |
filterDelay | number | — | ms to debounce typed filters (default 120 client, 300 remote). |
empty | string | 'No matching rows' | Text shown when there are no rows to display. |