--- name: data-table-usage description: > Use after component-usage-ux when an app needs the @techsio/ui-kit DataTable — a headless, data-driven grid built on @tanstack/react-table v9 that renders into the presentational Table organism. Covers column defs, sorting, conditional column filters, global search, row selection, column visibility/pinning/reorder, row reorder, tree/expanding rows, inline edit, colSpan/rowSpan, virtualization / infinite scroll and pagination — every feature behind a flag with a callback. metadata: component_version: "1.2.0" type: "core" library: "@techsio/ui-kit" library_version: "0.3.2" requires: "component-usage-ux app-token-overrides table-usage ux-guidelines" sources: "libs/ui/src/organisms/data-table.tsx libs/ui/src/organisms/data-table.helpers.ts libs/ui/stories/organisms/data-table.stories.tsx" --- # @techsio/ui-kit DataTable Usage `DataTable` is the data-driven grid. It owns the TanStack table instance and renders into the presentational `Table` organism, so it inherits every `--color-table-*` / `--padding-table-cell-*` token. Reach for the plain `Table` when you only need static markup; reach for `DataTable` when you need sorting/filtering/selection/pagination and friends. ## UX/UI guidelines House rules come from the `ux-guidelines` skill (writing, formatting, states, where actions and feedback live). This section applies them to `DataTable`. **Use it when** - Collections users search, sort, filter, select, edit inline and page through — the default for admin lists. - Any list that is the main content of a page (orders, products, customers, pages). **Use something else when** | Need | Use instead | | --- | --- | | A small static table (spec sheet, invoice lines) | Table | | Browsing products visually | a ProductCard grid | | Showing a trend | Chart | | Editing a whole record with validation across fields | the drawer/full form, not inline edit | **Do** - Align numeric columns (money, counts, percentages) with `meta: { align: "end" }` and render them with `tabular-nums`; text and dates stay start-aligned (ux-guidelines/formatting#alignment-in-tables). - Format cells with the app's `Intl` formatters; render missing values as `—`. - Row click = read (detail drawer); edit and delete live in `rowActions`, delete last with `tone: "danger"`. - Show bulk actions only while rows are selected; confirm destructive bulk actions with the count. - Design all three empty states (first use, no results, load error). `translations.emptyTitle` / `emptyDescription` hold one message, so pick it from the app's state on each render, or use `renderEmpty` to render a different state (with `Clear filters` / `Try again`) per case — never let a failed load read as `No records`. - Choose `size` per page: `sm` for scanning, `md` when rows are edited inline. **Don't** - Build a separate filter bar when column filters (`enableColumnFilters` + `meta.type`) fit. - Blank the table while refreshing — keep rows and show progress in the toolbar. - Center numbers or right-align text. - Put more than one primary action in the toolbar; the page's primary lives in the page header. **Copy and states** - Column headers are short nouns in sentence case, with units when shared (`Price (€)`). - Inline edit success → toast ` saved`; errors stay on the cell editor. - Search placeholder `Search …`. ## Setup ```tsx import { DataTable } from "@techsio/ui-kit/organisms/data-table" import type { ColumnDef } from "@techsio/ui-kit/organisms/data-table" type Order = { id: string; customer: string; total: number; status: string } const STATUS_OPTIONS = [ { label: "Paid", value: "paid" }, { label: "Pending", value: "pending" }, ] const columns: ColumnDef[] = [ { accessorKey: "customer", header: "Customer" }, { accessorKey: "total", header: "Total", meta: { align: "end", type: "number" }, cell: (info) => `${info.getValue()} €`, }, { accessorKey: "status", header: "Status", meta: { type: "enum", options: STATUS_OPTIONS }, }, ] open(row.original)} /> ``` ## Column types drive the filter and the editor Declare `meta.type` and DataTable renders the matching ui-kit control in both the header filter row and the inline row editor, at the table's `size`: | `meta.type` | filter control | editor control | |---|---|---| | `string` | Input + condition menu (icon) | Input | | `int` / `number` | Input + condition menu (`between` adds a second Input) | NumericInput | | `boolean` | tri-state Select (All/Yes/No) | Switch | | `enum` | Select (+ "All") | Select | | `multiEnum` | Combobox `multiple` | Combobox `multiple` | | `date` / `datetime` | Input `date` / `datetime-local` | same | | `time` | from/to time Inputs (window may cross midnight) | Input `time` | | `dateRange` | from/to date Inputs | from/to date Inputs | | `custom` | nothing — supply `meta.renderFilter` | supply `meta.renderEditor` | Filter values are objects — `{ operator, value, to? }` for text/number, `{ values: [...] }` for the enum types, `{ value }` for boolean, `{ from, to }` for date/time ranges. A bare value from the plain TanStack API (`column.setFilterValue("Ada")`, or a controlled `columnFilters` entry) is coerced to the right shape for every type: an array becomes `{ values }`, a lone date/time becomes a closed range on itself (a whole day for `date`, that exact minute for `time`), and `"true"` / `"false"` strings are parsed rather than coerced. Give `enum`/`multiEnum` their choices via `meta.options`. Register `filterFn: "typed"` on the column so filtering matches the declared type (`time` compares minutes-since-midnight; a `dateRange` cell compares interval overlap). There is no date-picker component yet, so date/time fields use the native `Input` types. The filter row puts the value control first and the operator behind a compact icon button (a Menu of conditions), so the input gets the width and the header stays on one line. The active condition is in the button's `aria-label`, and for `Is empty` / `Is not empty` the input is disabled and shows the condition as its placeholder. Row actions use the `Button` atom icon-only at `size="sm"`, `theme="borderless"`, with `variant` carrying the semantics (`danger` for destructive actions). Escape hatches, in precedence order: `meta.renderFilter` / `meta.renderEditor` per column → the table-wide `renderHeaderFilter` slot → `filterRenderers` / `editorRenderers` maps → the type default. All receive a context with `{ column, type, value, setValue, disabled, size, options }` (editors also get `row`, `error`, `commit`, `cancel`). ## Column widths and alignment ```tsx { accessorKey: "age", meta: { width: 80, align: "end" } } { accessorKey: "email", meta: { width: "var(--dimension-200)", minWidth: 120 } } { accessorKey: "active", meta: { width: "15%", align: "center" } } ``` `meta.width` / `meta.minWidth` / `meta.maxWidth` take a number (px) or any CSS length, so tokens, `%` and `ch` all work. Pair them with `tableLayout="fixed"` — under the default `"auto"` a width is only a hint and long content can still stretch the column. Use `meta.width`, not TanStack's `columnDef.size`: TanStack merges `size: 150` into every column def, so `size` cannot express "no width declared". Numeric widths are mirrored into `size`/`minSize`/`maxSize` internally, which keeps the rendered width and the sticky offsets of pinned columns in agreement. While resizing is on the live dragged width wins. Give **pinned (frozen) columns a numeric width**. Sticky offsets are summed from the numeric sizes, so a `%` or token width on a frozen column cannot be resolved to pixels and the frozen block can misalign. Unpinned columns take any unit. `meta.align` (`start | center | end`, default `start`) is forwarded to the `Table` cell as `data-align`, which is where the alignment is actually styled — so a hand-written `Table` gets the same three options. Nothing is inferred from the column type: center an icon/boolean column or right-align a number only if you say so. `Table`'s older `numeric` prop still right-aligns, but it means "this value is a number"; set one or the other, not both. ## Inline editing and interaction locking The table renders read-only until the user opts in: the right-hand actions cell holds an edit icon, and clicking it swaps that row's editable cells (`meta.editable`) to type-driven editors with save and cancel beside them. `enableInlineEdit` is what wires this up. One row is editable at a time; Enter commits, Escape cancels. Apply the committed `draft` to your own state in `onEditCommit` — DataTable does not mutate `data`. For a column that should always be an editor instead, skip `enableInlineEdit` and render your own control in `columnDef.cell`, pushing values through `table.options.meta.updateData` (see "Inline edit" below). Validation runs on commit from `meta.required` and `meta.validate(value, draft)`; failures block the commit and surface through `onEditValidationError`. While a row is being edited, `lockInteractionsWhileEditing` (default `true`) disables sorting, column filters, global search, pagination, selection, row and column reorder, and row click — anything that could move the row out from under the user. Blocked attempts report through `onInteractionBlocked({ action, reason: "editing", rowId })`, but only for `globalFilter`, `paginate`, `columnVisibility` and `rowClick`. Every other locked control — sort, column filters, selection, both reorder flavours — is natively `disabled` or non-draggable during an edit, so the interaction never reaches a handler and there is nothing to report; `globalFilter` is the one exception, reachable through `SearchForm`'s clear button even while its input is disabled. Filtering and sorting still compose freely with each other when no edit is active. Edit callbacks: `onEditStart`, `onEditChange`, `onEditCommit`, `onEditCancel` (with `dirty`), `onEditValidationError`, plus controlled `editingRowId` / `onEditingRowIdChange`. Slot return contract: `renderRowActions` and `renderHeaderFilter` treat `undefined` as "not handling this one" (falls through to the built-in edit button / the type-driven filter) and `null` as "render nothing here". ## Toolbar The toolbar is one row: the global search stretches to fill the free width, and custom actions sit at the trailing edge in a flex group. ```tsx ``` Each entry takes the full `Button` API (minus `size`, which follows the table); `label` is a convenience alias for `children`. Keep to `DATA_TABLE_MAX_TOOLBAR_ACTIONS` (3) — more still render, but DataTable `console.warn`s, because a crowded toolbar usually means the extras belong in a menu. It is a recommendation, not an error. The search is the `SearchForm` molecule: a clear button appears inside the field once there is a value, and a submit button is joined to its trailing edge with the touching corners squared off (`gapped={false}`). Filtering is live on every keystroke, so the submit button is a confirm affordance rather than the trigger. `translations.searchLabel`, `searchPlaceholder`, `clearSearchLabel` and `searchButtonLabel` cover its text. ## Loading states - `loading` replaces the body with `loadingRowCount` skeleton rows (default 5) while keeping the header, so the layout does not jump when data arrives. - `loadingMore` appends a single skeleton row — pair it with `onReachEnd` for infinite scroll so the user sees the next page being fetched. ## Drag affordances Reorder handles are always rendered but stay fully transparent until the row or header is hovered or receives focus, so the table stays calm while still being discoverable. They also reveal while their row/header is being dragged, so the handle does not vanish when the pointer leaves the source. During a drag the source is dimmed and lifted (`data-dragging`), and the drop target shows an insertion edge — a border on the leading or trailing side for columns, top or bottom for rows — so it is clear where the item will land. ## Accessibility Sortable headers carry `aria-sort`; the expander carries `aria-expanded`; the table carries `aria-busy` while loading and skeleton rows are hidden from assistive tech. Rows with `onRowClick` are focusable and activate on Enter or Space (the handler ignores keys bubbling from controls inside the row). Both drag handles receive dnd-kit's keyboard attributes, so reordering works without a mouse. Pass `getRowLabel` so selection checkboxes and the edit action are labelled by row content instead of an opaque row id. Inline-edit validation messages render next to the field with `role="alert"` and are linked through `aria-describedby`; focus moves into the edited row on start and returns to the control that opened it on commit or cancel. ## Sizing `size` (`sm | md | lg`) is forwarded to the underlying `Table` **and** to every nested control — filter inputs, inline editors, page-size select, pagination, action icons and the column menu — so the whole table scales as one. `paginationProps` exposes the full `Pagination` molecule API (variant, compact, siblingCount, translations, …) except the table-owned count/page/pageSize. The footer splits: the record range sits on the left, the pager and page-size select on the right. It shares the header's background so the two frame the table consistently. `translations.rangeLabel({ start, end, total })` builds the range text; `translations.pageSizeLabel` is the page-size select's accessible name (the design shows no visible label beside it). ## Feature flags (all opt-in unless noted) - `enableSorting` (default `true`) — click header to sort; `meta.align: "end"` right-aligns numeric columns. - `enableGlobalFilter` — renders the toolbar search (`DataTable.GlobalSearch`). - `enableColumnFilters` — renders a per-column filter row. Pick the control with `meta.type` (`"string" | "int" | "number" | "boolean" | "enum" | "multiEnum" | "date" | "datetime" | "dateRange" | "time" | "custom"`), plus `meta.options` for the enum types. Columns get `filterFn: "typed"` by default, which dispatches on that same `meta.type`; set `filterFn: "conditional"` for the operator-based ("with conditions") comparator instead, or override the whole UI with `meta.renderFilter` / `renderHeaderFilter`. `meta.filterVariant` and `meta.filterOptions` are deprecated aliases kept for older columns: both still work — `filterVariant` is resolved to a `meta.type` (`text`→`string`, `number`/`range`→`number`, `select`→`enum`) and selects that type's control and matcher, and `filterOptions` is used when `options` is absent. Prefer `meta.type` / `meta.options` in new code. - `enableRowSelection` — injects a leading checkbox column; header checkbox toggles all. Constrain it with `selectionMode: "single" | "multiple"` (single replaces the selection), `maxSelectedRows: N` (a hard cap — unselected rows disable once it is reached, selected ones stay deselectable, and `onSelectionLimitReached` fires) and/or `canSelectRow(row, { selectedCount, isSelected })` for rules those two can't express. All three compose; the select-all header checkbox is hidden unless selection is unbounded multiple. - `enableColumnVisibility` — an icon-only cog button (tooltipped with `translations.columnsLabel`) opening a checkbox list of hideable columns. The list stays open while toggling, so several columns can be hidden without reopening it. - `enableColumnPinning` + controlled `columnPinning` — freeze columns to either edge (sticky, with an edge shadow). TanStack Table v9 names the two sides logically, so the state is `{ start: string[], end: string[] }` (v8's `left`/`right`) and pinned cells carry `data-pinned="start" | "end"`. The sticky offsets use `insetInlineStart`/`insetInlineEnd`, so a start-pinned column freezes against the right edge in RTL. - `enableRowSelection` shift-click range selection is **off**. TanStack v9 enables it by default, but it applies a whole range in one update and would sail past `maxSelectedRows`; DataTable sets `enableRowRangeSelection: false` until it can offer a cap-aware version. - Column `filterFn` accepts `"conditional"`, `"typed"` and `"includesString"` by name. Other built-in comparators are not registered on the table's feature set (registering them all would pull every built-in into the bundle) — pass the function itself instead, which needs no registration. `sortFn: "auto"` works: `alphanumeric`, `datetime` and `text` are registered. - `enableColumnReorder` — drag column headers (dnd-kit); fires `onColumnReorder`. - `enableRowReorder` — injects a drag handle column; fires `onRowReorder`. - `enableExpanding` — the expand toggle lives in the trailing actions cell, so it never collides with the selection checkbox. Pair with `getSubRows` for tree rows, or with `renderExpandedRow` alone for master-detail (every row becomes expandable; narrow it with `getRowCanExpand`). The detail renders as one full-width cell spanning every column, containing a plain `div` — put any layout inside, no nested table cells. - `enablePagination` — renders `DataTable.Pagination` ("start–end of total" + page-size select + pager). Configure `pageSizeOptions`. - `enableVirtualization` + `maxHeight` — windowed rendering for large datasets (keeps native column alignment). Set `estimateRowHeight`. - `enableColumnResizing` — draggable column widths (`columnResizeMode: "onChange"`); widths are applied inline per cell. - `enableInlineEdit` — type-driven row editing with an edit/save/cancel actions cell (see above). - `stickyActions` (default `true`) — pin the row-actions cell to the right edge. - `hideHeader` — render without the column header row(s). - `onReachEnd` + `maxHeight` — infinite scroll; called once when scrolled near the bottom. Server-driven data: set `manualSorting` / `manualFiltering` / `manualPagination` and supply `rowCount` (or `pageCount`) so pagination totals stay correct. Prefer `rowCount` — it makes the "start–end of total" label exact. With only `pageCount`, the total is derived as `pageCount * pageSize`, so every page is reachable but the figure is an upper bound on the last page. ## Callbacks (for interaction tests + app wiring) Every stateful feature is controllable via a `state` + `onXChange` pair and also exposes a plain callback: `onRowClick`, `onSortingChange`, `onColumnFiltersChange`, `onGlobalFilterChange`, `onRowSelectionChange`, `onColumnVisibilityChange`, `onColumnOrderChange`, `onColumnPinningChange`, `onExpandedChange`, `onPaginationChange`, `onColumnReorder`, `onRowReorder`, `onCellEditCommit`, and `onReady(table)`. `onColumnReorder`'s `from`/`to`/`order` are relative to your own `columns` array — the injected selection and drag-handle columns are stripped out — so `arrayMove(myColumns, from, to)` reproduces the new order directly. `onReady` fires once on mount. Its methods stay live, but read `table.state` and `table.options` off it with care: `useTable` hands back a spread copy, so those two fields are a mount-time snapshot. Use `renderToolbar` for live state. ## Slots (open DOM paths) `renderToolbar(table)`, `renderEmpty()`, `renderRowActions(row)`, `renderHeaderFilter(column)`, `renderExpandedRow(row)`, and `slotProps.{root,header,body,row}` for className/data-attr/ref passthrough. Compose the toolbar/pagination yourself with `DataTable.Toolbar`, `DataTable.GlobalSearch`, `DataTable.ColumnVisibility`, `DataTable.Pagination`. ## colSpan / rowSpan TanStack has no body-cell spanning model. Pass `getCellSpan(cell, ctx)` returning `{ colSpan?, rowSpan?, hidden? }`; mark cells swallowed by a span with `hidden: true`. ## Inline edit Set `meta.editable` on a column and render an editable control in `columnDef.cell` that calls `table.options.meta.updateData(rowId, columnId, value)`; DataTable forwards it to `onCellEditCommit`. ## Presentation `variant` (`line | outline | striped`), `size` (`sm | md | lg`), `stickyHeader`, `showColumnBorder`, `hideHeader`, `caption` — all forwarded to the underlying `Table`. Use `stickyHeader` with `maxHeight` for a scrolling body and `hideHeader` for headerless layouts. The whole grid is one rounded card: the wrapper carries `rounded-table` and clips its children, so the corners are correct with a toolbar, a pagination footer, both, or neither — including the empty state. `variant="outline"` draws its border on that wrapper rather than on the ``, so the toolbar and footer sit inside the outline instead of beside it. The wrapper always draws a border in `--color-table-border`, the same colour as the row separators, so the grid reads as one bounded block. `variant="outline"` adds the outline shadow on top of it. `striped` is a standalone boolean, so zebra rows compose with any variant — prefer it over `variant="striped"`, which cannot be combined with `outline`. A striped row drops its own bottom border (the alternating background is already a separator; drawing both looked doubled-up), so an unstriped table still has the row-divider border and a striped one doesn't. `tintNestedRows` shades rows by `row.depth` — a tree (`getSubRows`) or expanded-detail child reads as "inside" its parent instead of just being the next row. Layered as an inset shadow rather than a background, so it composes with `striped` instead of losing the conflict against its zebra background. Setting `onRowClick` automatically makes rows interactive (pointer cursor + hover background); the affordance is dropped while an inline edit locks row clicks, so rows never look clickable when they are not. ## Don'ts - Do not hardcode colors/padding — the grid is fully tokenised via `Table`. - Do not reach for a canvas grid (VTable/S2): they cannot use our Tailwind tokens. - Do not mutate `data` in place for row reorder — apply the `onRowReorder` `data` result to your state.