Data grid API
import DataGrid, {useDataGrid, toQuery, toQueryKey, dateCell} from '@zuilib/data-grid' // everything
import DataGrid, {useDataGrid, toQuery, fromQuery} from '@zuilib/data-grid/data-grid'
import useDataGrid from '@zuilib/data-grid/use-data-grid'
import {toQuery, fromQuery, toQueryKey, toServerState, toColumnState, fromColumnState} from '@zuilib/data-grid/server-adapter'
import {dateCell, dateTimeCell, createDateCell, textColumn, twoValuesColumn} from '@zuilib/data-grid/cells'
Peer dependencies: React and React DOM 18 or 19. TanStack Table and Headless UI install transitively.
DataGrid
Generic in the row type: <DataGrid<Invoice> ... />. Extends the
<div> attributes; the rest are:
| Prop | Type | Default | Description |
|---|---|---|---|
columnsrequired | DataGridColumnDef<TData>[] | — | TanStack ColumnDefs plus the ZUI extras |
rowsrequired | TData[] | — | The current page |
rowCountrequired | number | — | Total rows on the server, all pages; drives the page count and the range text. Ignored with manualPagination={false}: the rows that pass the filters are counted instead |
state | Partial<DataGridState> | — | The controlled state; missing keys take their defaults. Left out, the grid keeps the state itself |
defaultState | Partial<DataGridState> | — | The initial state when state is left out; read once |
onStateChange | (state: DataGridState) => void | — | Called with the whole next state on every interaction, controlled or not: the state prop as rendered plus one change. An emission that is not applied is not carried into the next one (see the contract) |
getRowId | (row, index, parent?) => string | — | Row identity for selection, expansion and inline edit; defaults to the row index. Required for selection to survive paging: with the default, row 3 of every page is the same key |
manualPagination | boolean | true | Off: TanStack pages `rows` itself |
manualSorting | boolean | true | Off: TanStack sorts `rows` itself |
manualFiltering | boolean | true | Off: TanStack filters `rows` itself |
loading | boolean | false | aria-busy on the table and "Loading…" in the footer; skeleton rows in the current layout while there are no rows yet |
skeletonWhileRefreshing | boolean | false | Show the skeleton rows while loading even when rows are already displayed; off, a refetch keeps the current rows on screen |
error | ReactNode | — | With no rows it replaces the body; with rows it sits above them as a banner. A string is rendered in a danger-tone Alert |
labels | Partial<DataGridLabels> | — | Overrides any of the grid's strings: the footer, the checkbox and button names, the filter forms, the chips, the column manager, the empty state. defaultDataGridLabels holds the English ones; templates carry {count}, {column}, {first} / {last} / {total} or {date} placeholders. See Localisation |
emptyState | ReactNode | — | Replaces the default "No results" EmptyState |
density | 'compact' | 'comfortable' | 'comfortable' | Table size sm / md |
stickyHeader | boolean | false | Header cells stick to the top of the scroll container; bound it with scrollAreaClassName="max-h-96" |
onRowClick | (row: Row<TData>, event) => void | — | Click, Enter or Space on a row. Clicks on an editable cell (enableInlineEdit) stay with the cell: a double-click to edit does not fire it |
onCellCommit | ({rowId, columnId, value}) => void | — | Commit of an inline edit on a column with enableInlineEdit; value is the raw input string |
commitOnBlur | boolean | true | An inline edit that loses focus (a click elsewhere, Tab) is committed; false drops the draft instead |
selectAllMatching | boolean | true | Offer "Select all N" in the selection bar once every row on the page is selected and rowCount exceeds the page; sets allMatching in the state |
locale | string | — | BCP 47 tag for the numbers the grid formats (the range text, the selection counts); the user's locale when left out or when the tag is invalid (with a development warning). Text is labels |
renderExpanded | (row: Row<TData>) => ReactNode | — | Content of the full-width row under an open row; adds the chevron column (EXPAND_COLUMN_ID) after the selection column |
getRowCanExpand | (row: Row<TData>) => boolean | — | Which rows may open; every row when renderExpanded is set and this is left out |
selectionActions | DataGridSelectionAction[] | [] | {id, label, variant?, tone?, onSelect(selectedIds, {allMatching, rowCount})} buttons in the selection bar; with allMatching the action applies to every row matching the query, not only selectedIds |
enableRowSelection | boolean | (row) => boolean | true | Adds the leading checkbox column |
enableColumnResizing | boolean | true | |
pageSizeOptions | number[] | [10, 25, 50, 100] | A current page size outside the list is shown alongside it |
showPagination | boolean | true | Render the footer; false to place DataGrid.Pagination yourself |
tableOptions | Partial<TableOptions<TData>> | — | Anything else for useReactTable (defaultColumn, enableMultiSort, ...). Multi-sort is on by default: Shift-click on a head adds that column to sorting; {enableMultiSort: false} makes every click replace it |
aria-label | string | 'Data grid' | Accessible name of the role="grid" table (or pass aria-labelledby) |
children | ReactNode | — | Rendered above the selection bar and the table: DataGrid.Toolbar, headings, actions |
className | string | — | Merged last onto the root |
tableClassName | string | — | Merged last onto the <table> |
scrollAreaClassName | string | — | Merged last onto the scrolling wrapper |
DataGridState
All keys are TanStack's state shapes. defaultDataGridState holds the
defaults; resolveDataGridState(partial) fills a partial.
| Prop | Type | Default | Description |
|---|---|---|---|
pagination | {pageIndex: number; pageSize: number} | {pageIndex: 0, pageSize: 25} | 0-based page |
sorting | {id: string; desc: boolean}[] | [] | |
columnFilters | {id: string; value: unknown}[] | [] | A string (text), a string[] (select) or {from?, to?} (date-range) per column |
search | string | '' | The toolbar search |
columnVisibility | Record<string, boolean> | {} | |
columnOrder | string[] | [] | Leaf column ids. The selection and expand columns (SELECT_COLUMN_ID, EXPAND_COLUMN_ID) are moved first whatever order comes in |
columnPinning | {start?: string[]; end?: string[]} | {start: [], end: []} | The selection and expand columns are always first in start; the state the grid emits includes them |
rowSelection | Record<string, boolean> | {} | Keyed by getRowId |
expanded | Record<string, boolean> | true | {} | Open rows, keyed by getRowId (TanStack ExpandedState); UI-only, not part of toQuery |
allMatching | boolean | false | "Select all N": every row matching the query is selected, beyond this page. Set by the selection bar, cleared by any other selection change and by a sort / filter / search change; part of toQuery |
columnSizing | Record<string, number> | — | Column widths in px, keyed by column id. Optional: left out, the grid keeps the widths itself; passed (even {}), every resize goes out through onStateChange so it can be persisted. UI-only, not part of toQuery |
A sort, filter or search change also resets pagination.pageIndex to 0.
Column extras
DataGridColumnDef<TData> is TanStack's ColumnDef plus these (also
accepted on meta, TanStack's own extension point). enableSorting,
enableHiding, enableResizing, enableColumnFilter, size, minSize
and maxSize work as in TanStack.
| Prop | Type | Default | Description |
|---|---|---|---|
enableInlineEdit | boolean | — | Double-click / Enter on the cell shows an input (the cell carries aria-readonly="false"); Enter commits, Escape drops the draft, a blur follows commitOnBlur |
align | 'start' | 'center' | 'end' | 'start' | Head and cell alignment |
width | number | — | Initial width in px (TanStack size); the user resizes from there |
filter | DataGridFilter | — | Declares the head's filter popover: {control: 'text', placeholder?}, {control: 'select', options: [{value, label?}]} or {control: 'date-range'}. Ignored (with a one-time console warning in development) on a display column, one with no accessorKey / accessorFn |
meta.label | string | — | Only on meta: the column's name in the column manager, the filter chips and the pin / resize labels when header is not a string (a component, a function); otherwise the string header, then the id |
Cells and column helpers
From @zuilib/data-grid/cells. The renderers go in a column's cell;
the factories return a DataGridColumnDef and pass every other column
option (width, align, filter, enableHiding, ...) through.
| Prop | Type | Description |
|---|---|---|
dateCell | (ctx: CellContext) => ReactNode | The date in the user's locale (dateStyle: 'medium') in a <time dateTime>; - for an empty or invalid value |
dateTimeCell | (ctx: CellContext) => ReactNode | Date and time (dateStyle: 'medium', timeStyle: 'short') |
createDateCell | ({locale?, format?, empty?}) => renderer | Your own preset: a BCP 47 locale, Intl.DateTimeFormatOptions and the empty placeholder |
textColumn | ({field, header, enableSorting?, cell?, ...column}) => DataGridColumnDef | One field as text, sortable by default; field is the accessor and the column id |
twoValuesColumn | ({header, primary, secondary, id?, enableSorting?, ...column}) => DataGridColumnDef | primary / secondary are {field, render?}: the first line in medium weight, the second small and muted; the accessor is primary.field and the column id is id ?? primary.field, so a sort or filter entry in the state carries that id |
Parts
Every part reads the grid from context and must sit inside <DataGrid>.
DataGrid.Toolbar
| Prop | Type | Default | Description |
|---|---|---|---|
showSearch | boolean | true | The search field |
searchPlaceholder | string | labels.searchPlaceholder | 'Search…' by default |
searchDebounceMs | number | 300 | ms after the last keystroke before search is emitted; Enter emits at once |
showFilterChips | boolean | true | A removable chip per active column filter |
showColumnManager | boolean | true | The column manager button, right-aligned |
addableColumns | {id, label}[] | — | Fields the column manager offers under an “Add column” section — see DataGrid.ColumnManager |
onAddColumn | (id: string) => void | — | Called with a picked addable field’s id; you append the column def |
children | ReactNode | — | Rendered between the chips and the column manager |
DataGrid.ColumnManager
A popover button listing every column with a visibility checkbox, up /
down and pin start / end buttons. label (default labels.columns,
'Columns') names the button.
addable (an array of DataGridAddableField, {id, label}) with
onAddColumn adds an Add column
section under the list: fields your data carries that the grid has no
column for yet. The grid can't invent a TanStack column definition, so
picking one only calls onAddColumn(id) — you append the def to columns (and
persist it however you persist the rest of the layout). The
@zuilib/apps data-grid preset wires this to the dataset schema, so
spec-driven grids get add-from-data for free.
DataGrid.SelectionBar
Renders nothing until a row is selected. No props beyond the <div>
attributes; reads selectionActions, selectAllMatching, labels and
locale from the grid. "Select all N" needs setState in the context
(DataGrid provides it).
DataGrid.Body
The <table> alone. Takes stickyHeader, onRowClick, error,
skeletonWhileRefreshing, emptyState, className, scrollAreaClassName
and the aria-label / aria-labelledby of the root.
DataGrid.Pagination
The footer alone; a <nav> named labels.pagination. Takes locale to
override the grid's.
useDataGrid
const {table, state, setState, replaceState} = useDataGrid<Invoice>({
columns, rows, rowCount, state, defaultState, onStateChange, fixedColumnIds,
manualPagination, manualSorting, manualFiltering,
getRowId, enableRowSelection, enableColumnResizing, tableOptions,
})
getRowCanExpand is accepted too. table is the TanStack instance with every on*Change wired to
onStateChange (expansion included). Without state the hook keeps the
state itself from defaultState. fixedColumnIds are the ids kept first
in columnOrder and among the start-pinned columns (DataGrid passes its
selection and expand columns; normalizeColumnState(state, ids) is the
function it uses). setState(key, updater) replaces one key and emits;
replaceState(partial) emits a merged state (a reset, a restore). To render
the parts yourself, provide a DataGridContext value of
{table, state, setState?, loading, density, onCellCommit, renderExpanded, selectionActions, pageSizeOptions, rowCount, labels, locale?, commitOnBlur, selectAllMatching}
(labels is a full DataGridLabels; spread defaultDataGridLabels.
rowCount is what the footer and aria-rowcount show: table.getRowCount()
gives the prop, or the filtered count under client-side paging).
Wrap the parts in an element with @container: the toolbar and footer
respond to that element's width, the way they respond to the DataGrid
root.
Server adapter
| Prop | Type | Description |
|---|---|---|
toQuery | (state: Partial<DataGridState>) => DataGridQuery | {page, pageSize, sort: [{id, desc}], filters, search, allMatching}; page is 1-based, empty filter values are dropped and filters is keyed in column id order; selection, visibility, order, pinning, sizing and expansion are left out. Pure: depends only on toServerState(state) |
toQueryKey | (state: Partial<DataGridState>) => string | toQuery as a stable string: equal for two states whose request would be the same, whatever order their filters were applied in. The dependency for the fetching effect or the cache key |
toServerState | (state: Partial<DataGridState>) => DataGridServerState | The pagination, sorting, columnFilters, search and allMatching of a state |
fromQuery | (query: Partial<DataGridQuery>) => DataGridServerState | The state a query describes; the input is untrusted and it never throws. page / pageSize must be positive integers (else page 1 / the default size); a sort entry without a string id, a non-object filters or a non-string search is ignored; ids and filter values are not validated against the columns |
toColumnState | (state: Partial<DataGridState>) => DataGridColumnState | columnVisibility, columnOrder, columnPinning, columnSizing for a saved view, without the grid's own selection / expand columns |
fromColumnState | (saved: Partial<DataGridColumnState>) => DataGridColumnState | The same keys with the defaults filled in; spread over the state to restore a view |
Accessibility
- A real
<table role="grid">witharia-rowcount(all rows on the server plus every header row),aria-rowindexon every row (header rows, data rows, the error and empty rows),aria-colcountandaria-colindexon every head and cell, andaria-busywhile loading. The column counts cover the visible leaf columns: a column hidden through the column manager is removed from the grid rather than left as a gap, so the visible set is the set the grid presents. - Sortable heads carry
aria-sort; the sort control is a button, the filter control a sibling popover button (never nested). Both stay in the tab sequence. The head cell itself is reached with ArrowUp from the first row; it then holds the grid's roving tab stop, and only that head's resize handle (role="separator"witharia-valuenow/aria-valuemin/aria-valuemax) is a tab stop. - The grid is one tab stop (roving
tabindex: the row, cell or head last focused keeps it, the first row until then). Row checkboxes, expand toggles and editable content aretabindex="-1": Enter / Space on their cell operates them, Escape inside one returns to the cell. On a row, ArrowUp / ArrowDown / Home / End move between rows and Enter / Space activateonRowClick; ArrowRight steps into the cells and the arrows, Home / End (Ctrl for first / last row) move between them. Shift+ArrowRight / Shift+ArrowLeft open / close an expandable row. Rows carryaria-selected; editable cells carryaria-readonly="false". - The expand toggle is a named button with
aria-expanded. - The head checkbox is tri-state (
aria-checked="mixed"); every checkbox, icon button and resize handle has a name, all of them fromlabels. - Pinned columns stick with logical insets and the resize handle sits at
the inline end, so
dir="rtl"mirrors the grid. - The selection count and the range text are
aria-live="polite".
Slots
| Slot | Element | Notes |
|---|---|---|
[data-slot="data-grid"] | div | Root; data-density, data-loading |
[data-slot="data-grid-toolbar"] | div | — |
[data-slot="data-grid-search"] | input | The search (a SearchInput) |
[data-slot="data-grid-filter-chips"] | ul | — |
[data-slot="data-grid-filter-chip"] | span | A Badge; data-column |
[data-slot="data-grid-column-manager"] | div | The popover root |
[data-slot="data-grid-column-manager-button"] | button | — |
[data-slot="data-grid-column-manager-panel"] | div | — |
[data-slot="data-grid-column-manager-row"] | li | data-column |
[data-slot="data-grid-selection-bar"] | div | Only while rows are selected; data-all-matching |
[data-slot="data-grid-selection-count"] | span | — |
[data-slot="data-grid-selection-select-all"] | button | The "Select all N" button; only while the page is selected and more rows match |
[data-slot="data-grid-selection-action"] | button | data-action = the action id |
[data-slot="data-grid-selection-clear"] | button | — |
[data-slot="data-grid-table"] | table | role="grid"; data-density, data-loading |
[data-slot="data-grid-header"] | thead | — |
[data-slot="data-grid-header-row"] | tr | aria-rowindex; data-leaf on the row of leaf heads |
[data-slot="data-grid-header-cell"] | th | data-column, data-sort, data-pinned, aria-sort, aria-colindex |
[data-slot="data-grid-sort-button"] | button | data-sort |
[data-slot="data-grid-sort-icon"] | svg | — |
[data-slot="data-grid-header-label"] | span | An unsortable head |
[data-slot="data-grid-filter"] | div | The filter popover root |
[data-slot="data-grid-filter-button"] | button | data-active while a filter is set |
[data-slot="data-grid-filter-panel"] | div | — |
[data-slot="data-grid-filter-form"] | form | — |
[data-slot="data-grid-resize-handle"] | div | role="separator" |
[data-slot="data-grid-body"] | tbody | — |
[data-slot="data-grid-row"] | tr | data-row-id, data-selected, data-expanded, aria-selected, aria-rowindex |
[data-slot="data-grid-cell"] | td | role="gridcell"; data-column, data-pinned, aria-colindex; data-editable, data-editing, aria-readonly on an editable cell |
[data-slot="data-grid-cell-content"] | div | The content of an editable cell at rest; data-editable |
[data-slot="data-grid-cell-editor"] | div | The Input while editing |
[data-slot="data-grid-select-all"] | span | The head Checkbox |
[data-slot="data-grid-select-row"] | span | A row Checkbox |
[data-slot="data-grid-expand-toggle"] | button | The chevron; aria-expanded, data-expanded |
[data-slot="data-grid-expanded-row"] | tr | The full-width row under an open row; data-row-id |
[data-slot="data-grid-expanded-cell"] | td | Spans every column; holds renderExpanded |
[data-slot="data-grid-date-cell"] | time | span | dateCell / dateTimeCell; a span with data-empty when there is no value |
[data-slot="data-grid-text-cell"] | span | textColumn default cell |
[data-slot="data-grid-two-values"] | div | twoValuesColumn stack, with -primary and -secondary spans |
[data-slot="data-grid-skeleton-row"] | tr | — |
[data-slot="data-grid-message-row"] | tr | The row holding the error or the empty state; aria-rowindex |
[data-slot="data-grid-error"] | td | — |
[data-slot="data-grid-empty"] | td | — |
[data-slot="data-grid-pagination"] | nav | — |
[data-slot="data-grid-page-size"] | label | — |
[data-slot="data-grid-range"] | p | 1–25 of 1,240 |
[data-slot="data-grid-page-buttons"] | div | — |
[data-slot="data-grid-page-indicator"] | span | — |