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

filterable
rows
pageSize

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.

Per-column cell
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.

Table-level cell
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.

3 rows
Editable items
Name
Qty
Status
Bolts
120
open
Nuts
80
done
Washers
open
Editable cells: press Right Arrow to move into a row's cells, then Enter or F2 to edit.

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

PropTypeDefaultDescription
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 booleanfalseShow checkbox column; `checked` holds the checked row ids.
checked bindable string[][]Whether it is checked. Bind it with `bind:checked`.
columnMenu booleanfalseShow 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 booleantruePage the rows with a floating pager. Turn off for one continuous (optionally virtual) list.
pageSize number10Rows per page (when paginated).
virtual booleanfalseRender 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 number6Extra rows rendered above/below the viewport.
hasMore booleanfalseInfinite scroll: more rows exist beyond `rows`.
loading booleanfalseInfinite scroll: a fetch is in flight (shows a spinner row).
loadThreshold number8Request 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 booleanfalseThe 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'falseShow 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 SortStatenullCurrent sort `{ key, dir }` or null. Bind it with `bind:sort`.
remote booleanfalseYour 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.
/docs/data-table Truss UI · MIT