Components / Data Table

Data Table

Every tabular area list: accounts, live posts, invoices. One component owns the sticky header, sorting, skeleton loading, row click, selection, and infinite scroll.

import { DataTable, type DataTableColumn } from '@layers/ui/data-table'

Rendered stock

House rules

  • `fill` and `maxHeight` are alternatives and `fill` wins: with `fill` the wrap becomes `max-h-full` and `maxHeight` is ignored entirely (including the measured filtered-note subtraction, which the flex chain makes unnecessary). Use `fill` on a CanvasSheet page inside <CanvasSheetBody fill>; use `maxHeight` only where a real budget exists — `100%` in a dialog flex chain, or a useScrollAreaHeight pixel value below other content in a shared scroller.
  • NO hand-rolled <table>, ever (founder ruling 2026-07-31). Not "for now", not "it is only six columns". A hand-rolled table re-implements sorting, skeletons, the sticky header, the empty row and the scroll region — and gets them wrong quietly: the accounts table shipped a cell with no matching <th>/<col>, so every header sat one column left and a phantom column opened down the right.
  • Anything rendered BELOW the scroll region is SUBTRACTED from maxHeight, and the amount is MEASURED, never guessed (founder ruling 2026-07-31). maxHeight is a budget against the viewport; a footer outside the box spends budget the box already claimed, so the box grows full-height and jams the footer against the bottom edge. This component measures its filtered note with a ResizeObserver. Two hardcoded reserves were tried first and both were wrong, because the height depends on font scale, wrapping and the caller's padding — a magic constant here is a bug with a delay on it.
  • Multi-level ordering goes in ONE sortValue, not two comparators. DataTable sorts on a single key, so compose it: the accounts table returns `<kind-rank> <handle>` to order by ownership band and then alphabetically, which still inverts coherently when the header is clicked.
  • When a filter is narrowing the table, pass `filtered` (2026-07-31). It renders "Showing 4 of 31 accounts · View all" BELOW the scroll box. A filtered table otherwise just looks like a short table — nothing on screen says rows are hidden unless the user reads the toolbar and remembers what they set, and the toolbar scrolls away above a long table.
  • The note belongs in this component, not at the call site: DataTable owns the max-height scroll region, so a caller appending its own footer puts it inside that box, where it lands off-screen exactly when the table is long enough for the note to matter.
  • When only SOME rows can be acted on, pass `isRowSelectable` alongside `selection` — the selection twin of `isRowClickable`. A row it excludes renders no checkbox and select-all skips it, so the checked boxes, the selection count, and the bulk actions describe the same set. The pending-approvals queue shipped the counterexample: held approvals stayed checkbox-selectable while the bulk bar counted only decidable rows, so a checked box could sit over a count that ignored it.
  • When only SOME rows open something, pass `isRowClickable` alongside `onRowClick` (2026-07-31). `onRowClick` is a table-level prop, so it paints every row with the pointer cursor — including rows that lead nowhere. The accounts table shipped exactly that: clicking a tracked creator account wrote `detailId` and opened nothing, because we hold no credentials for one and the layout refuses to mount a settings sheet for it. A cursor that promises a door there is not is the dead affordance rules.md forbids.
  • DataTable is the one table. Area lists render through it, never a hand-rolled table element or a stack of row divs.
  • The table is the one scroll region, and how it is capped depends on the surface. On a CanvasSheet page do not compute a height: pass `fill` and put the page in <CanvasSheetBody fill> — the table then caps exactly where the sheet's own 40px bottom padding starts, at any chrome height and any viewport, and a short table stays short. A viewport constant has to assume a chrome height, so it is wrong by the difference on every sheet without that chrome (56px of dead space under a toolbar page's table, 88px under a chrome-only page's). Inside a Dialog the section fills the dialog body with a flex chain and the table takes maxHeight="100%" (invoices-section is the reference). When a table sits BELOW real content in a shared scrolling container, cap it to the container's visible height with useScrollAreaHeight (wallet-section is the reference) — filling the flex remainder there squeezed that table to 60px. The sticky header only works when the table scrolls, not the page.
  • Numeric columns set align="right" and cellClassName="tabular-nums".
  • Use `pinnedTopRow` for a reference such as All installs. It stays below the measured header while the body scrolls, and stays outside sorting, row actions and selection. Do not also include it in `data`.
  • A column with multiple lines supplies `loadingCell` with Skeleton elements matching its loaded height. The default loading cell is a single line.
  • A column whose name needs a definition passes `headerHelp`: one sentence, shown by a (?) beside the header. It sits outside the sort button, so a sortable column can carry one too.
  • Sorting is a two-state toggle between ascending and descending. Uncontrolled tables supply sortValue per column; server-sorted tables pass sortField, sortDirection, and onSort with pre-sorted data.
  • List data behind the table is cursor-paginated; infinite scroll goes through onScroll plus isFetchingMore, and loading renders skeleton rows via isLoading.
  • A row that breaks down into parts passes `subRows` (2026-10-02, the MMA studio scorecard: each app's iOS and Android rows under its total). Children draw under their parent through the same columns, on the raised surface; sorting orders the parents and each keeps its children beneath it. `expandedIds` names the parents whose children show; the caller owns the disclosure button in the parent's first cell (with `aria-expanded`), so expansion can live in state or the URL. Omit `expandedIds` to show every child.

See also