component — display

Data Table

A client-side data table over the TanStack row model: the consumer holds every row and the model does the work — sorting (three-state and multi-column), global and per-column filtering with faceted values, pagination, grouping with aggregates, row expansion and tree data, selection that survives paging, column resize, pinning, reordering and a saved layout, row virtualization, CSV export of what it actually holds, and editing bound through Conform and valibot — one cell at a time, in place and at the size of the row it is in, so opening a cell moves nothing and a number cell has no +/- pair to find room for, where the control is whichever conform-* variant the column names and the keyboard is a data table's (a click, Enter, F2 or typing opens a cell, a picked value commits on its own, Tab commits and moves to the next editable one, Escape restores it). Rows and chrome come from Table Shell and Table Controls; its server-driven twin, Server Table, is assembled from the same two. Which of the two you want is a question about where the rows come from, not how many there are.

  • table
  • data
  • grid
  • sort
  • filter
  • pagination
  • selection
  • grouping
  • virtualization
  • export
  • conform
  • editing
  • tanstack
  • interactive

18 examples

Columns, cells and footers

Accessor columns (by key and by function), a display column, a banded header whose bands are single cells spanning the columns under them, per-column alignment and width, truncation with a tooltip, an empty-value placeholder, and a footer total that follows the filters. The band row needs the client — react-aria's server path cannot build a parent column — so the prerendered HTML carries the band name above each label and the spanned row arrives with hydration, at the same height. Money and counts go through FormattedCurrency and FormattedNumber — the site is prerendered, so an implicit locale would be a hydration bug.

Accessor, derived and display columns under a banded header, with a column total in the footer.
OrderReference
OrderStatus
CustomerName
CustomerCountry
TotalsNet
TotalsGross
TotalsItems
ORD-4000PaidGermany50,00 €59,50 €1
ORD-4001PendingFrance91,00 €108,29 €2
ORD-4002ShippedSpain131,00 €155,89 €3
ORD-4003CancelledItaly172,00 €204,68 €4
ORD-4004RefundedNetherlands212,00 €252,28 €5
ORD-4005PaidAustria253,00 €301,07 €6
ORD-4006PendingBelgium293,00 €348,67 €1
ORD-4007ShippedPoland309,00 €367,71 €2
Net11.648,00 €

Showing 1–8 of 24

Sorting, single and multi-column

Three-state sort on every column but Reference, which opts out. Customer uses the locale-aware text comparator, Status a custom workflow order, Note pushes blanks last in both directions, and Date sorts as a date rather than as a string. Shift-click adds a column to the sort and the badge shows its priority.

Click a header to cycle ascending → descending → off. Shift-click a second header to sort by both; the badges are the sort order.
Reference
Customer
Status
Note
Amount
Date
ORD-4023Quanta BV 24CancelledHandled by desk 4908,00 €
ORD-4011Lumen BV 12PendingHandled by desk 4471,00 €
ORD-4022Pixel SARL 23ShippedHandled by desk 3868,00 €
ORD-4010Vertex SARL 11Paid—431,00 €
ORD-4021Delta Ltd 22PendingHandled by desk 2827,00 €
ORD-4009Apex Ltd 10RefundedHandled by desk 2390,00 €
ORD-4020Orbit SE 21Paid—811,00 €
ORD-4008Nova SE 9CancelledHandled by desk 1350,00 €

Showing 1–8 of 24

Global search and per-column filters

All five filter variants over 300 orders: text contains, enum with faceted counts, a number range bounded by the faceted minimum and maximum, a date range, and a boolean. Active filters appear as removable chips with a clear-all, and the column chooser and density toggle sit beside them.

Every filter is a Conform form with a valibot schema: a range whose lower bound is above its upper one is a field error, not a query that quietly returns nothing.
Reference
Customer
Status
Country
Amount
Date
ORD-4000PaidGermany50,00 €
ORD-4001PendingFrance91,00 €
ORD-4002ShippedSpain131,00 €
ORD-4003CancelledItaly172,00 €
ORD-4004RefundedNetherlands212,00 €
ORD-4005PaidAustria253,00 €
ORD-4006PendingBelgium293,00 €
ORD-4007ShippedPoland309,00 €
ORD-4008CancelledSweden350,00 €
ORD-4009RefundedDenmark390,00 €

Showing 1–10 of 300

Operators in a column header

A column's filter popover picks the question as well as the value: is not, does not contain, not between. The select is drawn only where there is a choice — boolean has one operator — and Apply commits the operator and the value as one condition, so dismissing the popover discards both.

Open Customer or Status: the select above the value is the question. Priority has none — its Yes / No / Any already says what “is not” would.
Reference
Customer
Status
Country
Amount
Date
ORD-4000PaidGermany50,00 €
ORD-4001PendingFrance91,00 €
ORD-4002ShippedSpain131,00 €
ORD-4003CancelledItaly172,00 €
ORD-4004RefundedNetherlands212,00 €
ORD-4005PaidAustria253,00 €
ORD-4006PendingBelgium293,00 €
ORD-4007ShippedPoland309,00 €

Showing 1–8 of 300

No conditions yet.

On here, and off in Server Table, which follows the one question the two are named for — who owns the query. These rows are all of them, so every operator the model can express is one this table can answer. A server-driven table's answer comes from a backend that may only implement contains, and nothing in the client could notice the difference — so there it is enableFilterOperators, off until the host says its query can answer one.

Saved filter presets

Lift the filters with columnFilters / onColumnFiltersChange and a preset is just a named list. Apply one from the Presets menu, or save the current filters as a new one.

Reference
Customer
Status
Country
Amount
Date
ORD-4000PaidGermany50,00 €
ORD-4001PendingFrance91,00 €
ORD-4002ShippedSpain131,00 €
ORD-4003CancelledItaly172,00 €
ORD-4004RefundedNetherlands212,00 €
ORD-4005PaidAustria253,00 €
ORD-4006PendingBelgium293,00 €
ORD-4007ShippedPoland309,00 €

Showing 1–8 of 300

A preset is the filter list under a name. Because the filters are the consumer's state here, a preset is equally a row in a database, a query string, or a saved view per user — the table does not need to know which.

Pagination, page jump and clamping

Page size selector, "showing X–Y of Z", first / previous / next / last, and a validated page jump. Narrowing the filters clamps the page index instead of leaving you past the end of the result.

Go to page 20, then filter Status down to one value: the page index is clamped to the last page that still has rows rather than showing an empty table.
Reference
Customer
Status
Amount
ORD-4000Paid50,00 €
ORD-4001Pending91,00 €
ORD-4002Shipped131,00 €
ORD-4003Cancelled172,00 €
ORD-4004Refunded212,00 €
ORD-4005Paid253,00 €
ORD-4006Pending293,00 €
ORD-4007Shipped309,00 €
ORD-4008Cancelled350,00 €
ORD-4009Refunded390,00 €

Showing 1–10 of 300

The page size and the page jump are Conform fields. "Page 900 of 15" is a validation message beside the input, which is the difference between an error you can act on and a table that silently shows nothing.

The search box is a Conform field; the query it drives is debounced. Watch the counter: typing eight characters runs one query, not eight.

Reference
Customer
Status
Amount
ORD-4000Paid50,00 €
ORD-4001Pending91,00 €
ORD-4002Shipped131,00 €
ORD-4003Cancelled172,00 €
ORD-4004Refunded212,00 €
ORD-4005Paid253,00 €
ORD-4006Pending293,00 €
ORD-4007Shipped309,00 €

Showing 1–8 of 24

Queries run: 0. The field updates on every keystroke; the query is lifted 250 ms after the last one — which server-side is the difference between one round-trip and one per character.

Selection, bulk actions and disabled rows

Multiple selection with a checkbox column, shift-click ranges, an indeterminate header checkbox, and rows that cannot be selected. The count and the bulk-action bar read from one selection model, which is what makes the selection survive paging and filtering.

Reference
Customer
Status
Amount
ORD-4000Paid50,00 €
ORD-4001Pending91,00 €
ORD-4002Shipped131,00 €
ORD-4003Cancelled172,00 €
ORD-4004Refunded212,00 €
ORD-4005Paid253,00 €
ORD-4006Pending293,00 €
ORD-4007Shipped309,00 €

Showing 1–8 of 300

Selected: 0. Select rows, page forward, select more, page back — the selection survives, because it is a model this table owns rather than the set of keys react-aria can see. Shift-click extends a range; the header checkbox is indeterminate while only some of the page is selected.

Detail panels, tree data and grouping

Three shapes of expansion, all owned by the row model: a detail panel as an extra spanning row, sub-rows indented by depth, and grouping with a sum aggregate and a per-group count.

A detail panel is one extra row with a single spanning cell — keyboard-reachable, and not selectable.
Reference
Customer
Status
Amount
ORD-4000Paid50,00 €
ORD-4001Pending91,00 €
ORD-4002Shipped131,00 €
ORD-4003Cancelled172,00 €
ORD-4004Refunded212,00 €
ORD-4005Paid253,00 €
ORD-4006Pending293,00 €
ORD-4007Shipped309,00 €
Sub-rows come from getSubRows and arrive pre-flattened with a depth, which is what the indent in the first cell is.
Reference
Customer
Status
Amount
ORD-4000Paid50,00 €
ORD-4001Pending91,00 €
ORD-4002Shipped131,00 €
ORD-4003Cancelled172,00 €
ORD-4004Refunded212,00 €
ORD-4005Paid253,00 €
Grouping is expansion in the row model, which is why one owner holds both. The Amount column aggregates with sum; the group header shows the row count.
Country
Reference
Status
Amount
Germany3Paid1.292,00 €
GermanyORD-4000Paid50,00 €
GermanyORD-4010Paid431,00 €
GermanyORD-4020Paid811,00 €
France3Pending1.389,00 €
FranceORD-4001Pending91,00 €
FranceORD-4011Pending471,00 €
FranceORD-4021Pending827,00 €
Spain3Shipped1.511,00 €
SpainORD-4002Shipped131,00 €
SpainORD-4012Shipped512,00 €
SpainORD-4022Shipped868,00 €
Italy3Cancelled1.632,00 €
ItalyORD-4003Cancelled172,00 €
ItalyORD-4013Cancelled552,00 €
ItalyORD-4023Cancelled908,00 €
Netherlands2Refunded780,00 €
NetherlandsORD-4004Refunded212,00 €
NetherlandsORD-4014Refunded568,00 €
Austria2Paid862,00 €
AustriaORD-4005Paid253,00 €
AustriaORD-4015Paid609,00 €
Belgium2Pending942,00 €
BelgiumORD-4006Pending293,00 €
BelgiumORD-4016Pending649,00 €
Poland2Shipped999,00 €
PolandORD-4007Shipped309,00 €
PolandORD-4017Shipped690,00 €
Sweden2Cancelled1.080,00 €
SwedenORD-4008Cancelled350,00 €
SwedenORD-4018Cancelled730,00 €
Denmark2Refunded1.161,00 €
DenmarkORD-4009Refunded390,00 €
DenmarkORD-4019Refunded771,00 €

Row actions, drag-reorder and conditional formatting

A per-row action menu, drag handles that reorder through onRowReorder, and row classes driven by the data. Striping, hover, focus and selection styling come from the quebi Table underneath.

Drag the grip to reorder. The action menu gets a column of its own at the end of the row, which is where an action on the whole row belongs — in the first cell it read as belonging to the first column. Narrow the window past sm and the rows become cards.
  • ReferenceORD-4000
    CustomerNova GmbH 1
    StatusPaid
    Amount50
  • ReferenceORD-4001
    CustomerApex AG 2
    StatusPending
    Amount91
  • ReferenceORD-4002
    CustomerVertex SE 3
    StatusShipped
    Amount131
  • ReferenceORD-4003
    CustomerLumen Ltd 4
    StatusCancelled
    Amount172
  • ReferenceORD-4004
    CustomerOrbit SARL 5
    StatusRefunded
    Amount212
  • ReferenceORD-4005
    CustomerDelta BV 6
    StatusPaid
    Amount253
  • ReferenceORD-4006
    CustomerPixel GmbH 7
    StatusPending
    Amount293
  • ReferenceORD-4007
    CustomerQuanta AG 8
    StatusShipped
    Amount309
  • ReferenceORD-4008
    CustomerNova SE 9
    StatusCancelled
    Amount350
  • ReferenceORD-4009
    CustomerApex Ltd 10
    StatusRefunded
    Amount390
Whole-row navigation is getRowHref, which puts a real link on the row — never an onClick that calls a router, because a control that changes the URL has to be an anchor.

Resize, pin, hide and a saved layout

Eight columns in a 420px-tall scroller with a sticky header. Resize from the column edge, pin from the header menu, hide from the Columns popover — and the arrangement is written to localStorage.

Drag a column edge to resize. Each header's ⌄ menu pins it to the start or the end, or hides it; the Columns popover brings it back. The layout is saved to localStorage under storageKey, so it survives a reload.
Reference
Customer
Country
Channel
Status
Items
Amount
Date
ORD-4000GermanyWebPaid150,00 €
ORD-4001FranceRetailPending291,00 €
ORD-4002SpainPartnerShipped3131,00 €
ORD-4003ItalyWebCancelled4172,00 €
ORD-4004NetherlandsRetailRefunded5212,00 €
ORD-4005AustriaPartnerPaid6253,00 €
ORD-4006BelgiumWebPending1293,00 €
ORD-4007PolandRetailShipped2309,00 €
ORD-4008SwedenPartnerCancelled3350,00 €
ORD-4009DenmarkWebRefunded4390,00 €
ORD-4010GermanyRetailPaid5431,00 €
ORD-4011FrancePartnerPending6471,00 €
ORD-4012SpainWebShipped1512,00 €
ORD-4013ItalyRetailCancelled2552,00 €
ORD-4014NetherlandsPartnerRefunded3568,00 €
ORD-4015AustriaWebPaid4609,00 €
ORD-4016BelgiumRetailPending5649,00 €
ORD-4017PolandPartnerShipped6690,00 €
ORD-4018SwedenWebCancelled1730,00 €
ORD-4019DenmarkRetailRefunded2771,00 €
ORD-4020GermanyPartnerPaid3811,00 €
ORD-4021FranceWebPending4827,00 €
ORD-4022SpainRetailShipped5868,00 €
ORD-4023ItalyPartnerCancelled6908,00 €
ORD-4024NetherlandsWebRefunded1949,00 €
ORD-4025AustriaRetailPaid289,00 €
ORD-4026BelgiumPartnerPending3130,00 €
ORD-4027PolandWebShipped4170,00 €
ORD-4028SwedenRetailCancelled5186,00 €
ORD-4029DenmarkPartnerRefunded6227,00 €
ORD-4030GermanyWebPaid1267,00 €
ORD-4031FranceRetailPending2308,00 €
ORD-4032SpainPartnerShipped3348,00 €
ORD-4033ItalyWebCancelled4389,00 €
ORD-4034NetherlandsRetailRefunded5429,00 €
ORD-4035AustriaPartnerPaid6445,00 €
ORD-4036BelgiumWebPending1486,00 €
ORD-4037PolandRetailShipped2526,00 €
ORD-4038SwedenPartnerCancelled3567,00 €
ORD-4039DenmarkWebRefunded4607,00 €
Pinning positions a column with a sticky offset, so a pinned column needs an explicit width for that offset to be exact. Reset to defaults is at the bottom of the Columns popover.

Editing cells, add, delete and undo

One click opens a cell as a Conform form over a valibot schema. Add prepends a row with its first cell already open, Delete is a row action, and every commit is undoable — dirty state is a history stack, not a flag. Editing a table is editing its cells; there is no row mode to enter.

Click a cell to edit it. A value you pick commits itself; a value you type commits on Enter, Tab or when you leave the cell. Escape puts it back.
Reference
Customer
Status
Amount
Date
Actions
ORD-4000Nova GmbH 1Paid50,00 €
ORD-4001Apex AG 2Pending91,00 €
ORD-4002Vertex SE 3Shipped131,00 €
ORD-4003Lumen Ltd 4Cancelled172,00 €
ORD-4004Orbit SARL 5Refunded212,00 €
ORD-4005Delta BV 6Paid253,00 €
ORD-4006Pixel GmbH 7Pending293,00 €
ORD-4007Quanta AG 8Shipped309,00 €
Every cell is a real Conform form over a valibot schema, so a reference that does not match ORD-1234 is an error under the control rather than a rejected save you have to reconstruct — and because the form is the whole row, a rule that compares two fields has both. Nothing here is batched: onCellEdit fires the moment the schema accepts the row, and Undo is a history stack built on top of it.
Opening a cell moves nothing. The control is sized from the table's density and the cell gives up exactly the padding the control's own border takes back, so an open row measures what a closed one does; the alignment is the column's, so the amounts stay under the amounts; and a number cell hides the + / − pair, which costs 74px a grid does not have — ↑ and ↓ still step. The only thing that changes the height is a value the schema refuses, which is the message arriving.

Sort, search and filters in the URL

queryToSearchParams / queryFromSearchParams from @/lib/data-table put the whole query in the address bar through React Router's useSearchParams. The lib module has no React in it, so the same two functions parse the query in a loader.

Reference
Customer
Status
Amount
Date
ORD-4000Paid50,00 €
ORD-4001Pending91,00 €
ORD-4002Shipped131,00 €
ORD-4003Cancelled172,00 €
ORD-4004Refunded212,00 €
ORD-4005Paid253,00 €
ORD-4006Pending293,00 €
ORD-4007Shipped309,00 €
ORD-4008Cancelled350,00 €
ORD-4009Refunded390,00 €

Showing 1–10 of 300

Sort, search and filters are in the address bar. That makes a filtered view a link someone can send and a back button that works — and on a prerendered site it is the only place the state can live and still match the served HTML.

Ten thousand rows, virtualized

The whole dataset in the browser with row virtualization and no pagination, plus CSV export of the visible columns or of every filtered row.

Loading, empty, no-results and error

The four states a table is in when it has nothing to show, told apart. Switch between them to compare.

Reference
Customer
Status
Amount
Date
Four different sentences for four different situations. An initial load is skeleton rows; a background refresh keeps the rows and says so; "nothing here yet" and "nothing matched" are different problems and only one has a next step; an error offers a retry.
Skeleton rows are the same height as real ones — 44px — so nothing jumps when the data lands.

Editable cells: any conform-* control, and the keyboard to match

Each column carries an `editor` callback that is handed Conform field metadata, so the control in a cell is whichever conform-* variant you bind — here ten columns and eight kinds of control, including the four no enum would ever have had room for. Cells are how a table is edited: one click opens one, and the commit follows the control rather than the focus — picking an option is the user being done, while typed text settles on Enter, on Tab, or when you leave the cell. The keyboard is what makes it a data table rather than a page of inputs: Enter, F2 or typing opens a cell, Tab commits and moves to the next editable one (wrapping to the next row, not leaving for the next focusable element on the page), Escape restores the value, arrows move between cells and then move the caret.

Click a cell to edit it — or focus one and press Enter, F2, or just start typing. A value you pick commits itself; a value you type commits on Enter, Tab or when you leave the cell. Escape puts it back.
Product
SKU
Price
Stock
Category
Supplier
Launch
Colour
Tags
Active
Halo pendantSKU-120089,00 €0LightingNordwerk GmbH#0EA5E9indoorceiling
Cove sconceSKU-1207135,50 €17SeatingLumen Industries#F97316indoorwall
Ridge stoolSKU-1214182,00 €34StorageVertex SE#22C55Eindoor
Basin chairSKU-1221228,50 €51SurfacesApex Manufaktur#A855F7indoorupholstered—
Slate shelfSKU-1228275,00 €8LightingOrbit BV#EF4444modular
Cairn crateSKU-1235321,50 €25SeatingNordwerk GmbH#14B8A6outdoormodular
Plane deskSKU-1242368,00 €42StorageLumen Industries#EAB308office
Verge benchSKU-1249414,50 €59SurfacesVertex SE#6366F1outdoor—
Ten columns, ten different controls, one editor callback each — text, number, select, combo box, date picker, colour picker, tag field and switch. None of them names a size: a cell states one around the control it holds, so a row measures the same open as closed and a number cell has no + / − pair to find room for. Set a product active with nothing in stock to see the cross-field rule fire: the form behind a cell is the whole row, so "active needs stock" is a rule it can actually check.

A click, Enter, F2 or typing edits · Tab commits and moves · Esc cancels · arrows move between cells, and move the caret once a cell is open.

Batching per-cell commits behind a Save bar

The same table with a draft in front of it: every cell commit lands in a useState instead of in the rows, edited rows are marked, and TableUnsavedBar is the only thing that writes. It is here rather than first because live commits are the model — batching is a caller's choice expressed over them, and the reverse cannot be expressed at all.

Edit a few cells: each commit lands in a draft, the row is marked, and nothing is written until you save.
Product
SKU
Price
Stock
Category
Halo pendantSKU-120089,00 €0Lighting
Cove sconceSKU-1207135,50 €17Seating
Ridge stoolSKU-1214182,00 €34Storage
Basin chairSKU-1221228,50 €51Surfaces
Slate shelfSKU-1228275,00 €8Lighting
Cairn crateSKU-1235321,50 €25Seating
Plane deskSKU-1242368,00 €42Storage
Verge benchSKU-1249414,50 €59Surfaces

Bulk edit, through the same schema

Select rows and edit them together. The modal is TableRowEditor over a schema holding only the fields being applied, because a bulk edit must not require the fields it is not touching. Several rows and a few fields is the job that form shape is still the right one for; a single row is edited in its own cells.

Product
SKU
Price
Stock
Category
Halo pendantSKU-120089,00 €0Lighting
Cove sconceSKU-1207135,50 €17Seating
Ridge stoolSKU-1214182,00 €34Storage
Basin chairSKU-1221228,50 €51Surfaces
Slate shelfSKU-1228275,00 €8Lighting
Cairn crateSKU-1235321,50 €25Seating
Plane deskSKU-1242368,00 €42Storage
Verge benchSKU-1249414,50 €59Surfaces
The modal and the cells above are the same shape over the same schema: one valibot object decides what a valid product is, whether you are editing one cell, one row, or five rows at once. That is the argument for a bulk edit needing no component of its own.

source

data-table.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { useTable } from "@tanstack/react-table"
import type { RowData, SortingState } from "@tanstack/react-table"
import { ArrowDownToLine, Copy, RotateCcw } from "lucide-react"
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"
import type * as v from "valibot"
import { Button, buttonStyles } from "@/components/button"
import { Card, CardContent } from "@/components/card"
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/menu"
import {
  CHROME_ICON_SIZE,
  CHROME_SIZE,
  TableBulkBar,
  TableColumnChooser,
  TableDensityToggle,
  TableFilterChips,
  TableFilterPanel,
  TablePager,
  TableSearch,
  TableToolbar,
  describeFilter,
  useTableCellEditing,
} from "@/components/table-controls"
import { TableShell } from "@/components/table-shell"
import {
  type DataTableCellAddress,
  type DataTableCellEdit,
  type DataTableColumn,
  type DataTableDensity,
  type DataTableInstance,
  type DataTableRow,
  type DataTableSelection,
  type FilterCondition,
  type FilterOperator,
  clampPage,
  copyToClipboard,
  dataTableFeatures,
  defaultOperator,
  downloadCsv,
  emptySelection,
  facetDomains,
  facetedOptions,
  isFilterSet,
  isRowSelected,
  leafColumns,
  packFilter,
  unpackFilter,
  nextSorting,
  qualifiedLabel,
  readView,
  toColumnDefs,
  toCsv,
  writeView,
} from "@/lib/data-table"

/**
 * Data Table — quebi design system
 *
 * The client-side mode: the consumer holds every row and the TanStack row model
 * does the work — sorting, filtering, faceting, grouping, aggregation,
 * pagination and expansion all happen in the browser.
 *
 * This file is one of the two modes and nothing else. The rows are drawn by
 * `TableShell` and the controls around them come from `table-controls`; both
 * are modules of their own, so the thing that makes this one *the client mode* —
 * a `useTable` with every row model switched on, a CSV export of rows it
 * actually holds, faceted filter options taken from the model — is all that is
 * left here. Its server-driven twin is `ServerTable`, which speaks the same
 * column vocabulary (`DataTableColumn` from `@/lib/data-table`) and assembles
 * itself from the same two halves.
 *
 * Which of the two you want is a question about where the rows come from, not
 * about how many there are: if the `data` prop is the whole answer, this is the
 * component; if it is a page of an answer, sorting it reorders a slice and
 * `ServerTable` is the component.
 */

export interface DataTableProps<T extends RowData> {
  "aria-label": string
  columns: DataTableColumn<T>[]
  data: T[]
  getRowId: (row: T) => string
  /** Tree data: the children of a row, rendered indented under it. */
  getSubRows?: (row: T) => T[] | undefined

  /* toolbar */
  caption?: ReactNode
  toolbarActions?: ReactNode
  enableGlobalSearch?: boolean
  enableColumnChooser?: boolean
  enableDensityToggle?: boolean
  exportFilename?: string
  onRefresh?: () => void

  /* model */
  enablePagination?: boolean
  pageSizes?: number[]
  defaultPageSize?: number
  enableMultiSort?: boolean
  defaultSorting?: SortingState
  defaultColumnVisibility?: Record<string, boolean>
  defaultGrouping?: string[]
  /** Controlled sort — pass both to keep the sort in the URL. */
  sorting?: SortingState
  onSortingChange?: (sorting: SortingState) => void
  globalFilter?: string
  onGlobalFilterChange?: (value: string) => void
  /**
   * Controlled column filters, as conditions. The operator rides with the value
   * — a condition reading `is not` filters as `is not` here, exactly as it does
   * in `ServerTable` and in `filterRows`, because all three run `matchesFilter`.
   */
  columnFilters?: FilterCondition[]
  onColumnFiltersChange?: (filters: FilterCondition[]) => void
  /**
   * Whether a column's filter popover draws an Operator select — `is not`,
   * `does not contain`, `not between` — rather than only honouring one that
   * arrived from a URL, a preset or a `FilterBuilder` (task #201).
   *
   * On here, and off in `ServerTable`, and the asymmetry follows the one
   * question the two are named for — who owns the query. These rows are all of
   * them and `matchesFilter` is what narrows them, so every operator the model
   * can express is one this table can already answer. A server-driven table's
   * answer comes from a server that may only implement `contains`, and a
   * control it cannot honour is worse than a missing one.
   *
   * The select is drawn per column, and only where offering it is true: a
   * variant with one operator (`boolean`, whose `Yes / No / Any` already says
   * what `is not` would) has nothing to pick, and a column carrying its own
   * `filterFn` *is* its own operator — the predicate is handed one and is free
   * to ignore it, so the header does not offer to change a question only that
   * function can answer.
   */
  enableFilterOperators?: boolean
  /** Named filter sets, offered beside the chips. */
  filterPresets?: { id: string; label: string }[]
  onApplyPreset?: (id: string) => void
  onSavePreset?: () => void

  /* selection */
  selectionMode?: "none" | "single" | "multiple"
  selection?: DataTableSelection
  onSelectionChange?: (selection: DataTableSelection) => void
  bulkActions?: (selection: DataTableSelection) => ReactNode

  /* rows */
  renderDetail?: (row: T) => ReactNode

  /* editing a cell */
  /**
   * The valibot schema a cell edit is validated against — the whole row, not
   * the one field, because that is what a cross-field rule needs. Without it
   * nothing is editable, whatever the columns say.
   */
  cellEditSchema?: v.GenericSchema
  /** Reported once the schema accepted the row. Write it back yourself. */
  onCellEdit?: (edit: DataTableCellEdit<T>) => void
  /** Controlled. Leave it out and the table holds the open cell itself. */
  editingCell?: DataTableCellAddress | null
  onEditingCellChange?: (cell: DataTableCellAddress | null) => void
  /** The row as form values. Defaults to the editable columns read off the row. */
  getEditValues?: (row: T) => Record<string, unknown>
  /**
   * The cell whose commit is in flight, if one is. An address rather than a
   * boolean, because commits no longer wait for the cell to be left: a control
   * commits when its value settles, so by the time the save is running the open
   * cell is routinely a different one, and one flag for the whole table draws
   * the spinner wherever the user happens to be rather than where the edit was.
   */
  savingCell?: DataTableCellAddress | null
  rowActions?: (row: T) => ReactNode
  getRowHref?: (row: T) => string | undefined
  onRowAction?: (row: T) => void
  isRowDisabled?: (row: T) => boolean
  rowClassName?: (row: T) => string | undefined
  onRowReorder?: (keys: string[], targetKey: string, position: "before" | "after") => void

  /* presentation */
  density?: DataTableDensity
  onDensityChange?: (density: DataTableDensity) => void
  striped?: boolean
  grid?: boolean
  allowResize?: boolean
  stickyHeader?: boolean
  height?: number
  virtualize?: boolean
  rowHeight?: number
  showFooter?: boolean
  /**
   * Collapse to one card per row below `sm`. The breakpoint is CSS, so both
   * layouts are rendered — reach for it on a table you can afford twice.
   */
  stackOnMobile?: boolean
  /** localStorage key for the column layout — order, visibility, sizing, density. */
  storageKey?: string

  /* states */
  isLoading?: boolean
  isRefreshing?: boolean
  error?: ReactNode
  onRetry?: () => void
  emptyMessage?: string
  noResultsMessage?: string
  className?: string
}

const EMPTY_ROWS: never[] = []

/**
 * DataTable — every row is already in the browser, and the row model does the work.
 *
 * Sorting, filtering, faceting, grouping, aggregation, pagination and expansion
 * are TanStack's; the rendering, the selection UX, the keyboard model and the
 * ARIA grid are react-aria's. If the rows are a page of a larger answer, this
 * is the wrong component — reach for ServerTable, which asks the server again
 * instead of reordering what it was handed.
 */
export function DataTable<T extends RowData>({
  "aria-label": ariaLabel,
  columns,
  data,
  getRowId,
  getSubRows,
  caption,
  toolbarActions,
  enableGlobalSearch = true,
  enableColumnChooser = true,
  enableDensityToggle = true,
  exportFilename,
  onRefresh,
  enablePagination = true,
  pageSizes,
  defaultPageSize = 20,
  enableMultiSort = true,
  defaultSorting = [],
  defaultColumnVisibility,
  defaultGrouping,
  sorting: sortingProp,
  onSortingChange,
  globalFilter: globalFilterProp,
  onGlobalFilterChange,
  columnFilters: columnFiltersProp,
  onColumnFiltersChange,
  enableFilterOperators = true,
  filterPresets,
  onApplyPreset,
  onSavePreset,
  selectionMode = "none",
  selection: selectionProp,
  onSelectionChange,
  bulkActions,
  renderDetail,
  cellEditSchema,
  onCellEdit,
  editingCell,
  onEditingCellChange,
  getEditValues,
  savingCell,
  rowActions,
  getRowHref,
  onRowAction,
  isRowDisabled,
  rowClassName,
  onRowReorder,
  density: densityProp,
  onDensityChange,
  striped,
  grid,
  allowResize,
  stickyHeader,
  height,
  virtualize,
  rowHeight,
  showFooter,
  stackOnMobile,
  storageKey,
  isLoading,
  isRefreshing,
  error,
  onRetry,
  emptyMessage,
  noResultsMessage,
  className,
}: DataTableProps<T>) {
  const columnDefs = useMemo(() => toColumnDefs(columns), [columns])
  const saved = useMemo(() => (storageKey ? readView(storageKey) : undefined), [storageKey])

  const [densityState, setDensityState] = useState<DataTableDensity>(
    densityProp ?? saved?.density ?? "normal",
  )
  const density = densityProp ?? densityState
  const setDensity = (next: DataTableDensity) => {
    setDensityState(next)
    onDensityChange?.(next)
    if (storageKey) writeView(storageKey, { ...readView(storageKey), density: next })
  }

  const [selectionState, setSelectionState] = useState<DataTableSelection>(emptySelection)
  const selection = selectionProp ?? selectionState
  const setSelection = (next: DataTableSelection) => {
    setSelectionState(next)
    onSelectionChange?.(next)
  }

  /**
   * Each filterable column's variant, which is what decides an operator's
   * default and therefore whether a condition has anything to carry into
   * TanStack's one-value-per-column filter slot at all.
   */
  const filterVariants = useMemo(
    () => new Map(leafColumns(columns).map((column) => [column.id, column.filterVariant])),
    [columns],
  )

  /**
   * The columns answering their filter with a predicate of their own. Kept here
   * rather than on `DataTableColumnMeta` because it is not presentation: the
   * meta is what a header, a chooser, a CSV row and a breakpoint all read, and
   * this is one question the filter popover asks about the column definition it
   * came from. It decides whether that popover offers an operator at all —
   * `filterFn` is handed one and free to ignore it, and a select that changes
   * nothing is worse than no select.
   */
  const customFilterColumns = useMemo(
    () =>
      new Set(
        leafColumns(columns)
          .filter((column) => column.filterFn)
          .map((column) => column.id),
      ),
    [columns],
  )

  const controlledState = useMemo(() => {
    const slices: Record<string, unknown> = {}
    if (sortingProp) slices.sorting = sortingProp
    if (globalFilterProp != null) slices.globalFilter = globalFilterProp
    if (columnFiltersProp) {
      slices.columnFilters = columnFiltersProp.map((condition) => ({
        id: condition.fieldId,
        value: packFilter(
          condition.variant ?? filterVariants.get(condition.fieldId),
          condition.value,
          condition.operator,
        ),
      }))
    }
    return Object.keys(slices).length > 0 ? slices : undefined
  }, [sortingProp, globalFilterProp, columnFiltersProp, filterVariants])

  const table = useTable<typeof dataTableFeatures, T>({
    features: dataTableFeatures,
    columns: columnDefs,
    data: data ?? EMPTY_ROWS,
    getRowId: (row, index, parent) =>
      parent ? `${parent.id}.${getRowId(row)}` : String(getRowId(row) ?? index),
    getSubRows,
    enableMultiSort,
    maxMultiSortColCount: 3,
    // A detail panel is expansion without sub-rows, and `toggleExpanded` is a
    // no-op unless the row says it can expand — so a table with a detail
    // renderer says every row can.
    ...(renderDetail ? { getRowCanExpand: () => true } : {}),
    autoResetPageIndex: true,
    initialState: {
      sorting: defaultSorting,
      grouping: defaultGrouping ?? [],
      expanded: defaultGrouping?.length ? true : {},
      columnVisibility: { ...defaultColumnVisibility, ...saved?.columnVisibility },
      columnOrder: saved?.columnOrder ?? [],
      columnSizing: saved?.columnSizing ?? {},
      columnPinning: {
        start: saved?.columnPinning?.start ?? [],
        end: saved?.columnPinning?.end ?? [],
      },
      pagination: { pageIndex: 0, pageSize: defaultPageSize },
    },
    // Only the slices the consumer actually controls are named here. A slice
    // named with an undefined value is still controlled, and a controlled
    // slice with no setter behind it is a frozen one.
    state: controlledState,
    // Naming a handler at all makes the slice controlled: TanStack's own
    // default setter is replaced by whatever is here, so `onSortingChange:
    // undefined` is not "no override", it is "this slice has no setter" and
    // `table.setSorting` silently stops working. Hence the spread.
    ...(onSortingChange
      ? {
          onSortingChange: (updater: SortingState | ((old: SortingState) => SortingState)) =>
            onSortingChange(
              typeof updater === "function" ? updater(sortingProp ?? []) : updater,
            ),
        }
      : {}),
    ...(onGlobalFilterChange
      ? {
          onGlobalFilterChange: (updater: unknown) =>
            onGlobalFilterChange(
              String(
                typeof updater === "function"
                  ? (updater as (old: string) => string)(globalFilterProp ?? "")
                  : updater,
              ),
            ),
        }
      : {}),
  })

  const setSorting = (next: SortingState) => {
    if (onSortingChange) onSortingChange(next)
    else table.setSorting(next)
  }

  const state = table.state
  const sorting = state.sorting ?? []
  const columnFilters = state.columnFilters ?? []
  /** The active filters as conditions — the operator unpacked back out of the slot. */
  const conditions: FilterCondition[] = columnFilters.map((filter) => {
    const { value, operator } = unpackFilter(filter.value)
    const variant = filterVariants.get(filter.id)
    return {
      id: filter.id,
      fieldId: filter.id,
      operator: operator ?? defaultOperator(variant),
      value,
      variant,
    }
  })
  const activeFilterIds = columnFilters.map((f) => f.id)
  const hasQuery = activeFilterIds.length > 0 || Boolean(state.globalFilter)

  // What every enum filter *could* offer, read from the rows before any filter
  // ran. The counts beside those choices come from the faceted row model and
  // move with the other filters; the choices themselves must not, or a value
  // another column has zeroed disappears from the panel that would switch to
  // it. A column that declares its own `filterOptions` already has a domain.
  //
  // Memoised on the pre-filtered rows, whose identity TanStack keeps until
  // `data` changes, so this pass over every row happens once per dataset
  // rather than once per render of a header.
  const facetColumnIds = useMemo(
    () =>
      leafColumns(columns)
        .filter((column) => column.filterVariant === "enum" && !column.filterOptions)
        .map((column) => column.id),
    [columns],
  )
  const preFilteredRows = table.getPreFilteredRowModel().flatRows
  const domains = useMemo(
    () => facetDomains(preFilteredRows, facetColumnIds),
    [preFilteredRows, facetColumnIds],
  )

  const persist = useCallback(() => {
    if (!storageKey) return
    writeView(storageKey, {
      ...readView(storageKey),
      columnOrder: table.state.columnOrder,
      columnVisibility: table.state.columnVisibility,
      columnSizing: table.state.columnSizing,
      columnPinning: table.state.columnPinning,
    })
  }, [storageKey, table])

  useEffect(() => {
    persist()
  }, [persist])

  const setColumnFilter = (columnId: string, value: unknown, operator?: FilterOperator) => {
    const next = columnFilters.filter((f) => f.id !== columnId)
    if (isFilterSet(value)) {
      next.push({ id: columnId, value: packFilter(filterVariants.get(columnId), value, operator) })
    }
    if (onColumnFiltersChange) {
      onColumnFiltersChange(
        next.map((filter) => {
          const unpacked = unpackFilter(filter.value)
          const variant = filterVariants.get(filter.id)
          return {
            id: filter.id,
            fieldId: filter.id,
            operator: unpacked.operator ?? defaultOperator(variant),
            value: unpacked.value,
            variant,
          }
        }),
      )
    } else {
      table.setColumnFilters(next)
    }
  }

  const paginated = enablePagination
  // `getRowModel()` is the *final* model, pagination included — so switching
  // pagination off means asking for the model one step earlier rather than
  // asking the same question and hoping.
  const rows = paginated ? table.getPaginatedRowModel().rows : table.getExpandedRowModel().rows
  const filteredCount = table.getFilteredRowModel().rows.length
  const pagination = state.pagination ?? { pageIndex: 0, pageSize: defaultPageSize }

  const exportCsv = (scope: "visible" | "all") => {
    const cols = (scope === "visible" ? table.getVisibleLeafColumns() : table.getAllLeafColumns())
      .filter((column) => !column.columnDef.meta?.noExport)
    const source = scope === "visible" ? rows : table.getFilteredRowModel().rows
    const csv = toCsv(
      cols.map((column) => column.columnDef.meta?.label ?? column.id),
      source.map((row) => cols.map((column) => row.getValue(column.id))),
    )
    downloadCsv(exportFilename ?? "table.csv", csv)
  }

  /** The selected rows if there are any, otherwise the page — as CSV, to the clipboard. */
  const copySelection = async () => {
    const cols = table.getVisibleLeafColumns().filter((c) => !c.columnDef.meta?.noExport)
    const selected = rows.filter((row) => isRowSelected(selection, row.id))
    await copyToClipboard(
      toCsv(
        cols.map((column) => column.columnDef.meta?.label ?? column.id),
        (selected.length > 0 ? selected : rows).map((row) =>
          cols.map((column) => row.getValue(column.id)),
        ),
      ),
    )
  }

  const cellEditing = useTableCellEditing<T>({
    columns,
    schema: cellEditSchema,
    getEditValues,
    onCellEdit,
    editingCell,
    onEditingCellChange,
    savingCell,
  })

  const chips = conditions.map((condition) => {
    const column = table.getColumn(condition.fieldId)
    return {
      column: condition.fieldId,
      label: column?.columnDef.meta?.label ?? condition.fieldId,
      text: describeFilter(
        column?.columnDef.meta?.filterVariant,
        condition.value,
        condition.operator,
      ),
    }
  })

  const shell = (
    <TableShell<T>
      aria-label={ariaLabel}
      table={table}
      rows={rows}
      getRowKey={getRowId}
      sorting={sorting}
      onSortIntent={(columnId, additive) =>
        setSorting(nextSorting(sorting, columnId, { additive: additive && enableMultiSort }))
      }
      onSortColumn={(columnId, direction) =>
        setSorting(direction == null ? [] : [{ id: columnId, desc: direction === "desc" }])
      }
      allowGrouping
      density={density}
      striped={striped}
      grid={grid}
      allowResize={allowResize}
      stickyHeader={stickyHeader}
      height={height}
      virtualize={virtualize}
      rowHeight={rowHeight}
      selectionMode={selectionMode}
      selection={selection}
      onSelectionChange={setSelection}
      allowSelectAllMatching={false}
      isRowDisabled={isRowDisabled}
      getRowHref={getRowHref}
      onRowAction={onRowAction}
      rowActions={rowActions}
      renderDetail={renderDetail}
      editingCell={cellEditing.editingCell}
      onEditingCellChange={cellEditing.setEditingCell}
      editableColumns={cellEditing.editableColumns}
      renderCellEditor={cellEditing.renderCellEditor}
      rowClassName={rowClassName}
      onRowReorder={onRowReorder}
      showFooter={showFooter}
      hasQuery={hasQuery}
      isLoading={isLoading}
      isRefreshing={isRefreshing}
      error={error}
      onRetry={onRetry}
      emptyMessage={emptyMessage}
      noResultsMessage={noResultsMessage}
      activeFilters={activeFilterIds}
      renderFilter={(columnId, close) => {
        const column = table.getColumn(columnId)
        const meta = column?.columnDef.meta
        if (!column || !meta?.filterVariant) return null
        const condition = conditions.find((entry) => entry.fieldId === columnId)
        const applied = condition?.value
        const options = facetedOptions({
          declared: meta.filterOptions,
          domain: domains.get(columnId),
          counts: column.getFacetedUniqueValues?.(),
          selected: Array.isArray(applied) ? applied.map(String) : undefined,
        })
        const minMax = column.getFacetedMinMaxValues?.()
        return (
          <TableFilterPanel
            columnId={columnId}
            label={meta.label}
            variant={meta.filterVariant}
            value={applied}
            /* Where the panel starts, and — with no select drawn — what it
               reports straight back, so re-applying a value never quietly
               resets a controlled `is not` to `is`. */
            operator={condition?.operator}
            /* A column with its own `filterFn` is its own operator: the
               predicate is handed one and may ignore it, and a select that
               changes nothing is a worse answer than no select. */
            editOperator={enableFilterOperators && !customFilterColumns.has(columnId)}
            options={options}
            bounds={minMax ? [Number(minMax[0]), Number(minMax[1])] : undefined}
            onApply={(value, operator) => setColumnFilter(columnId, value, operator)}
            onClear={() => setColumnFilter(columnId, undefined)}
            onClose={close}
          />
        )
      }}
      className={className}
    />
  )

  return (
    <div className="flex w-full flex-col gap-2">
      <TableToolbar
        caption={caption}
        actions={
          <>
            {toolbarActions}
            {enableDensityToggle && (
              <TableDensityToggle value={density} onChange={setDensity} />
            )}
            {enableColumnChooser && (
              <TableColumnChooser
                columns={table.getAllLeafColumns().map((column) => ({
                  id: column.id,
                  label: qualifiedLabel(column.columnDef.meta, column.id),
                  isVisible: column.getIsVisible(),
                  canHide: column.getCanHide(),
                }))}
                onChange={(id, isVisible) => table.getColumn(id)?.toggleVisibility(isVisible)}
                onReset={() => {
                  table.resetColumnVisibility()
                  table.resetColumnOrder()
                  table.resetColumnSizing()
                  table.resetColumnPinning()
                }}
              />
            )}
            {exportFilename && (
              <Menu>
                {/* MenuTrigger already renders the button; taking the look
                    from buttonStyles is what keeps it from nesting one. */}
                <MenuTrigger
                  aria-label="Export"
                  className={buttonStyles({ intent: "outline", size: CHROME_SIZE })}
                >
                  <ArrowDownToLine data-slot="icon" aria-hidden="true" />
                  Export
                </MenuTrigger>
                <MenuContent
                  placement="bottom end"
                  onAction={(key) => {
                    if (key === "copy") void copySelection()
                    else exportCsv(key === "visible" ? "visible" : "all")
                  }}
                >
                  <MenuItem id="visible">Visible columns, this page</MenuItem>
                  <MenuItem id="all">All columns, every filtered row</MenuItem>
                  <MenuItem id="copy">
                    <Copy data-slot="icon" aria-hidden="true" />
                    Copy selection
                  </MenuItem>
                </MenuContent>
              </Menu>
            )}
            {onRefresh && (
              <Button
                intent="ghost"
                size={CHROME_ICON_SIZE}
                aria-label="Refresh"
                onPress={onRefresh}
              >
                <RotateCcw data-slot="icon" aria-hidden="true" />
              </Button>
            )}
          </>
        }
      >
        {enableGlobalSearch && (
          <TableSearch
            value={String(state.globalFilter ?? "")}
            onChange={(value) =>
              onGlobalFilterChange ? onGlobalFilterChange(value) : table.setGlobalFilter(value)
            }
            label={undefined}
            placeholder="Search all columns…"
          />
        )}
      </TableToolbar>

      <TableFilterChips
        filters={chips}
        onClear={(columnId) => setColumnFilter(columnId, undefined)}
        onClearAll={() =>
          onColumnFiltersChange ? onColumnFiltersChange([]) : table.resetColumnFilters()
        }
        presets={filterPresets}
        onApplyPreset={onApplyPreset}
        onSavePreset={onSavePreset}
      />

      {selectionMode === "multiple" && (
        <TableBulkBar
          selection={selection}
          total={filteredCount}
          pageCount={rows.length}
          onClear={() => setSelection(emptySelection)}
        >
          {bulkActions?.(selection)}
        </TableBulkBar>
      )}

      {stackOnMobile ? (
        <>
          <div className="sm:hidden">
            <DataTableCards table={table} rows={rows} getRowKey={getRowId} rowActions={rowActions} />
          </div>
          <div className="hidden sm:block">{shell}</div>
        </>
      ) : (
        shell
      )}

      {paginated && (
        <TablePager
          page={pagination.pageIndex}
          pageSize={pagination.pageSize}
          rowsOnPage={rows.length}
          total={filteredCount}
          pageSizes={pageSizes}
          onPageChange={(page) =>
            table.setPageIndex(clampPage(page, pagination.pageSize, filteredCount))
          }
          onPageSizeChange={(size) => table.setPageSize(size)}
        />
      )}
    </div>
  )
}

interface DataTableCardsProps<T extends RowData> {
  table: DataTableInstance<T>
  rows: DataTableRow<T>[]
  getRowKey: (row: T) => string
  rowActions?: (row: T) => ReactNode
}

/**
 * One card per row, for the width where a table stops being readable.
 *
 * Columns are ordered by their `priority`, so the narrow layout shows the same
 * fields in the same order the wide one drops them in — the responsive story
 * is one decision on the column, not two.
 */
function DataTableCards<T extends RowData>({
  table,
  rows,
  getRowKey,
  rowActions,
}: DataTableCardsProps<T>) {
  const columns = [...table.getVisibleLeafColumns()].sort(
    (a, b) => (a.columnDef.meta?.priority ?? 0) - (b.columnDef.meta?.priority ?? 0),
  )
  if (rows.length === 0) {
    return <p className="py-8 text-center text-quebi-body-s text-quebi-fg-muted">No rows.</p>
  }
  return (
    <ul className="flex flex-col gap-2">
      {rows.map((row) => (
        <li key={getRowKey(row.original)}>
          <Card>
            <CardContent className="flex flex-col gap-1.5 p-3">
              {columns.map((column) => (
                <div key={column.id} className="flex items-baseline justify-between gap-3 text-sm">
                  <span className="quebi-eyebrow">
                    {column.columnDef.meta?.label ?? column.id}
                  </span>
                  <span className="text-end text-quebi-fg">
                    {String(row.getValue(column.id) ?? column.columnDef.meta?.emptyValue ?? "—")}
                  </span>
                </div>
              ))}
              {rowActions && <div className="pt-1">{rowActions(row.original)}</div>}
            </CardContent>
          </Card>
        </li>
      ))}
    </ul>
  )
}