# AdaptTable — full documentation > AdaptTable v3 documentation: a framework-neutral data engine in @adapttable/core, headless React bindings in @adapttable/react, and native table adapters for Mantine, MUI, Chakra UI, Ant Design, Radix Themes, Base UI, shadcn/ui and unstyled Tailwind. Individually imported features include filtering, grouping, pivot tables, formulas, editing, virtualization and export. Responsive mobile cards, URL state, i18n/RTL and optional provider-neutral AI sessions. MIT licensed; applications own data and persistence. Start with getting-started and concepts for package ownership, then features for opt-in composition. Use the v2-to-v3 migration guide when upgrading; historical migration examples describe their named versions, not current import paths. Vue and Angular bindings are not shipped. This file is generated from the canonical guides; the linked index is at https://orwa-mahmoud.github.io/adapttable/llms.txt. --- # Get started with AdaptTable — React table for your UI kit ▶ **Nothing to install yet — [open the live demo](https://orwa-mahmoud.github.io/adapttable/demo/) and use it.** Flip between [Mantine](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mantine) · [MUI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mui) · [Chakra](https://orwa-mahmoud.github.io/adapttable/demo/?kit=chakra) · [Ant Design](https://orwa-mahmoud.github.io/adapttable/demo/?kit=antd) · [Radix](https://orwa-mahmoud.github.io/adapttable/demo/?kit=radix) · [Base UI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=base-ui) · [shadcn](https://orwa-mahmoud.github.io/adapttable/demo/?kit=shadcn) · [Tailwind](https://orwa-mahmoud.github.io/adapttable/demo/?kit=tailwind) on the same data, and toggle grouping and inline editing while you are there. AdaptTable is a headless, UI-agnostic React data table. Pick the adapter for your design system and you get a styled, sortable, filterable, paginated table with URL-synced state, selection + bulk actions, RTL, and dark mode. ## Install The fastest path is the CLI — it detects your UI kit from `package.json`, prints the install command, and scaffolds a starter `src/PeopleTable.tsx`: ```bash npx @adapttable/cli init ``` Prefer zero install first? Open a live starter in [StackBlitz (Mantine)](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine) — or [any other kit](#try-it-in-stackblitz). A plain adapter `DataTable` is 69–79 kB min+gzip (measured 2026-09-15 from packed fixtures; React and the kit stay external). The [FAQ](./faq.md#how-big-is-it--is-it-tree-shakeable) has the method and the rest of the grid. Or install manually: `@adapttable/core`, the adapter for your kit, and the kit's own packages (peer dependencies — skip what you already have). `react` / `react-dom` 18 or 19 are peers everywhere. ```bash # Mantine pnpm add @adapttable/core @adapttable/mantine @mantine/core @mantine/hooks # Material UI pnpm add @adapttable/core @adapttable/mui @mui/material # Chakra UI (v3) pnpm add @adapttable/core @adapttable/chakra @chakra-ui/react @emotion/react # Ant Design pnpm add @adapttable/core @adapttable/antd antd # Radix Themes pnpm add @adapttable/core @adapttable/radix @radix-ui/themes # Base UI pnpm add @adapttable/core @adapttable/base-ui @base-ui/react # shadcn/ui — one import, pre-wired with the shadcn class preset pnpm add @adapttable/core @adapttable/shadcn # Tailwind / unstyled — bring your own classes pnpm add @adapttable/core @adapttable/unstyled ``` ## Supported versions | Dependency | Supported range | | ----------------- | --------------------------------------------------------------------- | | React / React DOM | `^18.0.0 \|\| ^19.0.0` (CI-tested on 18.3 / 19.0 / 19.2) | | Mantine | `^7.2.0 \|\| ^8 \|\| ^9` | | MUI | `^6 \|\| ^7 \|\| ^8 \|\| ^9` | | Chakra UI | `^3.13.0` | | Ant Design | `^6` | | Radix Themes | `^3` | | Base UI | `^1` | | Node.js | `>=22.12.0` (packed releases are CI-tested on Node 22.12 and Node 24) | Each floor is the lowest version the adapter actually runs on — verified by automated install-and-render probes, not guesswork. ## Provider setup Each adapter renders with its UI kit's own components, so your app needs that kit's provider once at the root — exactly as the kit's docs describe. **Mantine** ```tsx // main.tsx — once per app, straight from Mantine's own setup guide. import "@mantine/core/styles.css"; import { MantineProvider } from "@mantine/core"; ; ``` **Material UI** — works with the default theme out of the box; wrap in `ThemeProvider` to customize: ```tsx import { createTheme, ThemeProvider } from "@mui/material"; ; ``` **Chakra UI** (v3) — the provider takes a system; use the built-in `defaultSystem` or your own: ```tsx import { ChakraProvider, defaultSystem } from "@chakra-ui/react"; ; ``` **Ant Design** — works without a provider; add `ConfigProvider` for theme or locale: ```tsx import { ConfigProvider } from "antd"; ; ``` **Radix Themes** — import the Themes stylesheet and wrap in `` (see Radix Themes docs). **Base UI** — no provider. Import `@adapttable/base-ui` (it side-effect-loads minimal chrome CSS) or `@adapttable/base-ui/styles.css` once at the app entry. **shadcn/ui** — no provider. `@adapttable/shadcn` is the unstyled adapter pre-wired with the shadcn class preset, so it inherits your app's existing shadcn/ui theme (its CSS variables + Tailwind config) automatically. **Unstyled** — no provider. It renders semantic HTML with `data-*` and `className` hooks for your own CSS or Tailwind. ## Your first table Pass `data` and declare columns — that's the whole thing: ```tsx // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/base-ui", "@adapttable/shadcn", // "@adapttable/unstyled" — same props everywhere. import { DataTable } from "@adapttable/mantine"; import { filters } from "@adapttable/mantine/filters"; interface Person { id: string; name: string; role: string; status: string; hiredAt: string; } const PEOPLE: Person[] = [ { id: "1", name: "Ada Lovelace", role: "Engineer", status: "active", hiredAt: "2021-03-01", }, { id: "2", name: "Alan Turing", role: "Founder", status: "active", hiredAt: "2019-06-15", }, { id: "3", name: "Grace Hopper", role: "Admiral", status: "retired", hiredAt: "2018-01-20", }, ]; export function PeopleTable() { return ( r.id} features={[filters([])]} /> ); } ``` Column `filter` declarations need the filters feature — `filters([])` when every filter lives on a column, or pass standalone defs to the factory (below). See [feature composition](./features.md). What you just got without writing any of it: search, sorting, pagination (paged on desktop, infinite scroll on mobile), URL-synced state (reload-safe, shareable links), empty/loading states, a mobile card layout, and a filter form built from those `filter` declarations with kit-native widgets — each filter also drives its own removable chip, URL parsing, and row predicate. - Headers auto-derive from keys (`hiredAt` → "Hired At"); pass `header` to control the text in any language. - Dot-path keys reach nested values: `{ key: "department.name" }`. - Filters that aren't columns go in a table-level array: ```tsx r.id} features={[ filters([ { key: "companyId", type: "select", label: "Company", options: companies, }, { key: "budget", type: "numberRange" }, ]), ]} /> ``` ## Try it in StackBlitz Prefer to try before installing? Each starter is a minimal Vite app — one table on a demo dataset — that boots in the browser with no local setup. Pick your kit: - [Mantine](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine) - [Material UI](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mui) - [Chakra UI](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/chakra) - [Ant Design](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/antd) - [Radix Themes](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/radix) - [Base UI](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/base-ui) - [shadcn/ui](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/shadcn) - [Unstyled / Tailwind](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/unstyled) The source for each lives in [`starters/`](https://github.com/orwa-mahmoud/adapttable/tree/main/starters). ## Where next - [Columns](./columns.md) — headers, custom cells, the Columns menu (show/hide, reorder, pin), resizing. - [Inline cell editing](./cell-editing.md) — compose `editing()`, kit-native editors, keyboard flow. - [Row reordering](./row-reordering.md) — opt-in `rowReorder`, Space-lift - [Row pinning](./row-pinning.md) — sticky top and bottom rows, `{ top, bottom }` ids - [Pinned summary rows](./pinned-summary-rows.md) — host-owned totals outside the row model - [Row and column spanning](./row-spanning.md) — `getCellSpan`, one cell list per row - [Full-width and separator rows](./full-width-rows.md) — `extraRows`, host-injected slots - [Row styling and heights](./row-styling.md) — `rowStyle`, `rowHeight`, variable-height virtualizer keyboard, dataset-relative indices. - [Filtering](./filtering.md) — every filter type, options sources, chips, popover vs drawer. - [Data tiers](./data-tiers.md) — server data without a query library (`onQueryChange`), or full control via `source` and TanStack Query. - [Demo](https://orwa-mahmoud.github.io/adapttable/demo/) — every adapter, live. Full surface: [API reference](./api.md) · [core concepts](./concepts.md). --- # AdaptTable concepts — headless core, TableSource, adapters ▶ **See it working:** [the live demo](https://orwa-mahmoud.github.io/adapttable/demo/) — one dataset, one feature set, re-rendered by every real adapter. ## The `TableSource` contract Everything in AdaptTable revolves around one idea: a **`TableSource`** — a uniform contract that a table consumes regardless of where its rows came from. Both built-in source hooks fulfil it, and the table renders without knowing which produced it. ```ts interface TableSource { rows: readonly TRow[]; total: number; isLoading: boolean; // FIRST load only — refreshes never re-raise it isFetching: boolean; // any in-flight request isFetchingNextPage: boolean; // an append fetch (infinite mode) hasNextPage: boolean; // more rows can be APPENDED (always false when paged) fetchNextPage: () => void; // appends; no-op in paged mode refetch?: () => void; // re-runs the underlying fetch error: Error | null; paginationMode: "infinite" | "paged"; // state page: number; limit: number; search: string; sortBy: string | undefined; sortDir: "asc" | "desc" | undefined; extra: Record; // setters setPage; setLimit; setSort; setSearch; setExtra; setExtras; clearAll; } ``` Because the table is agnostic to the source's origin, you can switch between in-memory and server data — or build a custom source — without touching the UI. ## Source builders ### `useFrontendData` In-memory. Filters by a searchable-text projector, sorts by a column's `sortValue` (or a custom `getSortValue`), and slices for the current page. ```ts const source = useFrontendData({ data, columns, getSearchText, getSortValue }); ``` ### `useQuerySource` Server-paginated. Wraps a caller-supplied `useInfiniteQuery` hook and maps each page to rows via `selectPage`. Flattens pages in infinite mode, returns the latest page in paged mode, and clamps out-of-range pages. ```ts const source = useQuerySource({ usePaginatedQuery, selectPage, baseParams }); ``` ## Columns ```ts interface ColumnDef { key: string; // unique; also the backend sortBy value header: ReactNode; // pre-translated Cell?: ComponentType<{ row: TRow; rowIndex: number }>; // stable identity accessor?: (row: TRow) => ReactNode; // lightweight sortValue?: (row: TRow) => string | number | boolean | null | undefined; sortable?: boolean; width?: number | string; align?: "start" | "center" | "end"; mobileLabel?: string; hideOnMobile?: boolean; hideOnDesktop?: boolean; } ``` ## Pagination modes `"auto"` (the default) resolves to **infinite scroll on mobile** and **paged on desktop**, using the same breakpoint the table uses, so the two never drift. Force a mode with `paginationMode: "paged" | "infinite"`. In infinite mode the adapters auto-load the next page when the bottom of the list scrolls into view (via `IntersectionObserver`, prefetching ~200px early), and also render an explicit **Load more** button as a keyboard- and screen-reader-friendly fallback. The auto-load behaviour is packaged as a headless hook, `useInfiniteScroll`, exported from `@adapttable/core` — attach the returned ref to a sentinel element after your last row to get the same behaviour in custom markup: ```tsx const sentinelRef = useInfiniteScroll({ hasNextPage: source.hasNextPage, isFetchingNextPage: source.isFetchingNextPage, fetchNextPage: source.fetchNextPage, itemCount: source.rows.length, // re-arms so short pages keep loading enabled: source.paginationMode === "infinite", }); // …render rows…
; ``` It is SSR- and test-safe: where `IntersectionObserver` is unavailable it no-ops, leaving the Load more button as the path forward. ## Optional virtualization Long infinite lists can opt into row/card windowing with `virtualize`. The core exports `useTableVirtualization`, and the ready adapters wire it into their desktop rows and mobile cards. With no `maxHeight` the window tracks the page scroll; add `maxHeight` and the same prop virtualizes inside the scroll box instead — fifty thousand rows in a 380px panel stay a handful of DOM nodes. Ant Design maps the same `virtualize()` feature to antd's native virtual table mode. ```tsx row.id} paginationMode="infinite" virtualize estimateRowSize={56} estimateCardSize={140} /> ``` Virtualization is optional. Leave it off for small lists or paged tables; turn it on for long infinite lists. ## Responsive cards Adapters automatically switch from table rows to mobile cards at the shared mobile breakpoint. `hideOnMobile` can hide low-value columns, while `mobileIdentityColumns` preserves a configurable number of leading desktop-visible columns so every card keeps enough identity to be useful. ```tsx ``` ## The engine, and why it has no React in it Filtering, sorting, paging and grouping are decisions about data, not about the DOM. They live in `@adapttable/core` as a plain object with no framework in its import graph, and `@adapttable/react` is the binding that subscribes a component tree to one. ```ts import { createTableEngine } from "@adapttable/core"; const engine = createTableEngine({ data: people, columns: [{ key: "name", sortable: true }], rowKey: (row) => row.id, defaults: { limit: 25 }, }); engine.dispatch({ type: "setSearch", search: "ada" }); engine.rows("page"); // the rows that page shows ``` `CreateTableEngineOptions` is what you build one from. `data`, `columns`, `rowKey`, `locale`, `paginationMode`, `filterFn` and `getSearchText` stay live — change one and the engine follows. `tableId` and `defaults` are read once, because an identity and a seed cannot be retroactively different. `TableEngine` is the handle: | Member | What it gives you | | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `snapshot()` | A `TableSnapshot`: the current query state, the page it is showing, and the four revision counters. | | `rows(scope)` | A `TableRowScope` — `"page"`, `"visible"` or `"full"`. | | `dispatch(operation)` | A `TableOperation` a person performed: `setSort`, `setSearch`, `setPage`, `setLimit`, `setFilters`, `setGroupBy`, `setSelection`. | | `configure(patch)` | A `TableEngineConfigPatch` — controlled state a binding replays. Pass `{ silent: true }` to move without waking subscribers. | | `invalidate(axes, next)` | Tell it the data or columns changed. `invalidate(["data"])` with no new array re-derives from the rows it already holds. | | `subscribe(axes, listener)` | Wake on a `TableRevisionAxis` — `data`, `view`, `schema` or `policy` — and nothing else. | | `cellValue` / `rowByKey` / `rowKey` / `getColumn` | Read one cell, find a row, take a row's identity, look up a column. | `TableRevisions` carries those four counters. They are separate so a consumer can wake on the one it cares about: a virtualizer on `data`, a toolbar on `view`, an agent on `policy`. `snapshot().page` is the page actually on screen. Ask for page 9 of a table that shrank to three and it reports the last page, while `requestedPage` remembers what you asked for — so restoring the rows restores the page, instead of stranding a reader on page 1. ### Handing the engine to something that is not a table `createNeutralTable(engine, tableId, binding)` wraps one as a `NeutralTable`: the same rows and revisions plus an `operations` map saying which of them are actually wired right now. That is the shape `@adapttable/ai` reads, and the `NeutralTableBinding` is how a host tells it what the surrounding UI can do. An operation that stops being wired disappears from the map, so nothing is offered a capability the table can no longer perform. ## The two ways to use it 1. **Batteries-included** — `import { DataTable } from "@adapttable/"`. 2. **Headless** — `import { useDataTable } from "@adapttable/react"` and render your own markup with the returned prop-getters. --- # React table features — optional imports, presets and plugins ▶ **See it working:** [the Feature Lab](https://orwa-mahmoud.github.io/adapttable/demo/all-options/) — every opt-in, on every kit. Every opt-in is an import and one entry in `features`. The import is the switch, which is what lets a table pay only for what it named: ```tsx import { DataTable } from "@adapttable/mantine"; import { rowReorder } from "@adapttable/mantine/row-reorder"; row.id} features={[ rowReorder((from, to) => setRows(applyRowReorder(rows, from, to))), ]} />; ``` A built-in factory and a host plugin are the same `TableFeature` type in the same array — that is the public plugin surface, not a parallel API. ## What the import buys A bundler follows imports, not prop values, so the import is the switch: a table downloads a feature's implementation when it names it, and not before. An adapter's `DataTable` carries the base contract — responsive rendering, loading, error and empty states, accessibility, sorting, search and pagination — for the weight the [FAQ's measured table](./faq.md#how-big-is-it--is-it-tree-shakeable) reports. Everything else arrives with its own entry. The props that used to arm features are inert: `enableColumnMenu`, `bulkActions`, `contextMenu`, `commandPalette`, `statusBar`, `sidePanel`, `findInTable` and the rest configure a feature you compose, and draw nothing on their own. ## Kit subpaths Every public adapter exports the same factories: | Import | Factory | | ----------------------------------- | ----------------------------------------------------------------- | | `@adapttable//row-reorder` | `rowReorder` | | `@adapttable//saved-views` | `savedViews` | | `@adapttable//grouping-panel` | `groupingPanel` — grouping headers + interactive panel | | `@adapttable//grouping` | `grouping` — code-fixed grouping without the interactive panel | | `@adapttable//editing` | `editing` | | `@adapttable//virtualize` | `virtualize` | | `@adapttable//column-menu` | `columnMenu` | | `@adapttable//cell-navigation` | `cellNavigation` | | `@adapttable//preset` | `standardFeatures`, `StandardFeatureOptions` | | `@adapttable//features` | every factory, plus `applyTableFeatures` | | `@adapttable//pivot` | `PivotPanel` and the pivot engine (`pivot`, `pivotTableModel`, …) | `` is `mantine`, `mui`, `chakra`, `antd`, `radix`, `base-ui`, `shadcn`, or `unstyled`. The factories themselves live in `@adapttable/react/features`; the kit subpaths re-export them so the import path matches the table. The pivot engine stays a calculation — `import { pivot } from "@adapttable/core/pivot"` — not a `` prop. The kit `/pivot` subpath is the panel plus that engine, so a host that composes a pivot table still does it in one import. ## The standard preset — one import for a good table ```tsx import { DataTable } from "@adapttable/mantine"; import { standardFeatures } from "@adapttable/mantine/preset"; r.id} features={standardFeatures()} />; ``` With no arguments it composes the features that work with nothing else supplied: the Columns menu, the density chooser, CSV export, find-in-table, fit-columns, the fullscreen toggle, header filters, multi-sort, resizable columns and the status bar. Configurable preset members join only when you give them input. Features outside the preset append to the same ordinary array: ```tsx import { groupingPanel } from "@adapttable/mantine/grouping-panel"; [ ...standardFeatures({ bulkActions: [{ key: "delete", label: "Delete", onClick: remove }], filters: [{ key: "team", type: "select", options: teams }], savedViews: { storage: "local" }, }), groupingPanel("team"), ]; ``` One factory is callable with no arguments and is still NOT a member: the selection statistics feature needs a cell range — which needs `cellNavigation` — while arming a row-selection column on its own. Import it directly when you want it. The result is an ordinary array. Append to it, filter it, or replace an entry: ```tsx features={[...standardFeatures(), auditLog(), rowReorder(reorder)]} ``` Later entries win, so re-composing a member replaces it rather than doubling it, and a duplicate id warns in development. **The preset entry statically imports everything it can compose**, so its own bundle contains the configurable members whether or not you pass their options. That is the trade: one import instead of ten. Measured on MUI, the table alone is 69 kB gzipped and the same table with `standardFeatures()` composed is 122 kB. A table counting every byte imports the individual features it uses instead, and pays for those alone — `pnpm budget` measures both paths on every run. ## Types, and why no annotation is needed ```tsx import { groupingPanel } from "@adapttable/mantine/grouping-panel"; import { virtualize } from "@adapttable/mantine/virtualize"; ; ``` A factory whose configuration says nothing about rows returns a `StaticTableFeature` — the same feature whatever the table holds — and composes into any `` with no type argument. A factory that takes a row-typed callback returns `TableFeature` and infers the row from that callback: `rowReorder(handler)`, `editing(save)`. Put one in a table of a different row type and the compiler refuses, naming the feature's own row type. A host plugin is the same object: `feature("audit-log", { statusBar: true })`, or a `TableFeature` with `setup(host)` for live registration. ## Host plugins — `setup(host)` A plugin registers on the same `TableFeature` the factories return — `filterTypes`, an export writer, palette commands, context-menu items, a side panel — so the registration surface is one array, one host, with lifecycle via `onDispose` or a function returned from `setup`. ```tsx import type { TableFeature } from "@adapttable/mantine/features"; const currencyFilter: TableFeature = { id: "currency-filter", setup(host) { host.registerFilterType({ type: "currency", widget: "number", ops: ["eq", "gt", "lt"], defaultOp: "eq", stateKeys: (def) => [def.key], match: () => true, chips: () => ({}), conditionToExtra: () => ({}), }); return () => { /* table unmounted, or `features` changed */ }; }, }; ; ``` Every seam is a method on `TableFeatureHost`. Built-in factories that carry extras (`filterTypes`, `exportCsv` with a writer, `commandPalette` with extra commands, `contextMenu` with extra items, `sidePanel` panels) call the same methods in `setup`, so a plugin is not a second API. | Host method | Same as | | -------------------------- | --------------------------------------------- | | `registerFilterType` | `filterTypes([spec])` | | `extendFilterType` | a custom feature's `host.extendFilterType(…)` | | `registerEditor` | `column.editor: { type: "custom", render }` | | `registerAggregator` | `aggregate({ key: fn })` | | `registerWriter` | `exportCsv({ writer })` | | `registerColumnMenuAction` | appended after the built-in Columns actions | | `registerPanel` | `sidePanel({ panels, … })` | | `registerCommand` | `commandPalette({ commands, … })` | | `registerContextMenuItems` | `contextMenu({ items })` | | `onDispose` | cleanup when the table unmounts | A named editor is a string `column.editor` that is not a built-in (`"text"`, `"number"`, …). `resolveCellEditor` turns it into `{ type: "custom", render }` so adapters keep one custom-editor path. A named aggregator is a string `aggregate()` looks up after the built-ins, when the mapper **runs** (inside the table), not when `aggregate()` is called in the parent. `registerPanel` appends to a composed `sidePanel()` dock — the feature still owns `open` / `onOpenChange`. Registrations add content to their matching composed feature; they do not pull command-palette, context-menu or side-panel chrome into the base table. The superseded `FilterTypeRegistry.register` / `extend` methods and the `filterTypes` enabling prop are not part of the v3 API. ## Features that own hooks — `provider` `apply` sets props and `setup(host)` registers values, but neither can add a React hook. Hooks must be called in the same order on every render, so a table that calls `useRowReorder` only when the feature is composed is not a table with an optional feature — it is a crash. That is why the enabling props never saved a byte: whatever the props said, the import was already in the graph. A component is the answer, because mounting and unmounting one is the legal way to add and remove hooks. A feature may carry a `provider` whose component wraps the table, calls whatever hooks it needs, and publishes the result under a typed key: ```tsx import { FeatureStateScope, featureStateKey, type TableFeature, } from "@adapttable/react/adapter"; export const AUDIT = featureStateKey<{ count: number }>("audit-log"); export const auditLog = (): TableFeature => ({ id: "audit-log", provider: { Provider: ({ children }) => { const [count, setCount] = useState(0); useEffect(() => subscribe(() => setCount((n) => n + 1)), []); return ( {children} ); }, }, }); ``` Anything under the table reads it with `useFeatureState`, which returns `undefined` when the feature is not composed — the ordinary answer for a table that does not have it, not an error: ```tsx const audit = useFeatureState(AUDIT); if (!audit) return null; return {audit.count} changes; ``` Both halves are typed: `featureStateKey` fixes what the provider must publish and what a reader gets back, so this is a contract rather than a bag of strings. ### Features that draw — `renders` State is half of a feature; the other half is what the reader sees. A feature fills named positions in the table, and the kit's own components are what it fills them with: ```tsx import { FeatureSlot, featureSlotKey, slotRender, } from "@adapttable/react/adapter"; export const STATUS_BAR = featureSlotKey<{ total: number }>("status-bar"); export const statusBar = (): TableFeature => ({ id: "status-bar", renders: [ slotRender(STATUS_BAR, ({ total }) => ), ], }); ``` The table computes the props and asks; it never learns what was drawn: ```tsx ``` `slotRender` is what keeps `total` typed at the call site; one feature can fill several positions that take different props. An unfilled slot renders nothing, so chrome around a position the reader does not have simply is not there. `useFeatureSlotFilled` answers when a wrapper must not be drawn around nothing. Several features may fill one position — a toolbar takes more than one control — so a slot keeps every answer and orders them by feature id, the same way providers nest. Fillers belong to the table that composed them, so two tables on a page never draw each other's controls. This is why a kit's pixels stay out of the base graph: the adapter's table asks for a position, and only the feature that was imported can answer. ### What the table guarantees - **Order comes from the ids, not from your array.** Providers nest in feature-id order, so `[groupingPanel("team"), auditLog()]` and `[auditLog(), groupingPanel("team")]` build the identical tree. Moving a line never remounts a provider or discards what it was holding. - **One provider per id.** A duplicate id warns in development and the last one wins, exactly as `apply` already resolves duplicates. - **A provider mounts when its feature arrives and unmounts when it leaves**, and its cleanup runs once. - **State belongs to its own table.** It travels by context, so two tables on a page — and a table nested in another table's row detail — never read each other's. A nested table shadows the outer value for its own subtree while everything the outer table published stays readable. This is the same `TableFeature` in the same `features` array. There is no second registry to learn and nothing global to collide over. ## Every factory `rowReorder` · `rowPinning` · `pinnedSummaryRows` · `cellSpan` · `extraRows` · `rowAppearance` · `rowDetail` · `nestedTable` · `editing` · `rowEditing` · `batchEditing` · `editHistory` · `dirtyIndicators` · `grouping` · `groupingPanel` · `tree` · `virtualize` · `columnMenu` · `resizableColumns` · `collapsibleColumnGroups` · `exportCsv` · `cellNavigation` · `findInTable` · `fullscreen` · `commandPalette` · `contextMenu` · `sidePanel` · `bulkActions` · `filters` · `filterTypes` · `headerFilters` · `savedViews` · `selectionStats` · `densityChooser` · `print` · `statusBar` · `undoRedoButtons` · `multiSort` · `fitColumns` · `columnSelectionCheckbox` · `feature` (ad-hoc patch) · `applyTableFeatures` (the merge used by every adapter) · `useTableFeatures` (apply + `setup(host)`, the hook every adapter runs) · `featureHostOf` / `rememberFeatureHost` (the host of one table, never a sibling's) · `FeatureHostProvider` / `useFeatureHost` (hooks under that table) · `bindFeatureHostFn` (a mapper created outside the table still resolves names for the table that invokes it). --- # Client & server React table data — one TableSource API One ``, three ways to feed it — from "here's an array" to full query-library control. Search, sorting, filters, chips, and URL sync behave identically in every tier. ## Example ### 1. Frontend — `data` Pass the rows; the table filters, sorts, and pages them in memory. ```tsx // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere. import { DataTable } from "@adapttable/mantine"; interface Person { id: string; name: string; role: string; } const PEOPLE: Person[] = [ { id: "1", name: "Ada Lovelace", role: "Engineer" }, { id: "2", name: "Alan Turing", role: "Founder" }, { id: "3", name: "Grace Hopper", role: "Admiral" }, ]; export function PeopleTable() { return ( r.id} /> ); } ``` ### 2. Server — `data` + `total` + `loading` + `onQueryChange` Your API paginates; the table owns the query state and tells you when to fetch. ```tsx import { useState } from "react"; // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere. import { DataTable } from "@adapttable/mantine"; interface Person { id: string; name: string; role: string; } export function PeopleTable() { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(false); return ( { setLoading(true); try { const params = new URLSearchParams({ page: String(query.page), limit: String(query.limit), search: query.search, }); // Forward `signal`: superseded requests abort at the source. const res = await fetch(`/api/people?${params}`, { signal }); const body = (await res.json()) as { items: Person[]; total: number }; setRows(body.items); setTotal(body.total); } finally { setLoading(false); } }} columns={[{ key: "name", sortable: true }, { key: "role" }]} rowKey={(r) => r.id} /> ); } ``` #### What the query carries, and how it grows Every server tier receives one consolidated `TableQuery`: ```ts { (page, limit, search, sortBy, sortDir, sortLevels, filters); } ``` That is the whole baseline, and it will not change. Capabilities beyond it — grouping, aggregates, nested filter trees, facet counts, cursor pagination — ride as **optional** fields that a source opts into by declaring what its endpoint can answer: ```tsx useServerData({ rows, total, // this endpoint can group and count; it cannot do the rest yet supports: { grouping: true, facets: true }, onQueryChange: async (query, { signal }) => { // query.groupBy → ["team"] when the user is grouping // query.facets → ["status"] when a filter wants distinct-value counts }, }); ``` Declare nothing and nothing changes: the query arrives with exactly the seven baseline fields, so an endpoint written before a capability existed keeps working untouched. Declare a capability and its field starts arriving. If the table wants something the source has not declared, the field is **omitted rather than sent and ignored** — a server should never receive a field it never agreed to read — and development logs which capability would unlock it. That warning is the intended way to discover the next thing your backend could do, not an error. | Field | Capability | Carries | | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `groupBy` | `grouping` | Grouping keys, outermost first | | `aggregates` | `aggregates` | `{ key, fn }` pairs to compute. Optional `supports.aggregateOperations` lists the ids the backend can answer; omit it and the five standard functions are assumed. Custom ids travel as strings, never as functions. | | `filterTree` | `filterTree` | Nested AND/OR condition tree | | `facets` | `facets` | Column keys needing distinct-value counts. The response returns the same keys as `facets` on the page (`PaginatedResponse.facets` / `PageSelector.facets`) — counts for the filtered set with each facet's own filter removed. | | `cursor` | `cursor` | Opaque cursor from the previous response | The flat `filters` bag is always populated, including when `filterTree` is sent, so a server that only reads the simple form keeps working. ### 3. Full control — `source` Build a `TableSource` yourself — `useQuerySource` over TanStack Query (shown below; wrap your app in its `QueryClientProvider`), `useFrontendData` for headless in-memory use, or a hand-rolled object that fulfils the contract. ```tsx import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query"; // or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd", // "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere. import { DataTable, type PaginatedResponse, type TableQueryParams, useQuerySource, } from "@adapttable/mantine"; interface Person { id: string; name: string; role: string; } async function fetchPeople( params: Partial ): Promise> { const search = new URLSearchParams(); for (const [key, value] of Object.entries(params)) { if (value !== undefined) search.set(key, String(value)); } const res = await fetch(`/api/people?${search}`); return (await res.json()) as PaginatedResponse; } // Your query hook: fetch one page for the current params. function usePeopleQuery(params: Partial) { return useInfiniteQuery({ queryKey: ["people", params], queryFn: ({ pageParam }) => fetchPeople({ ...params, page: pageParam }), initialPageParam: params.page ?? 1, getNextPageParam: (last) => (last.hasNext ? last.page + 1 : undefined), placeholderData: keepPreviousData, }); } export function PeopleTable() { const source = useQuerySource({ usePaginatedQuery: usePeopleQuery }); return ( r.id} /> ); } ``` ## Explicit `mode` — when inference isn't what you meant The tier is inferred from what you pass (`data` alone → frontend; `data` + `onQueryChange` → server; `source` → full control). The optional `mode` prop pins it explicitly — and unlocks one combination inference cannot express: | I want… | Pass | `onQueryChange` acts as… | | ----------------------------------------------------------------- | ------------------------------------------ | --------------------------------------------------------------- | | The table to fetch nothing; my handler runs every query | `mode="server"` (requires `onQueryChange`) | **the contract** — you fetch and hand back `data` + `total` | | The table to keep filtering/sorting/paging my `data`, but TELL me | `mode="frontend"` + `onQueryChange` | **a pure notification** — fires per committed change, not mount | | Today's inference exactly | omit `mode` | contract when present, nothing otherwise | `mode="server"` without `onQueryChange` does not compile; `mode` together with `source` dev-warns and `source` wins. ## How it works - Tier resolution is by what you pass: `source` wins; otherwise `onQueryChange` selects the server tier; otherwise `data` alone is the frontend tier. Mixing tiers dev-warns and uses `source`. - **Frontend**: search, the declarative-filter predicate, sorting, and page slicing all run in memory. Pagination defaults to `"auto"` — paged on desktop, infinite scroll on mobile. - **Server**: the table owns page, page size, debounced search, sort, and filter state (URL-synced), and emits ONE consolidated `TableQuery` — `{ page, limit, search, sortBy, sortDir, sortLevels, filters }` — per real change, **including once on mount with the URL-restored values**. Your only job is to fetch and hand back `data` + `total`. - Server queries are value-keyed (`stableKey`), so identical re-renders and StrictMode double-mounts never re-fire the same query; when a newer query supersedes an in-flight one, the previous call's `signal` aborts — forward it to `fetch` and out-of-order responses die at the source. - **Full control**: every source builder returns the same [`TableSource`](./concepts.md) contract, so the table can't tell in-memory from server data — switch tiers without touching the UI. - Column `filter` shorthands and the `filters` array drive widgets, chips, and URL parsing in **all three tiers**; only the frontend tier also applies the row predicate (the other tiers receive `query.filters` instead). ## What a source can do — `capabilities` Some controls only work if the data layer behind them can answer. Exporting everything needs every row; grouping needs either the whole filtered set or a server that groups; "select all 2,431 matching" needs a source that can speak for rows that are not on screen. A source states what it supports: ```ts const source: TableSource = { ...rest, capabilities: { fullDataset: false, // one page at a time grouping: "server", // the API returns group rows selectAcrossPages: true, // it can act on the whole match set exportScope: "all", // it permits a wired full-export route totalCount: "exact", // `total` counts matches, not what has loaded }, }; ``` | Capability | Values | What it permits | Off means | | ------------------- | ------------------------------- | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | | `fullDataset` | `boolean` | Every row is reachable, not just the page on screen | The other four are decided independently | | `grouping` | `"client" \| "server" \| false` | `groupBy` groups in the browser, or renders the server's group rows | `groupBy` is ignored, and the status bar says why | | `selectAcrossPages` | `boolean` | The "select all N matching" banner after a full page is selected | Selection stays the rows on screen | | `exportScope` | `"all" \| "page"` | A source-owned `allFilteredRows` route may serve `scope: "all"`; it does not retrieve rows by itself | The Export button is disabled, with the reason on the control | | `totalCount` | `"exact" \| "loaded"` | `total` is the match count | `total` is what has arrived so far | **Omit `capabilities` and nothing changes.** The table reads the same answers off the source's shape, exactly as it always has: `allFilteredRows` present means the full dataset, `groups` present means the server grouped, a non-zero `total` means the count is real — which is why every source `useFrontendData` and `useQuerySource` build already answers correctly without declaring anything. Declare it when the shape is misleading in either direction: a paged source whose backend permits full exports (with the actual request wired on the export feature), or an in-memory slice that must not pretend to be the whole set. Capabilities describe support; they are not transport. `exportScope: "all"` without rows or a retrieval handler leaves Export all disabled. A source-owned route needs `allFilteredRows` to be present and the declaration to permit `"all"`. A host that wires `exportCsv.onExportAll`, `exportCsv.request`, or `exportCsv.fetchAll` supplies an independent executable route, so it can reach the rest whatever the source can or cannot retrieve. `onExportAll` is the server-built route: it receives the page-free current view, reports progress, and supports cancellation without loading rows into the table. See [browser and server-built exports](./exporting.md). ## Cache keys for TanStack Query and SWR Wiring the table to a query library means turning the emitted `TableQuery` into a cache key. Hand-rolling that fails in two ways that are hard to see: a key built from an object literal changes whenever `filters` is rebuilt, so the cache misses on every keystroke; and invalidation after a save either refetches the whole endpoint or only the page on screen. ```tsx import { tableQueryKey, tableQueryBaseKey } from "@adapttable/core"; const infinite = useInfiniteQuery({ queryKey: tableQueryKey(query, { scope: "people" }), queryFn: ({ signal }) => fetchPeople(query, signal), getNextPageParam: (last) => last.nextCursor, }); // after a write — every page of this view, nothing else queryClient.invalidateQueries({ queryKey: tableQueryBaseKey(query, { scope: "people" }), }); ``` - **`tableQueryBaseKey`** covers what decides _which_ rows: search, filters, sort, grouping, page size. - **`tableQueryKey`** appends _where_ in them the table is: page and cursor. The full key starts with the base key, so a library that matches by prefix — TanStack Query does — invalidates every page of a view from the base key alone. Both are stable across renders and ignore the order a filter object was built in, so an identical query always produces an identical key. Pass `scope` when a page shows more than one table, so they never share an entry. For SWR, hand `useSWR` the array directly or join it — the parts are strings. Neither library is imported or depended on here; these are plain arrays that happen to be exactly what both expect. The options shape is exported as `TableQueryKeyOptions`. ## Which requests actually fire `onQueryChange` fires per real change, not per render. Four guarantees, each covered by a test: - **One request per query.** Queries are compared by value, so setting the same search term three times in a tick, an identical re-render, or a StrictMode double-mount all collapse into a single call. - **Setting a value it already holds is not a change.** No request fires. - **A superseded request aborts.** When a newer query replaces an in-flight one, the previous call's `signal` fires. Forward it to `fetch` and an out-of-order response dies at the source rather than overwriting fresher rows. - **Returning to a value re-requests it.** Typing `a` → `ab` → `a` fires three times. The first `a` was aborted the moment `ab` superseded it, so collapsing the third call would leave the table with nothing in flight and nothing to show. `refetch()` is the one deliberate exception: it asks for fresh data, so it fires even though the query has not changed. Using `useQuerySource` instead? Deduplication is your query library's, keyed the way you configured it, and these guarantees do not apply. ## Options | Prop | Type | Default | Description | | --------------- | ------------------------------------------------------------------------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `readonly TRow[]` | — | Frontend tier: all rows. Server tier: the current page, exactly as the server returned it. | | `total` | `number` | `0` | Server tier: total row count across all pages (drives the pager). | | `loading` | `boolean` | `false` | Server tier: request in flight (skeleton when no rows yet, subtle refresh indicator otherwise). | | `onQueryChange` | `(query: TableQuery, info: { signal: AbortSignal; key: string }) => void \| Promise` | — | Server tier: fired per consolidated query change, once on mount included. `info.key` identifies the request; echo it back as `responseKey` to say which one the rows answer. | | `responseKey` | `string` | — | The `info.key` of the request the current `data` answers (see [Row grouping](./row-grouping.md#server-tiers)). | | `aggregates` | `readonly QueryAggregate[]` | — | Developer defaults sent as `query.aggregates`; requires `supports.aggregates`. Reader overrides overlay this; Restore defaults returns here, not to the last response. | | `error` | `Error \| null` | `null` | Forwarded error to display. | | `source` | `TableSource` | — | Full control: a prebuilt source from `useFrontendData` / `useQuerySource` / your own. | ## Notes - **Picking a tier**: rows already in memory (up to a few thousand) → frontend. A paginated API and no query library → server. Caching, infinite scroll, prefetching, or an existing TanStack Query setup → `source` with `useQuerySource`. - The hooks behind the first two tiers — `useFrontendData` and `useServerData` — are exported for headless use; `useTableData` is the resolver that picks between them. - `useQuerySource` accepts `selectPage` (a `PageSelector` — project your own page shape to `{ rows, total? }` when it isn't `PaginatedResponse`), `baseParams` (static params merged into every call, e.g. a parent scope id), and `sanitizeParams`. Its query argument is typed structurally as `InfiniteQueryLike`, so TanStack Query stays a type-only peer. See [`examples/mui-query-source.tsx`](../examples/mui-query-source.tsx) for a complete runnable version. - `selectPage` is read through a ref: the projected rows recompute when fetched pages, pagination mode, or `selectorKey` change — not when the selector function's identity changes. Memoizing `selectPage` alone cannot trigger a re-projection. Pass the closed-over input as `selectorKey` when a memoized selector must re-run against unchanged fetched pages: ```tsx const selectPage = useCallback( (page: Page) => ({ rows: page.items.map((row) => ({ ...row, name: `${row.name}${suffix}` })), total: page.pagination.total, }), [suffix] ); const source = useQuerySource({ usePaginatedQuery, selectPage, selectorKey: suffix, }); ``` `selectorKey` accepts only a stable `string` or `number`. An unmemoized inline selector that closes over changing values and omits the key will keep showing the previous projection until the next fetch. - On the server tier, `source.refetch()` re-emits the current query; out-of-range pages and stale responses are handled for you via the abort signal. See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/). --- # React table columns — ColumnDef, accessors, sorting, pinning & custom cells ▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — this page's feature is the starter's whole `columns` array in `src/App.tsx`; edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz) Columns are plain objects — declare a `key` and the table renders the value, derives the header, and wires sorting and filtering around it. Everything beyond the key is an opt-in refinement. ## Example ```tsx import { type CellProps, type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled import { Badge } from "@mantine/core"; interface Person { id: string; name: string; nameAr: string; department: { name: string }; salary: number; status: "active" | "on-leave"; hiredAt: string; } const people: Person[] = [ { id: "1", name: "Amira Hassan", nameAr: "أميرة حسن", department: { name: "Engineering" }, salary: 96000, status: "active", hiredAt: "2021-03-15", }, { id: "2", name: "Tom Becker", nameAr: "توم بيكر", department: { name: "Design" }, salary: 78000, status: "on-leave", hiredAt: "2022-11-01", }, { id: "3", name: "Lina Park", nameAr: "لينا بارك", department: { name: "Engineering" }, salary: 105000, status: "active", hiredAt: "2019-07-20", }, ]; // Define Cell components at module level so their identity is stable. function StatusCell({ row }: CellProps) { return ( {row.status} ); } const columns: ColumnDef[] = [ // Bare key: auto header "Name"; i18n swaps the data path when locale="ar". { key: "name", i18n: { ar: "nameAr" }, sortable: true }, // Dot path reaches nested values; auto header "Department Name". { key: "department.name", header: "Department" }, // accessor formats; sortValue keeps the column sortable by the raw number. { key: "salary", header: "Salary (USD)", accessor: (r) => r.salary.toLocaleString(), sortValue: (r) => r.salary, sortable: true, align: "end", width: 140, }, // Cell: a full React component receiving { row, rowIndex }. { key: "status", Cell: StatusCell, mobileLabel: "Status" }, { key: "hiredAt", hideOnMobile: true, meta: { exportFormat: "date" } }, ]; export function People() { return ( r.id} locale="en" /> ); } ``` ## How it works - A bare `{ key }` is a complete column: the key doubles as the row's data path (dot paths reach nested values, `"department.name"`), and the header is auto-humanised (`hiredAt` → "Hired At"). An explicit `header` always wins, in any language. - `renderHeader` replaces the caption only. The cell still owns sort, resize and the menu, and passes a `controller` (`label`, `sortDir`, `toggleSort`) so a custom caption can stay wired. `headerTooltip` is a native title; `headerActions` sit after the caption. `renderFooter` replaces one summary cell; `tableFooter` is a free slot under the table. - Cell content resolves `Cell` → `accessor` → the key's data path. `Cell` is a React component receiving `{ row, rowIndex }`; `accessor` is the lighter function form. Mini charts are a separate import — see [sparkline columns](./sparkline.md). - `sortable` opts a column into sorting; on frontend data the comparator reads `sortValue`, falling back to the column's accessor. See [sorting](./sorting.md). - `i18n` maps locale tags to alternative data paths; the table's `locale` prop picks one (exact tag → primary subtag → `key`). The cell, client-side sort, and the column's filter all follow the resolved path — header text does not. - `renameable` opts a leaf into user naming when the table also provides `onColumnRename`. This changes display text, mobile and export labels while the key and localized data paths stay fixed. See [column management](./column-management.md). - `hideOnMobile` / `hideOnDesktop` drop a column per layout; `mobileLabel` overrides the label on mobile cards. - `key` is also the value sent to a backend as `sortBy`, so keep it API-stable. ## Options | Prop | Type | Default | Description | | ----------------- | ------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | | `key` | `string` | required | Unique id; data path for the cell value; the backend `sortBy` value. | | `header` | `ReactNode` | humanised from `key` | Header content, pre-translated by the caller. | | `renderHeader` | `(ctx) => ReactNode` | — | Custom caption; receives a controller so sort/resize stay available. | | `renderFooter` | `(ctx) => ReactNode` | — | Custom summary-row cell. | | `headerTooltip` | `string` | — | Native tooltip on the caption. | | `headerActions` | `ReactNode` | — | Host controls after the caption. | | `renameable` | `boolean` | `false` | Allow a host-persisted user display name from the Columns menu. | | `accessor` | `(row) => ReactNode` | read the key's data path | Lightweight cell renderer. | | `Cell` | `ComponentType>` | — | Component per row, receives `{ row, rowIndex }`; wins over `accessor`. | | `sortable` | `boolean` | `false` | Enable sorting for this column. | | `sortValue` | `(row) => SortableValue` | the generated accessor value | Primitive extractor for the client-side sort. See [sorting](./sorting.md). | | `exportValue` | `(row) => unknown` | the display value | Value written to a CSV export when the file should carry something other than the formatted cell. | | `align` | `"start" \| "center" \| "end"` | `"start"` | Text alignment within the cell. | | `width` | `number \| string` | — | Width passed through to the rendered header/cell. | | `mobileLabel` | `string` | `header` (when a string) | Label on mobile card layouts. | | `hideOnMobile` | `boolean` | `false` | Hide the column entirely on mobile. | | `hideOnDesktop` | `boolean` | `false` | Hide the column entirely on desktop. | | `aggregatable` | `false \| true \| { default?, operations }` | omitted | Whether a reader may aggregate this column, and with which operations. See [row grouping](./row-grouping.md). | | `formatAggregate` | `(value, context) => DisplayValue` | — | How a group aggregate of this column reads. Presentation only. | | `group` | `string` | — | Spanning header above adjacent columns sharing the name. See below. | | `i18n` | `Record` | — | Per-locale data paths for the column's value. | | `meta` | `Record` | — | Free-form bag your own code can read back. | | `locale` | `string` (table prop) | — | Active locale tag (`"ar"`, `"ar-EG"`); drives `i18n` path resolution. | ## The cell as text `accessor` returns a `ReactNode`, which is right for rendering and useless everywhere else: a status badge is a React element, an avatar cell is a component, and neither is a word. Anything that needs the cell as a **string** — a screen-reader announcement, an `aria-label`, a tooltip, a clipboard copy — has nothing to read. `formatValue` is that string: ```tsx { key: "status", accessor: (row) => {row.status}, formatValue: (row) => row.status, // what a screen reader says } ``` Text is always available, so `formatValue` only matters where it makes the text _accurate_. `columnText(column, row)` resolves it in this order: 1. `formatValue` — the column stating its own text 2. `exportValue` — the underlying value, minus the formatting 3. `sortValue` — a primitive by definition 4. `accessor`, when it happens to return a primitive 5. the key's data path with one deliberate restriction on that last step: **a column that renders its own cell never falls back to the data path.** A column with `accessor: () => null` renders an empty cell, and reading its path would announce a value the user cannot see — worse than announcing nothing. Such a column resolves to `""`, and giving it a `formatValue` is the fix. ## Computed columns A total, a margin, a full name, days-until-due — columns whose value is derived rather than stored. Writing the derivation into `accessor` works until the column has to do anything else: sorting then compares the formatted string, so `"$1,240.00"` sorts before `"$90.00"`; filtering has nothing to match; the export carries the formatting; and the function runs again for every cell on every render. `computed` declares the derivation once and wires all four surfaces from it: ```tsx import { computed } from "@adapttable/core"; const columns = [ { key: "quantity" }, { key: "unitPrice" }, computed({ key: "total", header: "Total", deps: (row) => [row.quantity, row.unitPrice], value: (row) => row.quantity * row.unitPrice, format: (total) => money.format(total), column: { sortable: true, align: "end" }, }), ]; ``` The screen shows `$1,240.00`; sorting, filtering and export all see `1240`. - **`deps` is required, and listing them is the whole contract.** The value is recomputed when any dependency changes and reused when none do. A field the derivation reads but does not declare becomes a stale cell the moment the data changes underneath it. - **The result is cached per row**, in a `WeakMap` keyed by the row object — a row that leaves the page takes its cached value with it, so a long-lived table cannot grow a cache it never releases. - **`format` is display only.** Leave it out and primitives and dates render as text; any other value renders empty, since an object has no useful reading in a cell. - **`column` carries everything else** a column can be — `sortable`, `align`, `width`, `filter`, `hideOnMobile`. `accessor`, `sortValue` and `exportValue` are derived and cannot be set here, which is what keeps the four surfaces from disagreeing. **Define the columns at module level, or memoise them.** The cache lives inside the column `computed` returns, so rebuilding the column on every render throws the cache away with it — values stay correct, nothing is reused. It is the same rule `Cell` already asks for. Rows must be objects, since the cache is keyed by row identity. The spec type is exported as `ComputedColumnSpec` for callers that build columns dynamically. ## Grouped headers Give adjacent columns the same `group` and they render under one spanning header cell. A string is one level; a path stacks one header row per depth. That shortcut is presentational — for collapse options and groups that stay together through reorder, use a `ColumnGroupDef` parent with `children`. See [column groups](./column-groups.md). ```tsx const columns: ColumnDef[] = [ { key: "firstName", header: "First", group: "Name" }, { key: "lastName", header: "Last", group: "Name" }, { key: "q1", header: "Q1", group: ["Finance", "2026"] }, { key: "q2", header: "Q2", group: ["Finance", "2026"] }, { key: "hiredAt", header: "Hired" }, ]; ``` ```text | Name | Finance | | | | 2026 | | | First | Last | Q1 | Q2 | Hired | ``` Columns without a `group` sit under a blank spanning cell, so the header rows always line up. The grouping is **presentational and adjacency-based**: the span is computed from the columns as they are currently ordered, so dragging a column out of the middle of a group splits it into two spans rather than pretending the layout is something it is not. Reorder them back together and the group closes up again. Pass `collapsibleColumnGroups` and each real group header gains a toggle. What a collapsed group shows is that group's own options — an arrow stub by default, a kept child via `collapsedKey`, or a cell via `collapsedRender`. See [column groups](./column-groups.md). Collapse state lives on `columnLayout.collapsedGroups` and the URL (`colGroupCollapse`); group ids are `path.join("\u001f")` so a label may contain `/`. On mobile the card layout has no header row, but the same visible-column filter applies — cards hide the same leaves a collapsed group hid on desktop. ## Notes - Define `Cell` components at module level (or memoise them) — an inline component re-mounts every render and defeats row memoisation. - Path-derived cells render primitives only; a non-primitive value at the path renders nothing. Use `accessor` or `Cell` for objects. - A column whose `accessor` returns JSX needs `sortValue` to be sortable — without it the sort silently no-ops and a dev warning fires. - `mobileLabel` only falls back to `header` when the header is a string; with a JSX header, set `mobileLabel` explicitly (it also names the column in the Columns menu). - Duplicate column keys trigger a development warning — keys must be unique within the table. See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/). --- # React table column groups — spanning headers, collapsible ▶ **See it working:** [collapse column groups in Mantine](https://orwa-mahmoud.github.io/adapttable/demo/mantine/column-groups/) — one table, three groups, open by default: Contact folds to a chevron, Assignment keeps Team, Delivery shows a money-for-days brief (`align: "start"`). Actions stays ungrouped at the end. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind. A parent with `children` is a **column group**: one spanning header over its leaves. Collapse is per group — each parent decides what remains. Omit `collapsibleColumnGroups` and the headers stay static. This is not [row grouping](./row-grouping.md). Rows fold under a `groupBy`; columns span under a parent header. **Related:** [Columns](./columns.md) · [Column management](./column-management.md) ## Tree columns `columns` accepts a mix of leaf `ColumnDef`s and `ColumnGroupDef` parents (`ColumnInput`). Tree groups default `marryChildren: true`, so a reorder cannot split them. ```tsx import { DataTable, type ColumnInput } from "@adapttable/mantine"; const columns: ColumnInput[] = [ { header: "Contact", children: [ { key: "name", header: "Name" }, { key: "role", header: "Role" }, ], }, { header: "Assignment", collapsedKey: "team", children: [ { key: "team", header: "Team" }, { key: "status", header: "Status" }, ], }, { header: "Delivery", align: "start", collapsedRender: (row) => `${row.budget} for 35 days`, children: [ { key: "timeline", header: "Timeline" }, { key: "budget", header: "Budget" }, ], }, ]; row.id} collapsibleColumnGroups />; ``` HTML-table kits (Mantine, MUI, Chakra, Radix, Base UI, unstyled) render the same tree with `rowSpan` on ungrouped leaves, so Actions sits beside Delivery and its children instead of under a blank group row. Ant uses native grouped columns; `htmlGroupedHeaderPlan` is that tree flattened for ``. HTML kits draw the line under an open group with `groupedHeaderChildRule` so adjacent groups do not share one stroke. ## Collapse Pass `collapsibleColumnGroups` and each real group header gains a toggle. What a collapsed group shows is that group's own options — there is no table-wide mode. | Result | How | | --------------------------------------------- | ----------------------------------------------------------------------- | | Thin **arrow stub** (chevron + hairline cell) | No `collapsedKey`, no `collapsedRender`, no child `groupShow: "closed"` | | Keep one child | `collapsedKey: "timeline"` (or that leaf `groupShow: "closed"`) | | Custom one cell | `collapsedRender: (row) => …` (wins over `collapsedKey`) | | Always visible child | `groupShow: "always"` | | Open-only child | `groupShow: "open"` (default under a collapsible group) | A collapsed `collapsedRender` or stub group drops the child header row — the title fills the header height, the same way an ungrouped leaf does. `collapsedKey` keeps both rows: the group title and the stayed child's header. Each group's rule under the title covers only that group's children; adjacent groups do not share one stroke. The stub hides the visible caption (`hideLabel`). The toggle's `aria-label` still names the group (`Expand column group: Delivery`). The stub column is locked to a chevron width so leftover table space cannot stretch it into a blank data column. `collapsedRender` wins over `collapsedKey`. State lives on `columnLayout.collapsedGroups` and the URL (`colGroupCollapse`); group ids are `path.join("\u001f")` (`columnGroupId`) so a label may contain `/`. ## The flat `group` shortcut Adjacent leaves with the same `group` still span under one header. A string is one level; a path stacks rows. That shortcut is **presentational**: a reorder that breaks adjacency splits the span, and collapse (when armed) is an arrow stub unless a child sets `groupShow`. Prefer a tree parent when the group has collapse options or must stay married. ```tsx const columns: ColumnDef[] = [ { key: "firstName", header: "First", group: "Name" }, { key: "lastName", header: "Last", group: "Name" }, { key: "hiredAt", header: "Hired" }, ]; ``` ## Mobile The card layout has no header row. The same visible-column filter applies — cards hide the leaves a collapsed group hid on desktop. The arrow stub itself is `hideOnMobile`, so a collapsed Delivery does not become an empty card field. ## Helpers `flattenColumnTree` turns a mixed `ColumnInput[]` into leaves plus a `ColumnGroupRecord` map. `applyCollapsedColumnGroups` hides leaves under collapsed ids and inserts a stub (`COLUMN_GROUP_STUB_PREFIX`, `isColumnGroupStubKey`) or a `collapsedRender` column (`COLUMN_GROUP_RENDER_PREFIX`, `isColumnGroupRenderKey`). `marriedOrderHolds` rejects a reorder that would split a married tree group. `columnGroupHeaderCaption` returns the visible caption, or `null` on a stub. `htmlGroupedHeaderPlan` is the HTML-table rowspan plan (ungrouped leaves beside the group band; a collapsed brief fills the header). `groupedHeaderChildRule` is the inset hairline under an open group title. `groupedHeaderCellStyle` applies that hairline and `columnGroupStubStyle` (the 36px lock so a stub cannot stretch). `groupedHeaderLabelStyle` keeps the collapse chevron on the same line as the group title so a one-child group cannot wrap the arrow above the caption. `isColumnGroup` narrows a `ColumnInput` to a parent. See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/column-groups/). --- # React table sparkline columns ▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — import `@adapttable/react/sparkline`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz) ▶ **See it working:** [turn on the sparkline column in the Feature Lab](https://orwa-mahmoud.github.io/adapttable/demo/all-options/) — switch kits with it on and the chart is drawn by each one. A sparkline is a mini chart in a cell: bar, line or area, drawn as inline SVG. It ships as `@adapttable/react/sparkline` so a table that never imports it never pays for it. No chart library. ```tsx import { sparklineColumn } from "@adapttable/react/sparkline"; import { DataTable } from "@adapttable/mantine"; const columns = [ sparklineColumn({ key: "load", header: "Load", values: (row) => row.history, kind: "area", }), ]; row.id} />; ``` `Sparkline` is the chart on its own, for a host `Cell` or `accessor`. `sparklineColumn` wires the usual surfaces: the cell draws the SVG, `sortValue` is the last finite number, and `exportValue` is the series as `"1, 2, 3"` so CSV and xlsx get the numbers, not markup. The SVG is a fixed size (80×28 by default). No `ResizeObserver`, so a virtualized row can mount and unmount it without measuring. Mobile cards render the same cell. Time stays left-to-right even under RTL — mirroring a series would put "last" on the left. Pass a `label` for a translated summary; the default is a numeric sentence (`3 values, min 1, max 4, last 2`). Omit the import and nothing is drawn and nothing is downloaded. --- # React table sorting — multi-column, server-side & URL-synced ▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — this page's feature is already wired in `src/App.tsx` (`sortable` columns); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz) Mark a column `sortable` and header clicks cycle it ascending → descending → cleared, with the state kept in the URL so reloads and shared links restore the exact order. Multi-column sorting is one extra prop. ## Example ```tsx import { type ColumnDef, DataTable, useFrontendData, } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled interface Person { id: string; name: string; team: string; hiredAt: string; rating: number | null; } const people: Person[] = [ { id: "1", name: "Amira Hassan", team: "Platform", hiredAt: "2021-03-15", rating: 4.6, }, { id: "2", name: "Tom Becker", team: "Design", hiredAt: "2022-11-01", rating: null, }, { id: "3", name: "Lina Park", team: "Platform", hiredAt: "2019-07-20", rating: 4.9, }, { id: "4", name: "Sam Ortiz", team: "Design", hiredAt: "2023-02-08", rating: 4.1, }, ]; const columns: ColumnDef[] = [ { key: "name", sortable: true }, { key: "team", sortable: true }, // Formatted cell + sortValue: sort by the real date, not the display string. { key: "hiredAt", accessor: (r) => new Date(r.hiredAt).toLocaleDateString(), sortValue: (r) => r.hiredAt, sortable: true, }, // null ratings always sort last, ascending or descending. { key: "rating", sortable: true }, ]; export function People() { // `defaults` sets the initial sort; the URL overrides it once the user sorts. const source = useFrontendData({ data: people, columns, defaults: { sortBy: "name", sortDir: "asc" }, }); return ( r.id} multiSort // shift-click chains a second column /> ); } ``` ## How it works - A header click cycles the column inactive → ascending → descending → cleared. A click on a different column starts it ascending. - Frontend tier (`data` / `useFrontendData`): rows are compared by `sortValue`, falling back to the column's accessor. Numbers compare numerically, everything else by locale-aware string comparison; the sort is stable. - `null` / `undefined` / `NaN` always sort last — in both directions. A descending sort never flips the blanks to the top. - Sort state lives in the URL: `sortBy` + `sortDir` for a single sort, and the chain as `sort=name:asc,hiredAt:desc` under `multiSort`. `defaults` apply only while the URL is silent; clearing a defaulted sort writes an empty `sortBy=` marker so it does not resurrect. - `multiSort` adds shift-click (or shift-Enter): each shift-click adds the column to the chain or advances it (asc → desc → removed). Chained headers expose a 1-based `data-sort-index` for the order badge. A plain click resets the chain back to a single sort. - Server tier (`onQueryChange` / `useQuerySource`): the table only emits the state — `query.sortBy`, `query.sortDir`, and `query.sortLevels` for a chain. Your backend does the comparing; `sortValue` is unused. ## Options | Prop | Type | Default | Description | | --------------- | ------------------------------------------ | ---------------------------- | ---------------------------------------------------------------------------------- | | `sortable` | `boolean` (per `ColumnDef`) | `false` | Enable sorting for the column. | | `sortValue` | `(row) => SortableValue` (per `ColumnDef`) | the generated accessor value | Primitive extractor for the client-side comparator. Unused for server-sorted data. | | `multiSort` | `boolean` | `false` | Opt into multi-column sorting via shift-click / shift-Enter. | | `defaults` | `Partial & { extra? }` | — | Initial sort (`sortBy`, `sortDir`) on the source builders; URL values win. | | `sortByOptions` | `SortByOption[]` | — | Options for the mobile sort-by select. | ## Notes - `defaults` works both as a `` prop and as an option on the source builders — `defaults={{ sortBy: "name" }}` default-sorts the zero-ceremony `data` tier directly (ascending unless `sortDir` is given; explicit URL state wins). - A column whose accessor returns JSX needs `sortValue`; otherwise the sort cannot resolve a value and a development warning fires (`sortBy` matching no column warns too). - The plain-click reset of a multi-sort chain is deliberate: without it the chain would keep superseding the single sort and the click would appear dead. - A hand-edited URL sort with no `sortDir` falls back to ascending. - In multi-sort, ties at level N fall through to level N+1; rows that tie on every level keep their original order. See it live in the [demo](https://orwa-mahmoud.github.io/adapttable/demo/). --- # React table filtering — multi-condition, chips, operators & URL-synced ▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — this page's feature is already wired in `src/App.tsx` (declarative `filter` widgets on four columns); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz) Declare a filter once and AdaptTable derives everything from it: the kit-native widget, the `f_` URL param, the removable chip, and (on frontend data) the row predicate — no wiring. Nested AND/OR groups are the [advanced filter tree](./filter-tree.md). ## Example ```tsx // Needs your kit's provider once at the root (e.g. ). import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled import { filters } from "@adapttable/mantine/filters"; interface Person { id: string; name: string; department: { name: string }; status: string; salary: number; hiredAt: string; // ISO date } const data: Person[] = [ { id: "1", name: "Amira Haddad", department: { name: "Engineering" }, status: "active", salary: 98000, hiredAt: "2021-03-15", }, { id: "2", name: "Jonas Weber", department: { name: "Design" }, status: "onleave", salary: 76000, hiredAt: "2019-11-02", }, { id: "3", name: "Priya Nair", department: { name: "Engineering" }, status: "active", salary: 112000, hiredAt: "2023-06-20", }, { id: "4", name: "Sam Ortiz", department: { name: "Sales" }, status: "left", salary: 64000, hiredAt: "2018-01-09", }, { id: "5", name: "Lena Park", department: { name: "Design" }, status: "active", salary: 89000, hiredAt: "2022-09-12", }, ]; export function PeopleTable() { return ( r.id} columns={[ { key: "name", filter: "text", sortable: true }, // "auto" derives the choices from the data (frontend tier). { key: "department.name", header: "Department", filter: { type: "select", options: "auto" }, }, // Async options — usually `async () => (await fetch("/api/statuses")).json()`. { key: "status", filter: { type: "multiSelect", options: async () => [ { value: "active", label: "Active" }, { value: "onleave", label: "On leave" }, { value: "left", label: "Left" }, ], }, }, { key: "salary", filter: "numberRange", sortable: true }, { key: "hiredAt", filter: "dateRange" }, ]} features={[ filters([ { key: "tenure", type: "numberRange", label: "Tenure (years)", getValue: (r) => (Date.now() - new Date(r.hiredAt).getTime()) / 31_557_600_000, }, ]), ]} filtersMode="popover" // the default; "drawer" or "header" — one mode, never stacked /> ); } ``` Import `filters` from `@adapttable//filters` — the import is the switch. Column `filter` declarations still need that feature: pass `filters([])` when every filter lives on a column, or pass standalone defs as above. See [feature composition](./features.md). ## How it works - Two declaration sites, merged column-first: the column `filter` shorthand (a bare type like `"dateRange"`, or a definition without `key`/`label` — both inherited from the column) and standalone entries passed to `filters([…])` for filters with no column. On a key collision the standalone definition wins and a development warning points at the duplicate. - Seven built-in types (`FILTER_TYPES`): `text`, `select` (equals), `multiSelect` (wrapping multi-value chips), `checklist` (Excel-style distinct values with search, select-all and counts — from `source.facets` when present, otherwise `source.allFilteredRows`; a server page that omits both does not offer the widget), `boolean` (any / true / false — never a checkbox), `dateRange`, `numberRange`. - Widgets are operator-first. Text offers equals / not equals / contains / not contains / starts with / ends with / empty / not empty. Numbers offer `=` `≠` `>` `≥` `<` `≤` between / in / not in. Dates offer before / after / on / on-or-after / on-or-before / between / empty. The operator token is stored as `f_Op` (readable, stable across releases) beside the value keys (`f_name`, `f_salaryMin`/`f_salaryMax`, `f_hiredAtFrom`/`f_hiredAtTo`). Links written before `Op` existed still work: text defaults to contains, and a Min/Max pair still infers at-least / at-most / between. - `select`/`multiSelect` options come from a static `{ value, label }[]`, `"auto"` (distinct values derived from the frontend dataset, sorted, capped at `AUTO_OPTIONS_LIMIT` = 50, the same number as `FILTER_AI_OPTIONS_LIMIT`), or an async loader — one shared fetch serves both the form and the chip labels, and active chips re-label from raw values once it resolves. - A definition's `key` doubles as the row's dot path for the client-side predicate (`"department.name"` reaches nested values); `getValue` overrides it for computed values. - Active filters render as removable chips with a clear-all that resets every filter (and the page) while search and sort survive; `onClearFilters` replaces the built-in handler. ## Options `FilterDef` (entries of `filters`, and the column `filter` object minus `key`/`label`): | Prop | Type | Default | Description | | ------------- | ----------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | `string` | — | State key and `f_` URL param. Doubles as the row's dot path unless `getValue` overrides it. | | `type` | `string` | — | Built-in `FilterType` or a custom type registered on `filterTypes`. | | `label` | `string` | humanized `key` | Widget and chip label (`hiredAt` → "Hired At"). | | `options` | `FilterOption[] \| "auto" \| () => Promise` | — | Choices for `select` / `multiSelect`. | | `getValue` | `(row) => unknown` | reads `key` as a path | Row-value extractor for the client-side predicate. | | `placeholder` | `string` | — | Placeholder for text-like inputs. | | `ai` | `false \| FilterAiOptions` | visible, options ≤ 50 | Assistant catalog. `false` hides the filter. `{ options: false }` keeps it and omits values. A number sends values only when the static list is that long or shorter (`FILTER_AI_OPTIONS_LIMIT`). `"auto"` and async loaders are never fetched into the prompt. | | Factory / prop | Type | Default | Description | | --------------------------- | ----------------------------------- | -------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `filters([…])` | `FilterDef[] \| ReactNode` | — | Declarative array → the adapter builds the form; JSX → you draw it (escape hatch). Use `filters([])` when every filter is on a column. | | `filtersMode` | `"popover" \| "drawer" \| "header"` | `"popover"` | One container. Popover: anchored card, no backdrop. Drawer: panel + backdrop. Header: compact per-column row; hides the Filters button. | | `onClearFilters` | `() => void` | built-in clear | Clear handler used by the drawer and the chip strip. | | `filterLabels` | `Record` | derived | Per-key chip label resolvers. Derived automatically by declarative filters; needed only for JSX filters (or to override a derived label). | | `extraChips` | `ActiveFilterChip[]` | — | Extra chips driven by non-URL state, merged with the derived chips. | | `activeFilterCount` | `number` | chip count | Overrides the Filters-button badge. | | `closeHeaderFilterOnSelect` | `boolean` | `false` | Close a header-filter overlay after a finished single-control write (select/boolean, or a valueless operator). Off by default. | | `filterTypes([…])` | `FilterTypeSpec[]` | built-ins | Extra or replacement filter types merged onto `defaultFilterRegistry`. Same `type` replaces. Compose via `features`. | | `headerFilters()` | factory | — | Compose when `filtersMode="header"` (or pass `headerFilters` on a test harness). Desktop only. Never stacked with the popover or drawer. | ## Headless filter primitives The pieces behind the auto-built forms are exported for custom filter UIs: - **Count filters** (the numeric operator + value pair, committed stable in [Versioning & stability](./versioning.md)): `COUNT_OPERATORS` is the operator list and `COUNT_OPERATOR_SYMBOL` the display symbol per `CountOperator`; `CountFilterState` is the widget state; `countFilterExtra` / `countFilterStateFromExtra` convert state to and from the filter bag; `isCountFilterComplete`, `clearCountFilterExtra`, `sanitizeCountFilterParams` and `countFilterChipLabel` handle validation, reset, outgoing params, and the chip text. - **Operators**: `TEXT_OPS` / `NUMBER_OPS` / `DATE_OPS` are the stable URL tokens (`FilterOp`, `TextOp`, `NumberOp`, `DateOp`). `filterOpKey` / `FILTER_OP_SUFFIX` name the `f_Op` slot; `parseTextOp` / `parseNumberOp` / `parseDateOp` / `readFilterOp` read it; `isValuelessFilterOp` / `isListFilterOp` / `isBetweenFilterOp` classify operand shape. `TEXT_OP_LABEL_KEYS` / `NUMBER_OP_LABEL_KEYS` / `DATE_OP_LABEL_KEYS` map each token to a `TableLabels` key. `formatFilterChip` / `filterOpLabel` / `isEmptyRowValue` / `parseListOperand` / `parseNumberList` / `isFilterOpKey` are the helpers. `useTextFilterWidget` returns a `TextFieldWidget`. Relative windows: `RELATIVE_NAMED` / `RELATIVE_PRESETS` / `RELATIVE_PRESET_LABEL_KEYS` / `RelativeDateToken` / `RelativeDateRange` / `RelativePreset` / `parseRelativeToken` / `isRelativeDateToken` / `countedRelativeToken` / `splitRelativeToken` / `joinRelativeToken` / `relativeTokenLabel` / `resolveRelativeRange`. AND/OR groups: `FILTER_TREE_PARAM` / `FILTER_TREE_VERSION` / `parseFilterTree` / `serializeFilterTree` / `isActiveFilterTree` / `evaluateFilterTree` / `conditionToExtra` over a `QueryFilterGroup` of `QueryCondition`s (`isFilterGroup` narrows a child). Each adapter's `FilterTreeBuilder` sits at the top of the Filters form when `source.setFilterTree` is set; `toolbarShowsFilters` keeps the toolbar button in header mode for that tree. The engine stores `ft=1.{…}` and evaluates the tree on the frontend tier (ANDed with the flat extra bag). `useTableData` wires `evaluateFilterTree` itself; a host that calls `useFrontendData` directly passes `filterTreeFn` over the same defs as `filterFn`. A server that declares `supports.filterTree` receives the same tree on `query.filterTree`. Tree leaves become chips via `useFilterTreeChips`; Clear all drops `ft`. See [filter-tree](./filter-tree.md). - **Facet counts**: `computeFilterFacets` / `rowsExcludingFilter` / `FacetMap` / `FacetCounts` count what selecting a value _would_ keep — the filtered set with that facet's own filter removed. Frontend `useTableData` computes them from `allSearchedRows` (after search, before extras). A server that declares `supports.facets` receives `query.facets` (checklist keys) and returns the same map on the page; `useQuerySource` / `useServerData` surface it as `source.facets`. `useChecklistFilter` prefers that map over `allFilteredRows`. - **Type registry**: `FilterTypeSpec` is one type — widget kind, operators, predicate, chips, tree projection, optional `render`. Built-ins (`builtInFilterSpecs` / `defaultFilterRegistry`) are the first consumers; `filterTypes` on the table merges extras via `resolveFilterRegistry` / `createFilterRegistry`. A custom type registers through `TableFeatureHost.registerFilterType` / `extendFilterType` in `feature.setup(host)`, or `features={[filterTypes(specs)]}`. `filterWidgetKind` / `filterTypeOps` / `filterTypeDefaultOp` / `filterTypeSpec` / `renderRegisteredFilter` look a spec up. A custom type with `widget: "text"` draws the text widget; `extend("text", { ops })` adds operators without forking. `emptyFilterRegistry` seeds a registry from scratch. `FilterTypeRegistry` / `FilterWidgetKind` / `FilterWidgetRenderProps` are the types. - **Header filter row**: compose `headerFilters()` and set `filtersMode="header"` (see [feature composition](./features.md)) mounts each adapter's `FilterHeaderRow` / `FilterHeaderControl` over `FilterHeaderChrome` / `FilterHeaderControlChrome`. Helpers `filterDefForColumn` / `headerFilterStickTop` stay on core. The row sits under the leaf header and hides the toolbar Filters button (`resolveFilterMode` / `FilterChromeMode`). Pads and column spacers match the header so sticky, pin offsets, and column windowing stay aligned. A def whose bag key differs from the column key sets `column` (`key: "name"` under `column: "person"`). Ant Design keeps the control inside the header cell so `fixed` columns stay on antd's own header. Compact range inputs default the operator to `gte` (no picker in the header); checklist / multiSelect open a closed menu of checkboxes, not a native `` (options via `sortByOptions`) | | Pagination | Pager (or infinite) | `paginationMode="auto"` resolves to infinite scroll | | Row actions | Trailing icon buttons | Card buttons | | Long lists | Row virtualization (`virtualize`) | Card virtualization through the same prop | | Filters, chips, search, selection, bulk bar, saved views, URL state | identical | identical | The second half of that table is the point: behavior that took real work to get right — declarative filters, select-across-pages, shareable URL state — does not fork into a second code path on phones. One table, one state, two layouts. ## Tuning the cards - **`mobileLabel`** (per column) — the label a card shows for that field; falls back to the column's string `header`, then to its key. Set it to `""` for a field with no label at all — a bare avatar or title line — rather than an empty caption taking a line. `resolveMobileLabel` from `@adapttable/react/adapter` is the resolver every adapter uses, for a custom card layout that should match. - **`hideOnMobile`** (per column) — drop a column from cards entirely. - **`mobileIdentityColumns`** (default `3`) — how many leading desktop-visible columns the cards always keep. - **`sortByOptions`** — the options offered by the mobile sort-by select. - **`forceMobile`** — pin either layout regardless of viewport: cards inside a desktop dashboard panel, or the full table in a tablet kiosk. The [mobile demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/mobile-cards/) uses exactly this prop for its toggle. - **`rowClassName`** applies to desktop rows and mobile cards alike, and the [class-hook / `data-adapttable-part` surface](./customization.md) names the card regions (`cardDetail`, `group-card`, `summaryCard`) for styling. - **`rowStyle` / `rowHeight`** apply the same way — see [row styling and heights](./row-styling.md). ## Your own card The built-in card is a stack of labelled fields, which is right for most tables and wrong for some: an order wants its total large and its reference small; a person wants their avatar beside their name rather than under a caption reading "Avatar". `renderCard` replaces that stack — and only that stack: ```tsx r.id} renderCard={(row, card) => (

{row.name}

{card.fields.map(({ column, label, value }) => (
{label &&
{label}
}
{value}
))}
)} /> ``` The card's shell stays around what you return: the list-item semantics, the selection checkbox, the expand and tree toggles, the reorder controls, the row actions and the detail panel. A custom card cannot drop the parts that make the list usable, because it never owns them. Flat cards expose 44px up/down controls. Grouped and tree cards also expose the kit-native **Move to group…** / **Move under…** menu, including explicit confirmation when `movePolicy: "confirm"`; touch never has to emulate a drag. `card.fields` is what the built-in would have laid out — each field's `column`, its resolved `label` (`undefined` when the column asked for none) and its `value`, rendered exactly as the built-in renders it, cell renderers and editors included. So this is a layout decision, not a re-implementation: reuse the values and arrange them your way. `card.selected`, `card.expanded` and `card.index` come along for cards that change with their state. Omit it and the built-in card renders, byte for byte. ## Where the switch happens `mobileBreakpoint` is the width, in pixels, at or below which the cards take over. It defaults to 768 — a phone in portrait. ```tsx r.id} mobileBreakpoint={1024} /> ``` Raise it when the table lives in a sidebar or a split pane: the viewport says "desktop" while the table itself has a phone's width to work with, and the default would keep a five-column table in a 300px column. Lower it when the table is the whole page and its columns are narrow enough to survive. ## The width in between Between "everything fits" and "narrow enough for cards" there is a long middle where a table has too many columns. The usual outcomes are a horizontal scrollbar nobody finds, or columns squeezed until nothing is legible — neither is a decision. `responsivePriority` is the decision, made by the person who knows the data: ```tsx const columns = [ { key: "name", header: "Name", width: 200 }, { key: "team", header: "Team", width: 160, responsivePriority: 1 }, { key: "note", header: "Note", width: 240, responsivePriority: 2 }, ]; ``` Priority 1 is kept longest, in the ordinary sense of the word. As the table narrows, `note` goes first, then `team`. `name` never goes — a column that omits `responsivePriority` is never dropped, which is how the columns carrying the row's identity stay put by saying nothing. A table where no column sets it behaves exactly as it did before. The budget is arithmetic on each column's declared `width` (a resize wins over it, and a column with no width is budgeted at 150px), so it settles in one pass and gives the same answer every time. There is no measure-drop-remeasure loop, which is what makes tables that do it flicker. A dropped column is a fact about the viewport, not a choice the user made: it never reaches the layout state, the URL or a saved view, and the column menu still lists it. ## It composes with everything else - **Grouping** renders group header blocks between cards, with the same collapse behavior as desktop — see [row grouping](./row-grouping.md). - **Virtualization** windows the card list the same way it windows rows: one `virtualize()` feature, measured in a real browser across every adapter — see [virtualization](./virtualization.md). - **RTL** flips the cards along with everything else — see [i18n & RTL](./i18n-rtl.md). - **Every adapter ships it**: Mantine, MUI, Chakra, Ant Design, Radix, Base UI, shadcn/ui and unstyled all render the card layout natively — the [comparison table](./comparison.md) tracks it as a built-in across the board. ## When you still want the table on phones Set `forceMobile={false}` and the desktop layout renders everywhere — sticky header, pinned columns and horizontal scrolling included. The cards are the default because thumb-reach beats pinch-zoom for row-by-row work, but the choice stays yours per table. Related: [Getting started](./getting-started.md) · [API reference](./api.md) · [Live mobile demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/mobile-cards/) --- # React table URL state — shareable filters, sort, and page ▶ **See it working:** [the live demo](https://orwa-mahmoud.github.io/adapttable/demo/) — filter, sort and page it, then copy the URL: every bit of that state is in the address bar (the kit switcher too). AdaptTable keeps the table's state in the URL query string: search (`q`), pagination (`page`, `limit`), sorting (`sortBy`/`sortDir`, or `sort` for a multi-sort chain), row grouping (`groupBy`) and session aggregation choices (`groupAgg`), and every filter value (`f_`). Column layout (`colHide`, `colPin`, `colOrder`, `colW`, `colName`) joins in when you wire `useColumnLayoutUrlState`, and saved views capture all of it under a name. Reloading, sharing the link, or pressing back lands on the exact same slice. Two conventions keep URLs clean: default values are omitted, and changing search, sort, or a filter resets the page to 1. ## Format stability and recovery Every table namespace AdaptTable has written carries an `atv=1` marker (`people.atv=1` when `urlKey="people"`). A link without the marker is also version 1, so links created before the marker existed keep their exact meaning. The marker remains when the other values are cleared, so an explicitly empty state is versioned too. It changes only when a future release needs a real migration; adding an optional parameter does not reinterpret existing ones. URL state is untrusted input and recovers by policy: - Unknown parameters are ignored by the table and preserved on writes. - Malformed values fall back or are dropped independently, so one bad field cannot erase valid state. - When a parameter is duplicated, the first value wins. - An unsupported or malformed `atv` ignores AdaptTable's recognized parameters for that table only. Other table namespaces and application parameters remain intact. - One table may carry at most 8,192 encoded characters of recognized state. Oversized incoming state is ignored for that table; a write that would cross the limit keeps the previous valid state instead. Every recovery path produces the normal default table rather than throwing or rendering a blank result. ## Multiple tables on one URL: `urlKey` Two tables on one page would clobber each other's params. Give each a namespace and every param is prefixed (`people.q`, `orders.f_totalMin`, …): ```tsx import { DataTable } from "@adapttable/mantine"; type Person = { id: string; name: string; status: string }; type Order = { id: string; ref: string; total: number }; export function Dashboard({ people, orders, }: { people: Person[]; orders: Order[]; }) { return ( <> r.id} urlKey="people" /> o.id} urlKey="orders" /> ); } // → ?people.q=avery&people.page=2&people.atv=1&orders.f_totalMin=100&orders.atv=1 ``` The same `urlKey` option exists on `useFrontendData`, `useQuerySource`, `useTableUrlState`, `useColumnLayoutUrlState`, and `useSavedViews` for headless consumers. Omitting distinct `urlKey`s on shared-URL tables logs a development warning. ## URL adapters The URL layer is decoupled from any router via a tiny `UrlStateAdapter`: ```ts interface UrlStateAdapter { getSearch(): string; // current query string (no "?") setSearch(search: string, opts?: { push?: boolean }): void; subscribe(onChange: () => void): () => void; } ``` - **`createHistoryAdapter()`** — browser History API; the default (one shared instance per window via `getHistoryAdapter()`). - **`createMemoryAdapter(initial?)`** — in-memory; used for SSR, tests, and when URL sync is disabled (the table still gets fully working local state). Pass a custom adapter as `urlAdapter` on any `` (the headless hooks call the option `adapter`). ## Your router Every router recipe is the same shape: read the current query string, and navigate to a new one. `routerUrlAdapter` is that shape — its `RouterUrlAdapterOptions` are the router's current `search` and a `navigate` — so each router takes two lines instead of twelve — and it depends on no router, which is why it can ship at all. Memoize it on the search: the adapter is a value, and rebuilding it is how the table learns the route changed. ### react-router ```tsx import { useMemo } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { routerUrlAdapter } from "@adapttable/core"; export function useReactRouterAdapter() { const [params] = useSearchParams(); const navigate = useNavigate(); return useMemo( () => routerUrlAdapter({ search: params.toString(), navigate: (search, { push }) => navigate({ search }, { replace: !push }), }), [params, navigate] ); } ``` ### TanStack Router ```tsx import { useMemo } from "react"; import { useNavigate, useRouterState } from "@tanstack/react-router"; import { routerUrlAdapter } from "@adapttable/core"; export function useTanStackAdapter() { const search = useRouterState({ select: (s) => s.location.searchStr }); const navigate = useNavigate(); return useMemo( () => routerUrlAdapter({ search, navigate: (next, { push }) => navigate({ to: ".", search: next, replace: !push }), }), [search, navigate] ); } ``` ### Next.js (App Router) ```tsx "use client"; import { useMemo } from "react"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { routerUrlAdapter } from "@adapttable/core"; export function useNextAdapter() { const searchParams = useSearchParams(); const pathname = usePathname(); const router = useRouter(); return useMemo( () => routerUrlAdapter({ search: searchParams.toString(), navigate: (search, { push }) => { const url = search ? `${pathname}?${search}` : pathname; if (push) router.push(url, { scroll: false }); else router.replace(url, { scroll: false }); }, }), [searchParams, pathname, router] ); } ``` ```tsx r.id} urlAdapter={useNextAdapter()} /> ``` `push` is opt-in throughout: the default is a replace, because a table's every keystroke is not a page anyone wants to walk back through. The adapter reports no external changes on purpose. A router re-renders its tree on navigation, so the hook holding the adapter runs again and reads the new search itself — subscribing would deliver the same change twice. The one way to hold it wrong is to pass a `search` that does not update, which is why it takes a value rather than a getter. ## Turning URL sync off One prop: `urlSync={false}`. Search, sort, filters and pagination keep working identically — state just lives in memory, the address bar never changes, and any `urlAdapter` is ignored. ```tsx r.id} urlSync={false} /> ``` Headless equivalent: `useTableUrlState({ urlSync: false })` — handy inside modals or drawers where the address bar shouldn't change. ## Param reference | Param | Example | Meaning | | --------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `atv` | `atv=1` | AdaptTable URL-state format. Missing also means version 1; unsupported versions recover to defaults for this table only. | | `q` | `q=avery` | Committed search term. | | `find` | `find=Ada` | Find-bar query (in-table walk). Empty or closed deletes the param; the current-match index is never stored. | | `page` | `page=3` | 1-based page; omitted at 1. | | `limit` | `limit=50` | Page size, clamped to 1–500; omitted at the default (25). | | `sortBy` + `sortDir` | `sortBy=name&sortDir=desc` | Single-column sort (`sortDir` falls back to `asc`). | | `sort` | `sort=name:asc,age:desc` | Multi-sort chain; supersedes `sortBy`/`sortDir` while present. | | `groupBy` | `groupBy=team,status` | Ordered row-grouping column keys, outermost first. Written by `groupingPanel()` and omitted when no grouping is active. | | `groupAgg` | `groupAgg=budget:sum,age:none` | Per-column session aggregation overrides: a built-in name, a host operation id, or `none` (explicit suppression). A missing column preserves the developer's `groupAggregates` / `aggregatable.default` / original `query.aggregates`. | | `f_` | `f_status=active` | One filter value; `multiSelect` arrays are comma-separated with each entry percent-encoded. | | `f_From` / `f_To` | `f_hiredAtFrom=2026-01-01` | `dateRange` bounds (inclusive; the end bound keeps that whole day). A `relative` operator stores the token here (`today`, `last:7`) instead of a resolved day. | | `f_Min` / `f_Max` | `f_salaryMin=50000` | `numberRange` bounds (inclusive; parsed as numbers). | | `ft` | `ft=1.{"combinator":"and"}` | Versioned AND/OR filter tree. Unknown versions are dropped, never reinterpreted. | | `colHide` | `colHide=email,phone` | Hidden columns (keys percent-encoded). | | `colPin` | `colPin=name:left` | Pinned columns and their side. | | `colOrder` | `colOrder=name,role,salary` | Explicit column order. | | `colW` | `colW=name:220` | Per-column pixel widths. | | `colName` | `colName=name:Account%20owner` | User display names by stable column key. | With a `urlKey` every param is prefixed: `people.q`, `people.f_status`, `people.groupBy`, `people.groupAgg`, `people.colHide`, …. `groupAgg` is intentionally an override map, not a replacement aggregate configuration. Adding or changing an operation writes that column's id; removing a developer-declared aggregate writes `none` so the default does not come back; removing a reader-added aggregate deletes the entry. **Restore defaults** clears the whole map. Keys are percent-encoded, host-defined ids survive the round trip, and equivalent maps serialize in stable column-key order. [Row grouping](./row-grouping.md) documents the panel, column-menu, mobile, and keyboard routes that write this state. ### Defaults vs. explicit clears `defaults` (search, sort, `extra` filter values) apply only while the URL is silent about a key. When the user explicitly clears a defaulted value, the hook records it as an empty-valued param (`q=`, `f_status=`) so the default does not instantly resurrect — a missing param means "default applies", an empty one means "explicitly cleared". ## Reading and writing the params yourself The codecs the table uses are published, so a route handler, a saved-view store, or a test can read and write the same URL without mounting a table. `parseTableUrlState(search)` reads a whole query string into table state; `updateTableUrlState(search, patch)` returns the next query string; `applyTableUrlState` and `captureTableUrlState` move that state on and off a live table. Each param has a named constant and, where the value is not a plain string, a reader and a writer: | Constant | Reader / writer | | ----------------------------------------------- | ---------------------------------------------------------------------- | | `PARAM_PAGE`, `PARAM_LIMIT` | `readPage`, `readLimit` | | `PARAM_SEARCH`, `PARAM_FIND` | plain strings | | `PARAM_SORT_BY`, `PARAM_SORT_DIR` | `readSortDir`; the chain is `readSortLevels` / `writeSortLevels` | | `PARAM_GROUP_BY`, `PARAM_GROUP_AGGREGATES` | ordered keys and the per-column overrides | | `PARAM_COL_HIDDEN` | `readColumnLayout` / `writeColumnLayout` cover the whole column layout | | `PARAM_DENSITY`, `PARAM_FORMULA`, `PARAM_PIVOT` | the density, formula-column and pivot codecs | | `f_` | `readExtra` / `writeExtra` | | `ft` | `readFilterTreeParam` / `writeFilterTreeParam` | | row pins | `readRowPins` / `writeRowPins` | Writing a value that equals the default deletes the param instead of spelling it out, which is what keeps a shared link short. ## SSR The default History-API adapter hydrates from an empty query string (the server has no `window`; the real URL applies right after hydration). To server-render the exact requested slice, pass an explicit router adapter — it knows the request URL, and the hooks trust an explicit adapter to be hydration-consistent. `getHistoryAdapter()` itself returns a memory adapter when there is no `window`, so nothing crashes under SSR either way. --- # React table exports — browser files and server-built jobs AdaptTable uses one `exportCsv()` feature for CSV, XLSX, PDF, and custom writers. Small and medium exports can be built in the browser. Large server-backed exports can stay beside the data while the table shows progress, offers cancellation, and returns the finished download to the reader. ## Browser-built export For frontend data, `scope: "all"` reads the source's complete filtered and sorted set and builds the file directly: ```tsx import { exportCsv } from "@adapttable/mantine/export"; ; ``` A server source normally holds one page. `fetchAll` is the explicit browser-built alternative: it pages the current query, then runs the normal writer. The walk defaults to `EXPORT_FETCH_ALL_MAX_ROWS` (50,000). Reaching that cap calls `onCapped` and never pretends a partial file is complete. ```tsx features={[ exportCsv({ scope: "all", fetchAll: { fetchPage: (query) => api.people.list(query), onCapped: ({ rows }) => notify(`${rows} rows is the browser limit`), }, }), ]} ``` Use this route only when downloading every page and holding the resulting file in the tab is appropriate. The cap, paging behavior, and `onBeforeExport`/`onAfterExport` hooks are unchanged. ## Server-built export with progress `onExportAll(query, controls)` is the path beyond the browser cap. The table does not fetch rows. It sends the page-free view only: - committed search, flat filters, and the nested filter tree; - the primary sort and complete multi-sort chain; - grouping keys, outermost first; - visible column keys and requested export column keys, both in display order; - filename and writer format. ```tsx import { exportCsv } from "@adapttable/mantine/export"; { const job = await api.exports.start(query, { signal: controls.signal, }); for await (const update of api.exports.watch(job.id, { signal: controls.signal, })) { controls.setProgress?.(update.percent); controls.setMessage?.(update.message); if (update.url) return { url: update.url }; } }, }), ]} {...props} />; ``` Each run gets a fresh `AbortSignal`. The surface's Cancel button aborts it; the host should pass that signal through every request and stop its polling or stream. `setProgress` is clamped to 0–100. If it is never called, the kit shows an indeterminate indicator. `setMessage` supplies short job-specific detail below the localized heading. The host settles in one of three ways: - resolve `{ url }`: the completed surface offers a **Download export** link; - resolve nothing: the host chose another delivery method, and the surface still shows **Export complete**; - reject: the error surface shows the reason and a localized **Retry** button. Every terminal state — complete with or without a URL, failed, or cancelled — also offers a localized **Dismiss** control. Dismiss returns the surface to idle, clears the stale message, error, and download URL, and restores focus to the Export button. It does not abort or restart a job. Busy work keeps **Cancel** only; Dismiss never replaces it. Cancellation is not a failure. It shows and announces **Export cancelled**, ignores a late rejection from the aborted run, and never offers Retry for that run. Retry starts a new job with a new signal and the table's current view. `onExportAll` takes precedence over the generic `request` callback for `scope: "all"`. `request` remains available for hosts that take over page, selected, or range exports without the progress protocol. Neither host-owned route runs `onBeforeExport` or `onAfterExport`, because the table never builds their file. ## Accessibility and adapter behavior Every published adapter renders the same lifecycle through its own components: a progress card and determinate bar or indeterminate spinner, plus native-kit Cancel, Retry, Download, and Dismiss controls. The card is viewport-safe on mobile and uses logical positioning, so it remains at the inline end in LTR and RTL. Dismiss sits at the inline end of the heading so RTL placement stays correct. A polite live region announces start, each reported progress value, completion, failure, and cancellation. The Export button remains present with `aria-busy="true"` while a job runs, and the single-flight guard refuses a second start until that run settles or is cancelled. For column scopes, writers, lifecycle hooks, and headless helpers, see [Customization — Export](./customization.md#export). For PDF and print-specific options, see [PDF export and print layout](./export-pdf.md). --- # React table PDF export and print layout ▶ **See it working:** [download a grouped PDF and print the same view](https://orwa-mahmoud.github.io/adapttable/demo/mantine/export/) — a real table, not a recording. ▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — import `@adapttable/core/pdf`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz) A downloaded PDF and a printed page are the same view — the columns, group structure and page breaks the reader can actually see — shipped as `@adapttable/core/pdf` so a table that never imports it never pays for it. No PDF library. ```tsx import { pdfWriter, printTable } from "@adapttable/core/pdf"; import { DataTable } from "@adapttable/mantine"; import { exportCsv } from "@adapttable/mantine/export"; row.id} features={[exportCsv({ writer: pdfWriter(), scope: "all" })]} />; ``` `pdfWriter` is the production default for the export button: the same `exportCsv()` seam as [CSV and XLSX](./customization.md#export), the same scopes (`page`, `all`, `selected`, `range`) and the same column subset. The button relabels itself **Export PDF** from `labels.exportFile("pdf")`. `buildTablePdf` is the same file, for a host assembling rows by hand. Print is a different verb. `downloadExportFile` cannot open a dialog, so `openPrintLayout` (an `ExportTable`) and `printTable` (rows and columns) load `buildPrintDocument` into a hidden iframe and call `window.print()`. `buildPrintTableHtml` is the `` alone; `printStyles` is the stylesheet — repeating `thead`, `break-inside: avoid` on rows and groups, column widths from the table, `padding-inline-start` so a tree or a group indents under RTL. `PrintLayoutOptions` / `PdfWriterOptions` / `PrintPageSize` / `PrintPageBreak` configure title, direction, paper and whether a top-level group starts a new page (`pageBreak: "group"`). Paper defaults to A4 landscape; direction inherits `document.documentElement.dir` when omitted, so print matches what the reader is looking at. What to print stays the host's call — the table never picks the rows for a dialog it cannot open. Compose `print(onPrint)` and Print becomes a palette command; pass `true` as the second argument and the toolbar draws a Print button beside the view controls, captioned from `labels.print`. Both are opt-in, and the button needs the handler as well as the second argument, so neither can appear on its own. The PDF is written by hand (one page tree, no dependency). By default it draws in Helvetica and embeds nothing, so the file stays a few kilobytes and the alphabet stops at WinAnsi — glyphs outside it paint as `?` and still travel in `/ActualText`. Give it a font and that limit lifts; see [Fonts and non-Latin text](#fonts-and-non-latin-text) below. ## Fonts and non-Latin text `font` takes a TrueType file as bytes — `Uint8Array` or `ArrayBuffer` — and the writer embeds a **subset** of it: only the glyphs this table drew. A 421 KB Arabic face becomes about 20 KB in the file, which is what makes the option usable on a CJK font at all. ```tsx import { pdfWriter } from "@adapttable/core/pdf"; import { exportCsv } from "@adapttable/mantine/export"; const font = await fetch("/fonts/NotoSansArabic-Regular.ttf").then((res) => res.arrayBuffer() ); ; ``` Arabic needs more than glyphs, and it gets it. Letters take their contextual shapes — initial, medial, final, isolated — lam and alef become the single glyph they are written as, and right-to-left runs are reordered for drawing with the Unicode bidirectional algorithm's reordering rule, so a Latin product name, a date or a price inside an Arabic sentence still reads forwards and brackets face the right way. The logical string travels untouched in `/ActualText` and in the font's `/ToUnicode` map, so copy-paste, search and a screen reader read the sentence as written whatever order it was drawn in. Hebrew, Persian and Urdu reorder the same way; Persian and Urdu letters shape too. CJK needs neither shaping nor reordering — embed the font and the subsetter does the rest. What the option does not do: - **OpenType shaping (GSUB).** Contextual alternates and a font's own ligature tables need a shaping engine, which is a dependency. Shapes come from the Unicode presentation forms instead: correct, readable, connected Arabic, drawn plainly where a face's own design would go further. - **Bold.** A PDF font resource is one face, so a header row is stroked as well as filled rather than switching to a bold file. - **Characters the font does not cover** — colour emoji, or Latin in an Arabic-only face — draw as `?`, the same fallback the built-in face uses. Pick a font that covers the scripts the table holds. - **CFF-flavoured OpenType.** Those store outlines as PostScript charstrings; the writer throws with a message saying so rather than embedding megabytes whole. Use the `.ttf` build of the same family. `openPrintLayout` and `printTable` take `font` too, and embed it whole as an `@font-face` — the browser shapes text itself and needs every glyph. Print does not require it: the browser already has fonts. Pass it when the printed page must match the downloaded one, or when the machine doing the printing cannot be assumed to have a face for the script. Mobile cards use the same button and the same file. `hideOnMobile` never shrinks an export; print and PDF are the column view, not a card list. A grouped or tree-shaped table exports that structure, not a denormalised leaf list — the same `ExportViewEntry` rows XLSX writes as outline levels. Omit the import and nothing is drawn and nothing is downloaded. --- # React table with SSR, server components & streaming — Next.js App Router AdaptTable renders on the server, hydrates without mismatches, and sits inside Suspense and streaming boundaries — provided the client boundary goes in the right place. This page says where that is and why. **Related:** [Data tiers](./data-tiers.md) · [URL state](./url-state.md) · [Getting started](./getting-started.md) ## The short version ```tsx // app/people/page.tsx — a server component import { DataTable } from "@adapttable/mantine"; export default async function PeoplePage() { const people = await db.people.findMany(); // runs on the server return ; // client component, see below } ``` ```tsx // app/people/PeopleTable.tsx "use client"; import { DataTable } from "@adapttable/mantine"; export function PeopleTable({ data }: { data: Person[] }) { return row.id} />; } ``` Fetch on the server, pass the rows down, render the table in a client component. That is the whole pattern. ## Why the table is a client component A table is interactive: it holds state for sorting, filters, the open editor, the selection. React Server Components cannot hold state, so the table has to sit on the client side of the boundary. That is a property of what a table is, not a limitation of this one. Every package that ships hooks carries the `"use client"` directive in its build, so importing `DataTable` from a server component works without you writing a wrapper. A test in the release gate asserts the directive is on every built entry — if it were ever dropped, an App Router build would fail on the first `useState` with an error pointing at your application rather than at us. **`@adapttable/i18n` is deliberately the exception.** It is plain data and pure functions — no hooks, no directive — so locale labels can be imported and resolved in a server component and passed down as props. ## Server rendering without a DOM During SSR there is no `window`, `document`, `matchMedia` or `localStorage`. The engine renders through `renderToString` with none of them present — the frontend tier, the server tier, and the table shell are each covered by a test that runs in a DOM-free environment. Two seams matter when you render on a server: - **`forceMobile`** decides the card/table layout explicitly. The automatic choice comes from a media query, which a server cannot answer; passing the value you want makes the server and the first client render agree. - **`urlAdapter`** defaults to the browser History API. On the server, pass `createMemoryAdapter(searchParamsString)` so the table restores state from the request's query string instead of reaching for a `window` that is not there. ```tsx import { createMemoryAdapter } from "@adapttable/core"; const urlAdapter = typeof window === "undefined" ? createMemoryAdapter(searchParams.toString()) : undefined; // the browser default ``` ## Hydration The rule is the ordinary React one: the server's markup and the first client render must match. Two things in a table can break it, and both have an answer above: - **Layout** — if the server guesses desktop and the client is a phone, the first render disagrees. Pass `forceMobile` when you render on a server. - **URL-restored state** — sort, filters and page come from the query string. Give the server the same query string the browser has, through `createMemoryAdapter`, and both renders start from the same state. Anything read from `localStorage` — saved views, a stored column layout — is applied after mount by design, so the first client render matches the server and the stored state arrives immediately afterwards. ## Suspense and streaming The table does not suspend. It renders whatever rows it is given, including none, so it never blocks a streaming response by itself. What suspends is your data. Put the boundary around the component that fetches: ```tsx }> {/* awaits its own data */} ``` For a table that fetches on the client instead, `loading` drives the built-in skeleton, and `skeletonRows` sets how many rows it shows — a Suspense boundary is not needed for that case and adds nothing. The table's first paint needs no measurement — column widths, sticky offsets and virtualization all resolve after mount — so a table inside a late-arriving chunk renders from its props alone, exactly as it does on a normal client render. ## Notes - Works the same in **Next.js (App and Pages Router), Remix, and Vite SSR**. - Server components can fetch, sort and filter before the table ever sees the data — pass the finished rows and let the table page them, or use the [server tier](./data-tiers.md) and let it drive your endpoint. - The `"use client"` directive is in the published build; you never add it to your own imports of AdaptTable. --- # Server queries — parse and validate the table's query The table puts its whole state in the URL. That is what makes a view shareable and a page reloadable — and the moment that URL reaches a backend it stops being state and becomes **user input**. `limit=999999`. `sortBy=password`. A filter on a column that is not in the table at all. Each is one fetch away from a slow query, a leaked field, or a stack trace in a log. ```bash npm install @adapttable/server ``` That is the whole install. The package runs in a route handler by definition, so it holds no React: nothing in it renders, and nothing it imports does either. An Express or Fastify service with no React in the project can install it and parse. ## One call ```ts import { parseTableQuery } from "@adapttable/server"; export async function GET(request: Request) { const query = parseTableQuery(request, { columns: ["name", "team", "budget"], maxLimit: 100, }); return Response.json(await people(query)); } ``` `parseTableQuery` takes a `Request`, a `URL`, a query string or `URLSearchParams` — so Next.js route handlers, Remix loaders and Server Actions all work without an adapter — and returns a `ServerTableQuery`: | Field | What it is | | ---------------- | ------------------------------------------------------------ | | `page` | 1-based, always at least 1 | | `limit` | clamped to the schema's ceiling | | `offset` | `(page - 1) * limit`, computed once so every caller does not | | `search` | the free-text query, absent when there was none | | `sort` | the multi-sort chain, outermost first | | `groupBy` | the grouping column, when the schema allows it | | `filters` | column filters, keyed by column | | `filterTree` | the advanced AND/OR tree | | `pivot` | the [pivot configuration](./pivot.md) | | `pivotCollapsed` | the folded pivot groups, by collapse key | | `cursor` | the opaque cursor, in cursor mode | | `rejected` | everything it refused, and why | ## The schema is an allowlist `columns` is the reason this package exists. A `sortBy` that reaches your database because nobody checked it is a column name chosen by whoever sent the request. ```ts { columns: ["name", "team", "budget"], // what a client may name maxLimit: 100, // the largest page it may ask for defaultLimit: 25, // when it asks for none urlKey: "left", // when two tables share one URL } ``` A schema cannot raise `maxLimit` past the table's own ceiling of 500. ## Forgiving by default, strict on request It never throws. Anything invalid is dropped and reported: ```ts const query = parseTableQuery(request, schema); query.rejected; // [{ param: "sortBy", value: "password", reason: "not a sortable column" }] ``` A stale bookmark should give a sensible table, not a 500 — so the default is to degrade. A route that would rather reject has the list to do it with: ```ts if (query.rejected.length > 0) { return Response.json({ error: query.rejected }, { status: 400 }); } ``` ## Filter trees are all or nothing An unknown field discards the **whole** tree rather than one condition. Dropping a single condition out of an AND quietly _widens_ the result set, which is the one failure mode a filter must not have — a request that should have returned three rows returning three thousand is worse than one that returned none. Sorting and pivoting are different: an unusable sort level or pivot field is dropped on its own, because losing one level of an ordering is a smaller lie than losing the ordering, and neither can widen anything. A pivot parameter carries more than column names — whether subtotals and grand totals are shown, and which groups are folded. Those are the client's view of its own table, so they arrive as sent: the schema filters the axes and the measures, and `pivot.subtotals`, `pivot.grandTotals` and `pivotCollapsed` pass through. The folded keys are dimension **values** rather than columns — a team, a region — so nothing can vouch for them and nothing pretends to: parameterise them like a search term. ## The types `parseTableQuery(input, schema)` takes a `QueryInput` — a `Request`, a `URL`, a query string or `URLSearchParams` — plus a `QuerySchema`, and returns a `ServerTableQuery`. `QuerySchema` is the allowlist: `columns`, `maxLimit`, `defaultLimit`, `urlKey`. `ServerTableQuery` is the table above, where `filters` values are `ServerFilterValue` (one string, or several for a checklist), `pivotCollapsed` is absent rather than empty when nothing is folded, and `rejected` is a list of `QueryRejection` — each carrying the `param` it came from, the `value` that arrived, and the `reason` it was refused. ## Decoding a parameter yourself `@adapttable/core/query` is the model without React — the encodings on their own, which is what this package is built on: ```ts import { deserializePivot, parseFilterTree } from "@adapttable/core/query"; const tree = parseFilterTree(params.get("ft")); const config = deserializePivot(params.get("pivot")); ``` It exports the `ft=1.{…}` codec (`parseFilterTree`, `serializeFilterTree`, `isActiveFilterTree`, `FILTER_TREE_PARAM`, `FILTER_TREE_VERSION`), the `pivot=rows:…` codec (`serializePivot`, `deserializePivot`, and `serializePivotState` / `deserializePivotState` for the folded groups as well), `isFilterGroup` for walking a tree, and the types those speak in — `QueryCondition`, `QueryFilterGroup`, `SortLevel`, `SortDirection`, `PivotConfig`, `PivotMeasure` and `PivotUrlState`. Every one of those names is also on `@adapttable/core`, from the same source module. The narrow entry leaves out the hooks, which is what lets it carry no `"use client"` boundary and no React import at all — so it loads in a process that has never installed React, and the encoding it reads is the same one the table wrote. Reach for it when you want the pieces; reach for `parseTableQuery` when you want the allowlist, which is almost always. Related: [data tiers](./data-tiers.md) · [URL state](./url-state.md) · [filtering](./filtering.md) · [pivot tables](./pivot.md) --- # React table AI assistant — native widgets and active capabilities A table can tell an agent what it can do **right now**, without sending every row or every feature guide up front. Use the [optional native assistant](#the-optional-widget) or the headless `useTableAssistant` controller to build a conversation around that contract. Your application supplies the transport; table operations still pass through the same permissions, validation and approval path. That contract lives in `@adapttable/ai`. It is optional. `@adapttable/core`, every adapter root, and `@adapttable/server` import none of it. Compose `tableAgent` from `@adapttable/ai-react` when a table should publish a manifest; omit the import and the bytes stay out. ## What is wired, not what is installed Capabilities come from the live table: - A table with search and pagination advertises `view.setSearch` and `view.setPage`. - Grouping, filters, export, editing and reorder appear only when that feature is composed **and** the host callback (where a write needs one) is present. - `view.setSelection` appears when selection is wired (`apply.setSelection`). - `view.pinColumn` appears when column pinning is wired and at least one column is pinnable. `view.hideColumn` and `view.setColumnOrder` appear when a layout-owning feature (the Columns menu) is composed. `view.pinRow` appears when row pinning is wired. These are view operations, so none takes the write-approval path. - `views.apply` appears when `featureIds` includes `saved-views` and `apply.applyView` exists. - `rows.read` / `rows.resolve` appear when the table has columns. `rows.read` redacts `readable: false` cells and is bounded by `limits.readMax`. `scope: "full"` requires `source.fullDataset === true`. - `rows.add` / `rows.delete` appear when the host apply methods exist and `writePolicy` is `"allow"`. `rows.delete` is destructive, and takes the same row references `edit.cells` does — a stable `rowKey`, or a 1-based `position` in a named `scope` — resolved before any row is removed. - Data-layer truth comes from the source's [`TableSourceCapabilities`](./data-tiers.md) — the manifest copies those fields and never re-infers them from shape. Package availability never participates. Installing `@adapttable/ai` does not enable grouping on a table that never imported it. ## Filters `view.setFilters` is the extra bag the table already applies — the same keys the filter form writes (`team`, `salaryMin`, `salaryOp`). `describe` lists each visible filter's type, operators and, when the static list is short enough, its options. A `{ key, op, value }` array is accepted and converted to that bag. `FilterDef.ai` controls what the assistant sees. Omit it and the filter is visible, with options sent only when there are 50 or fewer static choices. `ai: false` hides the filter. `{ options: false }` keeps the filter and omits the values — the usual 10k-customer case. `{ options: 10 }` raises or lowers that cutoff. A list over the cutoff is omitted, not truncated, so a sample cannot look like the full set. `"auto"` and async loaders are never fetched into the prompt. `view.describe` reports the current extras and the same catalog (`AgentFilter` / `AgentFilterOption`). Pagination, sort and search already publish current state and a typed schema (`page` / `pageMax`, `sortBy` / sortable columns, the search string). Filters were the gap this catalog closes. ## Pinning Pinning is addressing, not styling, so both capabilities take identity rather than a position on screen. `view.pinColumn` takes a column `key` and a **logical** `side`: `"start"` is the inline-start edge, which is the right edge under `dir="rtl"`. The same call is therefore correct in both writing directions. Pass `side: null` to unpin. A column the host marked `pinnable: false` refuses a pin but still accepts an unpin, so a column the host pinned itself is never stranded. The end edge belongs to the table's trailing actions column, which is chrome an agent never addresses. `view.pinRow` takes a `side` of `"top"` or `"bottom"` — physical, because a pinned row sits above or below the scrolled body in every direction — plus a row reference. Address the row by stable `rowKey`, or by 1-based `position` with the `scope` and the `expectedRevision` that position was read at; a position read against a view the table has since left is refused rather than applied to whatever row now sits there. Summary rows are chrome, not data, and cannot be pinned this way. `view.describe` reports the live `pinnedColumns` map and `pinnedRows` lists, so unpinning is an inverse of what is actually pinned rather than a reset of the layout. ## Assistant contracts A conversational assistant is a wrapper around this same executor — there is no chat-specific dispatcher. `@adapttable/ai` exports the shapes a controller and a widget are written against: `AssistantRequest`, `AssistantAction`, `AssistantProposal`, `AssistantOutcome`, `AssistantTurn`, `AssistantConversation` and the `AssistantPlanner` seam that turns a sentence into actions. An `AssistantAction` is exactly the `(capabilityKey, args, expectedRevision, idempotencyKey)` tuple `execute` already takes, so a planned turn is governed identically to a scripted call, and an action planned against a stale view fails instead of applying to a different one. `AssistantSuggestion` is an authored prompt with a stable `id`, a localizable `title`, and the capability keys it `requires`. Suggestions are never derived from capability keys — a key is not a sentence. `eligibleSuggestions(suggestions, available)` hides the ones this table cannot run, and `assertUniqueSuggestions` catches a repeated id. A capability definition may contribute its own through `presentation`. Nothing in these contracts imports React or calls a model. ## The headless assistant `@adapttable/ai-react` turns those contracts into a conversation, and still renders nothing. `useTableAssistant({ session, transport, suggestions })` returns `status`, `messages`, `draft`/`setDraft`, `send`, `stop`, `clear`, the live `suggestions`, `runSuggestion`, `open`/`setOpen` and `error`. A host renders its own panel from those; the widget each kit ships is written against the same values, so it is a convenience and never a requirement. `examples/ai-assistant-custom-ui.tsx` is a complete panel with no widget in it. The rules it keeps: - **One send at a time**, reserved before any await, so two clicks in one tick cannot interleave two turns' actions against one table. - **A draft survives a failed turn.** It is cleared optimistically and put back if the turn fails — unless the reader typed something else meanwhile. - **Stopping is not failing, and nothing is retried.** An action whose outcome is unknown stays unknown; the assistant never sends it twice. - **A late reply is dropped.** A turn belonging to a previous table, or to a panel that has unmounted, never writes into the transcript. - **Closing the panel discards nothing** — not the draft, not the transcript, not a submitted action. - **A new session is a new conversation.** Switching tables aborts the turn in flight and starts empty, so history never crosses between tables. `transport` may be a fresh object every render; the controller reads the latest one rather than reconnecting on its identity. A host that genuinely swaps transports — a backend for a scripted one — says so with `transportKey`, because a backend must never quietly become a simulated one. Receipts come from results, never from an outer flag. `receiptFromResult` and `receiptsFromResults` report `executed`, `staged`, `rejected`, `awaiting-approval`, `cancelled`, `stale` or `failed`; `turnStatus` summarizes a turn as `applied`, `partial`, `none`, `cancelled` or `failed`. An approved write that has not reached the host is `staged`, not executed — Save is still the reader's, on the table's own dirty path. A transport is the only thing that knows about HTTP or a model. `assistantHttpTransport` on `@adapttable/ai/http` adapts the existing backend bridge; a host writing its own implements `AssistantTransport` from `@adapttable/ai` and pulls in neither. ## The optional widget Every kit ships a panel on `@adapttable//assistant`, and it is a separate entry point on purpose: a table that never imports it carries none of it. ```tsx import { TableAssistant } from "@adapttable/mantine/assistant"; import { useTableAssistant } from "@adapttable/ai-react"; const assistant = useTableAssistant({ session, transport, suggestions }); ; ``` `TableAssistant` takes `TableAssistantProps`: the `assistant` view, `open` and `onOpenChange`, an optional `presentation` (`TableAssistantPresentation` — `"panel"` beside the table, or `"sheet"` for a modal on a narrow viewport), `labels`, `className`, `launcher` (set `false` when the host supplies its own trigger — the toolbar button and the floating launcher drive ONE panel), and `onSettings`. `tableAssistant()` binds the same component to the `TABLE_ASSISTANT` slot for hosts that compose it as a feature. The panel is a sibling of the table, never a cell inside it, so it can sit beside the grid without covering the rows a reader is asking about. ### What the panel does, in every kit Structure, keyboard and announcements live in `TableAssistantChrome` (`TableAssistantChromeProps`); each kit fills `TableAssistantSlots` with its own `Panel`, `Sheet`, `Button`, `Composer` and `Badge` (`TableAssistantPanelProps`, `TableAssistantSheetProps`, `TableAssistantButtonProps`, `TableAssistantComposerProps`, `TableAssistantBadgeProps`). Core draws no control, so a Mantine table's assistant is Mantine and an antd table's is antd — `createAdapterTableAssistantFeature` is what an adapter calls to bind its own. - **Empty state** asks what to do, then offers only the suggestions this table can actually run. - **Enter sends, Shift+Enter starts a line**, and Enter mid-IME-composition belongs to the IME — sending there would post a half-written word. - **Send becomes Stop** while a turn runs. A disabled composer always says why rather than becoming a dead end. - **Roles are named, not coloured.** Each message shows its speaker, and each receipt says in words what became of the action. A staged write says it still needs saving in the table. - **New messages follow only when the reader is already at the bottom**; otherwise the panel offers to take them there, so an earlier result stays readable. - **Escape closes the panel**, unless something inside it already answered — one key never dismisses two things. Closing returns focus to the launcher. - Backend text is rendered as text, never as markup. The view it reads is `TableAssistantView`, built from `TableAssistantMessageView`, `TableAssistantReceiptView` and `TableAssistantSuggestionView`. `useTableAssistant`'s return satisfies it, and so does a host driving the panel from its own state. `assistantIsBusy` and `assistantIsUsable` answer the two questions a host's own chrome usually asks of a status token. ## Six ways to wire it Every one of these runs through the SAME governed executor. What changes is how much of the UI you keep. **1 — The ready widget.** The short path: a panel in your kit's own components, beside the table. ```tsx import { useTableAssistant } from "@adapttable/ai-react"; import { assistantHttpTransport } from "@adapttable/ai/http"; import { TableAssistant } from "@adapttable/mantine/assistant"; import { tableAgent } from "@adapttable/ai-react"; const transport = useMemo( () => assistantHttpTransport({ endpoint: "/api/table-agent" }), [] ); const assistant = useTableAssistant({ session, transport, suggestions }); ; ``` `session` comes from the table. Either read it inside the table's tree with `useFeatureState(TABLE_AGENT_STATE)`, or lift it out with `tableAgent({ tableId, bridge: { attach: setSession } })` when the panel is a sibling. **2 — Your own launcher.** The floating launcher and a toolbar button drive one panel, so turn the built-in one off and open it yourself. ```tsx ; ``` **3 — A controlled panel.** Own the open state and the surface. Pass `presentation="sheet"` on a viewport too narrow for a table and a panel side by side, and the kit's own modal is used. ```tsx const [open, setOpen] = useState(false); const narrow = useMediaQuery("(max-width: 900px)"); const assistant = useTableAssistant({ session, transport, open, onOpenChange: setOpen, }); ; ``` **4 — Your own UI, our controller.** Keep the lifecycle, render nothing of ours. `examples/ai-assistant-custom-ui.tsx` is a complete panel built this way; the widget above uses these same public values, which is what makes it optional rather than required. ```tsx const a = useTableAssistant({ session, transport, suggestions });
    {a.messages.map((m) => (
  1. {m.role} {m.text} {m.receipts?.map((r) => ( {r.capabilityKey}: {r.status} ))}
  2. ))}
; ``` **5 — Your own transport.** The seam names nothing about HTTP or any model, so this pulls in neither. Anything that turns a sentence into actions is valid — a backend, an in-process planner, or a fixed script. ```ts import type { AssistantTransport } from "@adapttable/ai"; const transport: AssistantTransport = { async send({ session, text }) { const result = await session.execute( "view.setFilters", { filters: planFilters(text) }, session.manifest().viewRevision, crypto.randomUUID() ); return { text: "Filtered.", results: [result], keys: ["view.setFilters"] }; }, }; ``` Pass the live revision, not a remembered one: an action planned against a view the table has left must fail rather than apply to a different one. **What a transport owes the panel.** `signal` is a request, and a transport is your code: honour it if you can, by passing it to `fetch` or to whatever does the waiting. The panel does not depend on that. If a stopped turn answers anyway, its reply is dropped rather than appended, and Stop frees the composer immediately rather than waiting for a transport that may never settle. What a transport must NOT do is retry. Stopping cancels the panel's interest in an answer; it does not undo a write that already reached the host, and it cannot cancel work a backend has already started. An action whose outcome is unknown stays unknown — sending it again is how one stopped edit becomes two. **6 — The HTTP backend you already run.** `assistantHttpTransport` adapts the existing bridge; `examples/ai-http-backend.ts` is the runnable server. ```ts const transport = assistantHttpTransport({ endpoint: "/api/table-agent", headers: { authorization: `Bearer ${yourEndpointToken}` }, }); ``` That token is your endpoint's, never a model provider's. Provider credentials belong on the backend; the browser never holds one, and this library contains no model client to hold it with. ## What a reader is actually told - **Eligibility is live.** Suggestions and capabilities are re-checked against the current manifest, so a feature the host turns off stops being offered rather than failing when pressed. - **Descriptions are progressive.** `catalog()` is small and stays small; `describe(key)` fetches a schema only when something needs it. - **Approval is not persistence.** Approving a write lets it reach the host. Under `commit: "stage"` the host callback is the staging one, so the change sits on the table's own dirty path and the panel says it still needs saving. Approving and saving are two separate acts by design. - **Continuation is optional.** A local action receipt needs no second model call; nothing forces one turn to become two. - **Sessions are isolated.** One session per table, one conversation per session. Switching tables aborts the turn in flight and starts empty, so a reply about one table can never land under another. ## Three portable calls Any agent runtime can speak this: 1. `catalog()` — keys and one-line summaries, in a stable order. 2. `describe(key)` — the guide plus strict input/output JSON Schemas. 3. `execute(key, arguments, expectedRevision, idempotencyKey)` — validate, refuse a stale revision, replay an idempotent key, then dispatch. Runtimes that support typed tools can wrap each described capability as its own tool. The three calls stay the fallback. Protocol identity is the schema version (`adapttable.agent.v1`) and the capability keys. Labels may be translated for people; execution is locale-independent. ## The manifest does not send rows Initialization publishes: - table id and view revision - the enabled capability keys - readable/writable column metadata - how rows are addressed (`visible` / `page` / `full`) - limits (`pageMax`, `readMax`) and policy (`write`, `approval`, `commit`) - the source capability record It never dumps the dataset or every feature instruction. Bounded `rows.read` and write approval (`approval` / `commit` / kit chrome) live in [`@adapttable/ai`](./ai.md). The kit strip uses `agent-approval`, `agent-approval-list`, `agent-approval-approve`, `agent-approval-reject`, and `agent-approval-row`. Escape rejects. Enter is not a silent confirm. ## Registering a capability of your own `createAgentSession({ capabilities })` takes `AgentCapabilityDefinition`s. A definition carries a namespaced `key`, a one-line `summary` for the catalog, a `guide` with JSON Schema for its input and output, an `isEnabled(observation)` that decides whether it is wired right now, and `execute`. ```ts const archive: AgentCapabilityDefinition = { key: "orders.archive", summary: "Archive an order.", kind: "write", guide: { guide: "…", input: archiveInput, output: archiveOutput }, isEnabled: (observation) => observation.writePolicy === "allow", execute: (context, args) => host.archive((args as ArchiveArgs).rowKey), }; ``` `kind` is what makes it governed. A `"write"` or `"destructive"` capability goes through the same path as a built-in mutation, and the session — not your handler — enforces it: 1. the table's write policy, then the commit mode; 2. `plan`, if you wrote one, to resolve a side-effect-free `CapabilityPlan` the approver can read; 3. approval, when the table's `approval` policy asks for it; 4. the revision and the permissions again, after every await; 5. only then `execute`. A handler that never calls `onApprove` therefore cannot write unapproved, and a denied or still-pending approval calls it zero times. Staging is declared, not assumed. A governed capability defaults to `staging: "unsupported"`, so a table running `commit: "stage"` rejects the call with `commit-incompatible` before your handler runs rather than committing something the host wanted staged. Set `staging: "supported"` when the capability really can stage. `AgentCapabilityContext` is what `execute` receives: the `observation` it was authorized against, the host's `apply` callbacks, a live `observe()`, the bound `onApprove`, the request's `signal` and `throwIfCancelled()`, and — for a governed write — the approved `plan` and the resolved `commit` mode. ### Wiring the review The ready-made path is two props. The assistant hands you the write; the panel draws it: ```tsx import { useTableAssistant } from "@adapttable/ai-react"; import { TableAssistant } from "@adapttable/mantine/assistant"; const assistant = useTableAssistant({ session, transport }); ; ``` `approval` is safe to pass always: the panel draws it only when the resolved presentation names the widget. A panel mounted outside the table cannot read its feature state, so the bridge hands the same value over: ```tsx const [approval, setApproval] = useState(null); tableAgent({ bridge: { approvals: setApproval } }); ``` To draw the review yourself, read the model and render whatever you like. The counting, the labels and the preview are all in it, so a custom surface says the same things the built-in ones do: ```tsx import { approvalReview } from "@adapttable/react/adapter"; function MyApproval({ pending }: { pending: AgentApprovalPending }) { const review = approvalReview(pending, labels); if (!review) return null; return ( ); } ``` `decideAt` is absent when the write cannot be split — hide per-row controls rather than drawing dead ones. ### One write at a time An approval is one transaction, and a second write while one is open is refused rather than queued behind it. Every decision control belongs to the transaction it was made for: a control left over from an approval that has settled does nothing, so a stale click cannot answer the next write. The write settles exactly once, whichever comes first — the reader deciding, the turn being aborted, or the last row being answered. Approving is not saving. `commit: "stage"` puts an approved change on the table's own dirty path, where the reader still presses Save; `commit: "immediate"` calls the host save path and reports what it returned. Rejecting stops a write that has not run. It does not undo one that already has — cancellation after the host callback is the host's own concern. ### Where a waiting write is reviewed Three surfaces can review an approval, and exactly one of them draws the controls: whichever the resolved `presentation` names. | `presentation` | Where | | ------------------ | --------------------------------------- | | `widget` (default) | In the conversation, under the messages | | `table` | A strip above the rows it changes | | `modal` | The kit's own dialog, over the page | The others do not repeat the buttons. The assistant says a change is waiting when the decision is being made elsewhere; that is all. Every surface draws the same review, because they all read one model: ```ts import { approvalReview } from "@adapttable/react/adapter"; const review = approvalReview(pending, labels); review.changes; // 12 review.rows; // 8 — three edits to one row are three changes and one row review.preview; // the first three review.approveLabel; // "Approve all" — "Approve" for one, "Approve remaining" // once any change has been decided ``` A long write opens with a summary — _12 proposed changes across 8 rows_ — and the first three changes. **Review all 12 changes** opens the rest inside the same surface: in widget mode the conversation gives way and **Back to conversation** returns, rather than a second overlay opening over the first. `Approve all` becomes `Approve remaining` the moment any single change is decided, because a row already refused stays refused and the first label would be a promise the control cannot keep. A running tally sits beside it. A write with one change says `Approve` and offers no per-row pair: "all" of one names a set that does not exist, and two controls answering the same question is a decision the reader has to make before they can act. A write that enumerates no rows — an opaque server operation — is shown by name and its arguments as pairs, not as the JSON the capability will receive. It gets no invented row count and no per-row checkboxes, and it is answered whole. Closing the assistant does not answer anything. The write stays parked and the launcher still says so; reopening brings the review back. ### The reader and the model are told different things A proposal has two before-values, and they are not the same value. `WriteProposal.before` is what the MODEL is told. It is read at the agent's own addressing scope, through the same readable-column allowlist as `rows.read`, because it travels: the session returns it, and an HTTP or MCP continuation sends it back to the backend. A row outside that scope therefore has no before-value here, and a column marked `readable: false` never appears in one. What the person approving sees is resolved separately, in the React binding, from the table already on their screen. That value never enters a proposal, a result, or any transport — so a row the current filter hides still reads correctly for them without widening what the model was given. Being able to see the table is not entitlement to every cell in it. A column the host marked unreadable resolves to nothing on that side too, and nothing is reported as **Unavailable** rather than drawn as an empty cell: ``` Ada · ssn: Unavailable → redacted // nobody could look it up Ada · notes: — → "call back" // the cell is genuinely empty ``` `beforeUnavailable` on the approval proposal is that distinction. A value is never invented to fill the gap. ### Whether a human is asked, and where Two questions, answered separately. **Policy** is whether an agent has to wait for a person. **Presentation** is where that person is asked. Turning approval off does not move a surface, and choosing a surface authorizes nothing. The table sets both once: ```ts useTableAssistant({ approval: { policy: "writes", presentation: "widget" }, }); ``` `approval: "writes"` still works and means the policy alone. The defaults are `writes` and `widget`. An action overrides either field on its own: ```ts const actions: RowAction[] = [ { key: "email", label: "Email", onClick: email }, { key: "delete", label: "Delete", onClick: remove, // Always ask for this one, wherever the table reviews approvals. ai: { approval: { policy: "required" } }, }, ]; ``` Inheritance is per FIELD. Overriding the policy leaves the presentation as the shared one, and vice versa — so a table can say "review in the widget" once and then mark two sensitive actions as always-ask without repeating itself. An absent `ai` object changes nothing: there is no key whose absence means authorized. `policy: "automatic"` skips the human confirmation and nothing else. Permissions, schema validation, and the staging and save rules all still run, and a column the table marked unwritable is still refused. **`ai.approval` is not `confirm`.** `confirm` describes a person clicking the action themselves and being asked whether they meant it. `ai.approval` describes an agent asking to run it on their behalf. One agent execution raises one prompt — the approval — not both. The `ai` object carries overrides only. The action's key, label, handler and disabled rules stay where they are; it is plain data, so a table with no assistant carries no agent code because one of its actions mentions it. ### Deciding a bulk write row by row A reader can approve some of a bulk write and refuse the rest, and the session then narrows `context.plan`: `proposals` and `payload` both describe exactly the approved rows, in plan order. `args` is not narrowed. It is what the model asked for, and rewriting it would misreport the request. So a handler that works from `args` rather than from `plan.payload` would apply rows the reader refused — and the session cannot read your handler to find out which kind it is. So you say. `partial: "supported"` is a promise that `execute` applies `plan.payload`: ```ts { key: "staff.raise", kind: "write", partial: "supported", plan: (context, args) => ({ proposals: rows.map((row) => ({ rowKey: row.id, column: "salary", after: row.next })), payload: rows, perItem: true, }), execute: (context) => save(context.plan?.payload as Row[]), } ``` The default is `"unsupported"`. An undeclared capability is offered to the reader whole, and a row-by-row answer for it is refused with `approval-not-decomposable` rather than quietly widened to "approve all" — so `args` and `plan` always agree for handlers that never opted in. Three things must all hold before a write is offered per item: the plan sets `perItem: true`, the capability declares `partial: "supported"`, and `payload` is an array lined up with `proposals` index for index. `rows.reorder` is the counter-example among the built-ins — two proposals describe one indivisible move, so it is always offered whole. A malformed decision fails the call without writing anything: a position outside the plan, a repeated position, or a non-integer returns `approval-invalid`. Nothing is filtered and run anyway. A `read` or `view` capability skips all of it. Nothing about a view operation asks for write approval. ## Cancelling Pass an `AbortSignal` to `execute` and the session stops at every seam it owns: before your handler runs, after planning, after approval, and before each row of a bulk write. Cancellation is not an approval question — a table with `approval: "never"` and no `onApprove` cancels exactly the same way. A multi-step handler cooperates by calling `context.throwIfCancelled()` immediately BEFORE each side effect, and by passing `context.signal` to anything that accepts one: ```ts execute: async (context, args) => { const rows = await fetchArchivable(args, { signal: context.signal }); context.throwIfCancelled(); await context.apply.deleteRows?.(rows.map((row) => row.id)); return { archived: rows.length }; }; ``` Nothing here claims to undo a callback the host has already been given. That is what decides the retry rule: - **Cancelled before any write.** Nothing ran, so the idempotency key is left free and the same key may be sent again. - **Cancelled part way through a bulk write.** The rows already written stay written and are reported in `results`; the rows after them are never attempted. The key now belongs to that partial outcome, and sending it again replays the outcome rather than writing the first rows twice. --- # AI table API — sessions, approval and governed execution `@adapttable/ai` exposes a table's available operations to an agent without choosing a model provider. Discover capabilities, inspect their schemas and execute validated actions against the current table. Applications retain control of permissions, approval and persistence. For connection examples, read [OpenAI, MCP and JSON integrations](./ai-integrations.md) or [the HTTP backend guide](./ai-http.md). ## Start here Three levels, in the order most teams want them. Each is complete on its own; none is a prerequisite for the next. **1. The ready experience.** A widget, a transport, and the table you already have. The capabilities come from the table's configuration — you do not copy a schema into a backend, and there is nothing to keep in step. ```tsx import { tableAgent, useTableAssistant } from "@adapttable/ai-react"; import { assistantHttpTransport } from "@adapttable/ai/http"; import { TableAssistant } from "@adapttable/mantine/assistant"; const transport = assistantHttpTransport({ endpoint: "/api/agent" }); function Orders({ rows }: { rows: Order[] }) { const [session, setSession] = useState(null); const assistant = useTableAssistant({ session: session ?? undefined, transport, // Only a genuine transport swap re-establishes the conversation. transportKey: "orders", }); return ( <> ); } ``` `assistant.error` carries the last failure and each turn's receipts say what actually happened — `applied`, `staged`, `awaiting-approval`, `rejected`, `stale`, `failed` or `cancelled`. A staged write is not a saved one, and the panel says so rather than reporting success. **2. Your own agent, our executor.** You already have a model call and a reply to parse. Map whatever it produced onto a capability key and arguments, and call the session — the permission predicate, the revision check and the approval policy all apply, whichever route reaches them. ```ts const result = await session.execute( "view.setFilters", { filters: { team: ["Core"] } }, session.manifest().viewRevision, // Your own replay identity. The same key twice never runs twice. `turn-${turnId}-1` ); if (!result.ok) report(result.error); // never a silent retry ``` See [`examples/ai-custom-bridge.ts`](https://github.com/orwa-mahmoud/adapttable/blob/main/examples/ai-custom-bridge.ts). **3. The conversation, without React.** `@adapttable/ai/assistant` is the whole lifecycle — one send at a time, a draft that survives a failure, a late reply dropped rather than appended — with no DOM and no HTTP. A binding for another framework subscribes to it rather than re-deriving those rules. ```ts import { createTableAssistant } from "@adapttable/ai/assistant"; const store = createTableAssistant({ session, transport }); const stop = store.subscribe(() => render(store.getState())); store.connect(); // Always, on unmount or teardown: a disposed store drops its connection and // cannot write into a surface that has gone. onTeardown(() => { stop(); store.dispose(); }); ``` See [`examples/ai-assistant-store.ts`](https://github.com/orwa-mahmoud/adapttable/blob/main/examples/ai-assistant-store.ts) and, for a custom panel in React, [`examples/ai-assistant-custom-ui.tsx`](https://github.com/orwa-mahmoud/adapttable/blob/main/examples/ai-assistant-custom-ui.tsx). The optional helpers add convenience, not permission. None of them is required to use an agent you already have, and none of them can reach past what the table wired. ```bash npm install @adapttable/ai ``` The root entry is React-free and SDK-free. Use it from any agent runtime. The React feature is a separate import. ```ts import { createAgentSession } from "@adapttable/ai"; import { tableAgent } from "@adapttable/ai-react"; ``` ## `tableAgent({ tableId, bridge, writePolicy, approval, commit, columns, readMax })` Compose it next to the other features: ```tsx import { tableAgent } from "@adapttable/ai-react"; import { DataTable } from "@adapttable/mui"; import { agentApproval } from "@adapttable/mui"; import { filters } from "@adapttable/mui/filters"; sendToRuntime(manifest), attach: (session) => (runtime.session = session), }, }), ]} />; ``` `writePolicy` is `"allow"` or `"deny"`. Deny strips every write key. Allow still goes through the host's existing edit/reorder/add/delete callbacks — the table never owns the data. `approval` is `"writes"` (default — every mutating key), `"destructive"` (only `rows.delete`), or `"never"` (skip chrome and `onApprove`; still validate and honour commit). Host `onApprove?: (proposal) => Promise` replaces the kit strip when it is set. `commit` is `"stage"` (default — dirty/batch path; Save still belongs to the reader) or `"immediate"` (invoke the host callback now). `readMax` bounds `rows.read` (default 50). `scope: "full"` requires `source.fullDataset === true`. Readable-false columns are redacted. `columns` overlays readable/writable/type on the published column metadata. `bridge.publish` receives every new manifest (feature, column, permission or policy change). `bridge.attach` receives the live `catalog` / `describe` / `execute` session. Two tables each get their own session; they do not share revision or idempotency state. ## Manifest shape ```ts { schemaVersion: "adapttable.agent.v1", tableId: string, viewRevision: number, capabilities: CapabilityKey[], columns: { id, label, type, readable, writable, sortable }[], rowAddressing: { scope: "visible" | "page" | "full", key: "rowKey" }, limits: { pageMax: number, readMax: number }, policy: { write: "deny" | "allow", approval: "writes" | "destructive" | "never", commit: "stage" | "immediate", }, source: TableSourceCapabilities, } ``` ## Capability keys Stable catalog order: `columns.describe`, `view.describe`, `view.setPage`, `view.setSort`, `view.setSearch`, `view.setFilters`, `view.setGroupBy`, `view.setAggregations`, `view.pinColumn`, `view.hideColumn`, `view.setColumnOrder`, `view.pinRow`, `view.setSelection`, `views.apply`, `rows.read`, `rows.resolve`, `export.run`, `edit.cells`, `rows.add`, `rows.delete`, `rows.reorder`. `view.setFilters` takes `{ filters }` — the extra bag the table already uses (`{ team: ["Core"] }`, `{ salaryMin: 10000, salaryOp: "gt" }`) or an array of `{ key, op, value }` conditions. `describe` lists the live filters, operators and (when the static list is short enough) options. `ai: false` on a `FilterDef` hides that filter; `{ options: false }` or a number cap omits a large list rather than truncating it. Without declarative defs the argument stays the host's own object. `view.setAggregations` takes `{ set?, remove?, restoreDefaults? }` — add or change specific column operations without replacing the others, remove specific aggregations with the same suppression semantics as the panel, or restore developer defaults. The whole request is validated before anything is applied. It is advertised only when grouping can actually execute aggregates. `describe` lists eligible columns and operation ids (never a `calculate` function). A server-side apply reports `pending: true` until the response that answers the new query arrives. `view.pinColumn` takes `{ key, side }` where `side` is the logical `"start"` or `null` to unpin. `view.hideColumn` takes `{ key, hidden? }` — omit `hidden` or send `true` to hide, `false` to show. `view.setColumnOrder` takes `{ key, index }` (0-based, hidden columns included) or `{ order }` with every column id exactly once. `view.pinRow` takes `{ side }` — `"top"`, `"bottom"` or `null` — with a `rowKey`, or a `position` plus the `scope` and `expectedRevision` it was read at. These are view operations, so none takes the write-approval path. See [adaptive capabilities](./agent-capabilities.md#pinning). `edit.cells` takes `{ edits: Array<{ column, value, rowKey?, position?, scope? }> }` — each edit needs `rowKey` or a 1-based `position`. Positions resolve before write. `rows.delete` addresses rows the same way: `{ rows: Array<{ rowKey?, position?, scope? }> }`, resolved before anything is removed, so a reference that names no row is refused while the deletion is still a proposal. The execute result is `{ ok, revision, idempotencyKey, result: { proposals, applied, approval, results? } }`. `createAgentSession({ observe, apply })` is the same contract without React — hand it the current observation and the apply hooks. See [adaptive capabilities](./agent-capabilities.md). ## What the agent is told `buildAgentContext(session, options?, inputs?)` on `@adapttable/ai/context` produces the whole of it: the **contract** (what this table permits, which changes rarely) and the **view** (where it is right now, which changes constantly). Nothing else reaches a model, and the builder performs no I/O — it reads no rows and makes no request. ```ts import { buildAgentContext } from "@adapttable/ai/context"; const { contract, view, selection } = buildAgentContext( session, { profile: "compact" }, // omit for the unbudgeted default { filters: liveFilters, view: { page, limit, search } } ); ``` **Profiles.** `full` is the default: every guide and every column description travels, bounded only by the hard byte limit. What a model's context window costs is the backend's business, and trimming on its behalf means guessing — a guess that drops the sentence saying a column counts thousands is paid for by the reader who gets the wrong number back. `compact` is there for a host that has measured its own backend and wants the smaller payload: it names every capability but attaches the full guide only for the ones a turn is likely to need, and strips column detail to make room. The rest are fetched on demand, in one batched discovery round rather than one request per key. `selection.selected` and `selection.deferred` say which went which way, and `selection.notes` says why. Pass `tokenBudget` with either profile to name your own ceiling. **Budgets and sizes.** `selection.contractBytes` and `selection.viewBytes` are UTF-8 bytes on the wire. `selection.estimatedTokens` is an estimate of prompt size and says so: `selection.estimated` is `true` when nobody counted, and you can pass your own `estimateTokens` to make it a measurement. Neither is a count of model invocations — a turn is one invocation whatever the context weighs. **Exclusions.** `createAgentSession({ excludeCapabilities })` and `tableAgent({ ... })`'s equivalent remove a capability from the contract, the suggestions and the executor at once. The table's own control is untouched: a person can still filter a table whose agent may not. **Versions.** `contract.version` names everything the contract says. Two requests carrying the same version describe the same table; a backend that pinned one and sees another knows to ask for the whole thing again. **Column examples.** `ai.examples` on a column is what the author wrote. `ai.sample: true` opts that column into live sampling, which is a separate, deliberate call — `sampleColumnValues(session, key)` reads through `rows.read` under the session's own permission predicate, caps at five distinct values, revalidates each against the column's declared type, and never touches an unreadable column. The contract marks those columns `sampled: true`, so nobody confuses an author's example with somebody's data. `tableAgent` does the reading for you: it collects the columns whose author asked, samples them once when the contract changes rather than once per turn, and hands the values in. A host building its own context calls `sampleColumns(session, wanted)` and passes the result as `inputs.samples` — the context builder performs no I/O of its own, which is why the read happens outside it. Sampling is for a column with no declared list of its own. A filter-backed column already publishes its options in the filter catalog — the declared set, complete, so no row can introduce a value the model has not been told about. What sampling answers is the other case: free text, references, dates and amounts, where the only way to learn how a value looks is to see one. The values are read when the contract changes, so they illustrate the column rather than tracking it. Five values were never the set anyway, which is why the contract marks them `sampled: true` — a model needing the real list reads rows or the filter catalog. ## Saying how far a long call has got A capability that works through a thousand rows one call at a time has nothing to say between "started" and its receipt, and a reader watching a spinner cannot tell a slow write from a stuck one. Its execution context carries a channel for that: ```ts capabilities: [ { key: "orders.settle", // … execute: async (context, args) => { const rows = rowsFor(args); for (const [index, row] of rows.entries()) { context.throwIfCancelled(); await settle(row); context.reportProgress?.({ done: index + 1, total: rows.length, label: "orders", }); } return { settled: rows.length }; }, }, ]; ``` The conversation shows it where it is waiting, and it goes when the turn settles. `label` is shown as given — this package has no translation for your nouns, and inventing one would be worse than leaving it out. **Progress is not a result.** The receipt still comes from what the handler returned, a call that reported progress and then failed has failed, and none of this involves the model: nothing is sent, and no second call is made to say the work is done. Wire `bridge.progress` to read it outside the table; a panel inside the table reads it from the table's own state and needs nothing. ## Who owns the conversation The panel keeps the transcript itself and sends every earlier message with each turn, which is what a backend that remembers nothing needs. Both halves are yours to change, and they are independent of each other. **Where the messages live.** Pass `messages` and the panel renders the list you hand it instead of the one it kept; `onMessagesChange` reports every change, with the whole list. A different array replaces the transcript, which is how a message that arrived somewhere else gets in: ```tsx const [messages, setMessages] = useState(() => loadThread(threadId)); // Another device, a colleague, an agent working the same table. useEffect( () => socket.on("message", (m) => setMessages((all) => [...all, m])), [] ); useTableAssistant({ session, transport, messages, onMessagesChange: setMessages, }); ``` Handing the same array back is you holding what you were given; only a different one replaces the transcript. **How much of it travels.** `conversation` decides how much history goes out with each turn. It never changes what the reader sees. | | On the wire | For | | ------------------ | ---------------------- | ----------------------------------------------------- | | `"full"` (default) | every earlier message | a backend that keeps no session | | `20` | the last 20 | a long thread where resending all of it earns nothing | | `0` | this turn's text alone | a backend that holds the thread itself | ```tsx // The server owns the thread; it needs the question, not the history. useTableAssistant({ session, transport, conversation: 0 }); ``` ## Stopping, losing the connection, and coming back Three different things happen to a turn that does not end in a reply, and the conversation reports which one: - **Stop** — the reader ended it. The signal is raised, the remaining actions never run, and nothing is left running anywhere. `interrupted` is `"stopped"`. - **Disconnect** — the connection was released, by an unmount, a table change or `disconnect()`. The backend was never asked to stop, so it may still be working. `interrupted` is `"detached"`, and the conversation stops waiting for a reply that can no longer reach it. - **Resume** — rejoin work that is still running. The turn comes back on a fresh baseline: the table is read again before anything is applied, because it is not the table the turn started against. A turn can only be detached if the transport named something to come back to. It does that while the turn runs, through `onResumable`, and implements `resume` to rejoin: ```ts const transport: AssistantTransport = { send: async ({ text, onResumable, signal }) => { const job = await backend.start(text, { signal }); // Named now, because a connection released later is too late to ask. onResumable?.(job.id); return toReply(await backend.wait(job.id, { signal })); }, resume: async ({ handle, signal }) => { // Same replay identities as the first attempt, so a completed action is // answered from the session's record rather than run again. return toReply(await backend.wait(handle.token as string, { signal })); }, }; ``` Without `resume`, releasing the connection ends the turn — which is the honest outcome for a transport that cannot get back to it. **What this package guarantees:** within the life of one session, an action that already ran is not run again when a turn is resumed. The session keeps a replay record per identity, and a resumed attempt that reuses its identities gets the recorded result rather than a second execution. **What the host owns:** everything that has to survive the page. A reload builds a new session with an empty replay record, a new transcript and no memory of what ran, so durable recovery is a host decision. Keep the handle and hand it back: ```ts useTableAssistant({ session, transport, onDetach: (handle) => sessionStorage.setItem("turn", JSON.stringify(handle)), resumeHandle: restoredHandle, }); ``` A backend whose work outlives a page must also be idempotent across it — the replay record that answers a repeated action is in the session that went away. ## Approval, undo and what a reader agreed to `approval` answers two questions separately: **which** capabilities need a human (`policy`), and **where** they are asked (`presentation`). An action can override either for itself. `alwaysAllow` is a third, and it is off unless you name the keys: ```ts tableAgent({ tableId: "orders", approval: { policy: "writes", alwaysAllow: ["edit.cells"] }, }); ``` The control appears only for a capability on that list. It never appears for a destructive one, never for a write that enumerates rows, and never for an action whose own configuration demands a human every time — and a host `onApprove` bypasses the chrome entirely, so nothing there can reach past it. A panel mounted beside the table rather than inside it cannot read the table's feature state, so the standing decision travels through the bridge: `bridge.alwaysAllowed` is called with an `AlwaysAllowedState` — the capability keys currently waved through and a `revoke(key)` — and `useTableAssistant` takes it as `alwaysAllow`. Without it a reader can allow a capability and have no way to take it back. ```ts const [allowed, setAllowed] = useState(null); tableAgent({ bridge: { alwaysAllowed: setAllowed } }); useTableAssistant({ session, transport, alwaysAllow: allowed ?? undefined }); ``` What a reader waved through is readable from the store as `alwaysAllowed` and revocable with `revokeAlwaysAllow(key)`; it resets whenever the contract moves, because "allow this" was said about a table that no longer exists in that shape. A key that this table does not offer is an error at build time (`ApprovalAlwaysAllowError`), not a control that silently never appears. **Undo covers the view, per turn and per action.** The sanitized view is captured before a turn's calls run and restored through the same capabilities the agent used. A turn that did more than one thing also offers each action its own control on its receipt card, because restoring the field that action wrote is exactly that action and nothing else; a turn that did one thing offers only the turn's, which is already the same control. The offer stands while the fields it would put back still hold what the turn left in them — a reader changing the page, a second agent, a source refresh or a later turn all end it, and the panel says which. Writes are not part of it: staging, Save and the edit history own those. Receipt cards are on by default and `receipts={false}` on the assistant turns them off for a host that keeps its own account of a turn. The record is unchanged either way: the receipts stay in the conversation state. ## Protocol adapters Each is an optional subpath. None is in the root graph, and each routes every call back through `session.execute`. | Subpath | For | | -------------------------- | ------------------------------------------------ | | `@adapttable/ai/http` | The wire, the client and the assistant transport | | `@adapttable/ai/assistant` | The framework-free conversation store | | `@adapttable/ai/context` | The permitted context, and column sampling | | `@adapttable/ai/voice` | Dictation, in the browser or through a backend | | `@adapttable/ai/json` | Plain JSON tools and `AgentEnvelope` | | `@adapttable/ai/openai` | Strict function tools with OpenAI-safe names | | `@adapttable/ai/mcp` | MCP tools, resources and annotations | | `@adapttable/ai/mcp-apps` | The table as a view an MCP host embeds | | `@adapttable/ai/webmcp` | The table as browser tools for a page agent | | `@adapttable/ai/ag-ui` | The table as an AG-UI run's frontend tools | | `@adapttable/ai/ai-sdk` | The table as AI SDK client tools | `@adapttable/ai-react` is the React binding: `tableAgent` and `useTableAssistant`. It is the only one of these that imports React. --- # React table AI integrations — OpenAI, MCP and JSON tools `@adapttable/ai` does not host a model, a chat UI, or an AdaptTable service. A live table publishes a compact catalog. Your runtime maps that contract onto the tools it already speaks, then calls `session.execute`. [Try the interactive demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/ai/) — a real Mantine table you talk to. The demo answers a fixed set of example requests with no model behind them; connect your own backend from the assistant's settings for free-form conversation. Nothing on this page is required to put a conversation in front of a reader. If that is what you want, start with [the optional widget](./agent-capabilities.md#the-optional-widget) — one panel per kit — or [the headless controller](./agent-capabilities.md#the-headless-assistant) if you would rather draw it yourself. [Six ways to wire it](./agent-capabilities.md#six-ways-to-wire-it) lays them side by side, from the ready widget to a transport of your own. This page is the layer underneath: how any agent runtime reaches the same governed executor. See [adaptive capabilities](./agent-capabilities.md) and [`@adapttable/ai`](./ai.md) for the session itself. ```ts import { createAgentSession } from "@adapttable/ai"; import { executeEnvelope, toJsonTools } from "@adapttable/ai/json"; import { toOpenAITools } from "@adapttable/ai/openai"; import { mcpListChanged, toMcpResources, toMcpTools } from "@adapttable/ai/mcp"; ``` The root entry stays React-free. `@adapttable/ai-react` is only for `tableAgent`. The three integration subpaths never import a model SDK. ## Three integration levels ### 1. Custom frontend bridge Transform any agent's action format, then call `session.execute`. ```ts import { runCustomBridge, createBridgeSession, } from "../examples/ai-custom-bridge"; const session = createBridgeSession({ setFilters: (filters) => applyHostFilters(filters), }); await runCustomBridge( session, { tool: "view.setFilters", input: { filters: { team: ["Core"] } } }, "bridge-filter-core" ); ``` Compiling source: [ai-custom-bridge.ts](../examples/ai-custom-bridge.ts). ### 2. `AgentEnvelope` on your transport Carry a versioned envelope over HTTP, a websocket, or postMessage. Parse it in the browser or worker, then execute. There is no second argument validator — `session.execute` owns that. ```ts import { executeEnvelope, parseEnvelope } from "@adapttable/ai/json"; const envelope = parseEnvelope(body); const result = await executeEnvelope(session, envelope); ``` ```ts interface AgentEnvelope { schemaVersion: "adapttable.agent.v1"; tableId: string; key: string; args: unknown; expectedRevision: number; idempotencyKey: string; } ``` Compiling source: [ai-server-agent.ts](../examples/ai-server-agent.ts). ### 3. Optional JSON / OpenAI / MCP helpers Use these from **your** agent runtime. LangChain or any other framework may consume the JSON tools. None of them become an AdaptTable dependency. ```ts const tools = toJsonTools(session); const openai = toOpenAITools(session, { deferred: true }); const mcp = toMcpTools(session); ``` Compiling source: [ai-mcp-host.ts](../examples/ai-mcp-host.ts). ## One-call response — result stays in the app A model may return text and structured actions in one response. Execute the actions, then show success or error in application or table UI. Sending `ExecuteResult` back to the model is optional. ```ts import { applyTurnWithoutRoundTrip } from "../examples/ai-one-call"; const { text, results } = await applyTurnWithoutRoundTrip(session, turn); showInApp(text, results); ``` Compiling source: [ai-one-call.ts](../examples/ai-one-call.ts). ## Optional result-return loop ```ts import { applyTurnAndReply } from "../examples/ai-result-return"; await applyTurnAndReply(session, turn, (text, results) => { sendBackToYourRuntime(text, results); }); ``` Compiling source: [ai-result-return.ts](../examples/ai-result-return.ts). Do not add a chat-response wrapper to AdaptTable. The turn envelope is application-owned. ## Catalog is live-table-derived `catalog()` lists only capabilities enabled by mounted features, column permissions, source support and host callbacks. A table with filtering and pagination must not advertise editing, grouping or pivoting. Two equally valid ways to learn schemas: 1. Compact `catalog → describe → execute` — describe one key when needed. 2. Eager helpers (`toJsonTools`, `toOpenAITools`) that call `describe` for every enabled key. ```ts for (const entry of session.catalog()) { const guide = session.describe(entry.key); await session.execute(entry.key, args, revision, idempotencyKey); } ``` Host write-safety chrome (not on the envelope): - `approval`: `"writes"` | `"destructive"` | `"never"` (default `"writes"`) - `commit`: `"stage"` | `"immediate"` (default `"stage"`) Stage records a proposal in the host edit callback. Immediate persists through that same callback. Approve, then Save, then Undo — the table never owns the data. ## JSON, OpenAI, MCP `toJsonTools` returns `JsonFunctionTool[]`. `executeJsonTool` takes a `JsonToolCall`. `executeEnvelope` and `parseEnvelope` are re-exported from `@adapttable/ai/json`. `toOpenAITools(session, { deferred: true })` returns only `catalog`, `describe` and `execute`. `strict: true` is the default. Eager tools replace each `.` in a catalog key with `_` (`view.setPage` → `view_setPage`) because OpenAI function names cannot contain dots. `executeOpenAITool` maps those names back, and still accepts the dotted catalog key. Deferred `execute` requires both `key` and `args` (`args` is a JSON string under `strict`). Strict mode lists every property in `required`, forbids open maps, and treats originally-optional fields as nullable. `toMcpTools` / `toMcpResources` list enabled keys. Each capability is also a resource at `adapttable://table/{tableId}/capability/{key}`. `mcpListChanged` decides when to emit `notifications/tools/list_changed`. `@adapttable/ai/http` is the optional ready-made transport: post the permitted context to your endpoint and execute the calls that come back through the same session. Setup, protocol and the runnable example live on [connect a backend](./ai-http.md). ## Protocol adapters Each of these speaks a protocol somebody else defined, and each ends in the same `session.execute` — the same exclusion predicate, the same revision check, the same approval policy and the same receipts. None adds an SDK dependency: the event and part types are this package's own, and the one seam a host fills is "send this, stream that back." An integration is described as supported here only once its recorded-event conformance fixture runs against the real adapter code. ### MCP tools, and the table as an MCP App `toMcpTools` carries annotations derived from what each capability declares: `readOnlyHint` for view and read keys, `destructiveHint` true only for a destructive one and explicitly false elsewhere, `idempotentHint` where running it twice lands where running it once did, and `openWorldHint` always false — a capability acts on this table and nothing behind it. `toMcpToolList` and `toMcpResourceList` return the same lists as cacheable responses, stamped with the contract they describe. `mcpToolResult` maps one `execute` outcome into a `tools/call` result and keeps the provenance envelope on a row window. `@adapttable/ai/mcp-apps` publishes the table as a view an MCP host embeds: `mcpAppResource` for the `ui://adapttable/table/{tableId}` descriptor, `mcpAppCsp` for a policy built only from the domains you declared, and `createMcpAppBridge` for the view's side — `ui/initialize`, the tool-input and tool-result notifications, and `tools/call` for a reader's actions. The bridge requires the host's exact origin: a handshake posted to `"*"` would announce the table's contract to whatever else is listening. `approveThroughHost` and `askThroughHost` use the host's elicitation when it advertises one and return nothing when it does not, leaving the table's own approval in charge. ### WebMCP — an agent in the page ```ts import { registerWebMcpTools } from "@adapttable/ai/webmcp"; const registration = registerWebMcpTools(session, { exposedTo: ["view.setPage", "view.setSort", "rows.read"], onWarning: (warning) => console.warn(warning.message), }); // A contract change is a different set of tools: dispose and register again. onTeardown(() => registration.dispose()); ``` Nothing happens at import: `document.modelContext` is read when you call it, so the module loads on a server and in a browser without the API and registers nothing in either. In React, `tableAgent({ webmcp: true })` does the same and re-registers on a contract change for you. An agent in the page reads the table through one call and changes it through another, and the reader can move the table in between. Every tool therefore takes an optional `expectedRevision` — the revision the call was planned against, from the `revision` an earlier result carried: ```ts // The agent read the table at revision 5 and acts on what it read. await tool.execute({ page: 3, expectedRevision: 5 }); // Refused if the reader has since moved it, and the refusal says where it is: // { error: { code: "revision-mismatch", ... }, revision: 7 } ``` Omit it and the call acts on the table as it is, which is what an agent that never names a revision has always done. This is the one transport where the revision travels on the call: HTTP and the AI SDK send the view with each request, and AG-UI publishes it as state, so those bind it for you. ### AG-UI — the table as a run's frontend tools ```ts import { aguiTransport } from "@adapttable/ai/ag-ui"; const transport = aguiTransport({ connection: { run: (input, signal) => yourEndpoint(input, signal) }, onApprove, // the same seam `session.execute` uses }); ``` The enabled contract becomes the run's tool definitions, the sanitized view its `STATE_SNAPSHOT` and then RFC 6902 `STATE_DELTA`s, and the conversation its `MESSAGES_SNAPSHOT`. A `RUN_FINISHED` interrupt with `reason: "confirmation"` becomes an `ApprovalSubject` and resumes with `resume[{ interruptId, status, payload }]`; `input_required` becomes a question for the reader. A tool call this table does not own is left to whoever registered it. ### AI SDK — client tools on your own route ```ts // Your route, your provider, unchanged apart from the spread. import { aiSdkTools } from "@adapttable/ai/ai-sdk"; streamText({ model, system, tools: { ...yourTools, ...aiSdkTools(session) }, }); ``` Every entry omits `execute`, which is how the AI SDK decides the client runs it. In the browser, `aiSdkTransport` answers those calls and returns each result as the tool output on the next request — the shape `addToolOutput` sends. `tool-approval-request` becomes an `ApprovalSubject` and answers as an approval response; `output-denied` carries the reject reason. A stream that declares a version this adapter does not speak is refused with `unknown-stream-version` rather than parsed as though it were one it does. ## Examples - [ai-custom-bridge.ts](../examples/ai-custom-bridge.ts) — any agent format → `execute` - [ai-one-call.ts](../examples/ai-one-call.ts) — text + actions, no model round trip - [ai-result-return.ts](../examples/ai-result-return.ts) — optional result return - [ai-server-agent.ts](../examples/ai-server-agent.ts) — Node worker + HTTP envelope - [ai-mcp-host.ts](../examples/ai-mcp-host.ts) — tools, resources, list-changed - [ai-browser-agent.tsx](../examples/ai-browser-agent.tsx) — `tableAgent` + JSON tools - [ai-http-backend.ts](../examples/ai-http-backend.ts) — runnable OpenAI/Anthropic/Gemini/DeepSeek server - [ai-assistant-custom-ui.tsx](../examples/ai-assistant-custom-ui.tsx) — a complete conversation panel with none of the shipped widget in it - [ai-assistant-store.ts](../examples/ai-assistant-store.ts) — the conversation with no React, no DOM and no HTTP - [ai-agui-host.ts](../examples/ai-agui-host.ts) — an AG-UI run driven from recorded events, including a confirmation interrupt - [ai-sdk-route.ts](../examples/ai-sdk-route.ts) — the route half: client tools declared beside a route tool - [ai-http-backend.py](../examples/ai-http-backend.py) — the same wire in Python, standard library only, run through `uv` Intention fixtures (no live model) live in `packages/ai/src/__fixtures__/intentions.json`. --- # React table AI backend — HTTP protocol and local example This is the setup page for a real model behind a live table. The [interactive playground](https://orwa-mahmoud.github.io/adapttable/demo/mantine/ai/) defaults to **Simulated**: local scripted buttons, no credentials, no network model call. Switch to **Connect backend**, paste an endpoint, and the same `tableAgent` session sends the permitted context and executes the calls that come back. A recorded walkthrough of this page will land here when it exists. Absence of that video is not a missing feature. `@adapttable/ai` stays provider-neutral. It does not ship a model SDK, an API key field, or a hosted AdaptTable service. Provider selection lives in your backend, or in the example server below. ## What the table does automatically On each request the HTTP bridge sends: - the [permitted context](./ai.md#what-the-agent-is-told) — the contract (capabilities, column permissions, filters, limits, write/approval/commit policy) and the view (page, size, search, sort, grouping, filter state); never the dataset - the user message, and the conversation so far How much of each capability's guide travels is the **profile**. `compact` names every capability and explains the ones a turn is likely to need; anything else is asked for on demand, in one batched round rather than a request per key, and cached per connection and contract version. `full` sends every guide up front. Neither changes what the table permits — only how much explaining arrives before it is asked for. A backend that asks for a guide or a row window puts that in `toolCalls` alongside anything it wants run; the bridge answers through `session.describe` and `rows.read`, so an unreadable column stays redacted and `readMax` still applies, then continues the same turn. Returned calls run through `session.execute`. Revision checks, permissions, approval chrome, commit policy and idempotency stay on the session, and the replay identity is the bridge's own — a backend cannot mint a second write by repeating one. The bridge never retries a mutation. Text plus calls is a complete turn. A second model call is not required to say “done.” Sending execute receipts back is optional (`continueWithResults` on the response, `returnResults` on the client). ## Path 1 — run our example Prerequisites: Node 22.6 or newer (the example runs TypeScript directly with `--experimental-strip-types`) and `pnpm install` at the repository root. From this repository: ```bash cp -n examples/ai-http-backend.env.example examples/.env.ai-http ``` `-n` keeps an env file you already filled in — the copy is skipped rather than overwritten. Edit `examples/.env.ai-http`. Set `AGENT_PROVIDER` to `openai`, `anthropic`, `gemini` or `deepseek`, and the matching API key. Never commit the filled file. ```bash pnpm --filter @adapttable/ai build pnpm --filter @adapttable/examples ai-http ``` The server refuses to start on a configuration it cannot use: an `AGENT_PORT` that is not a port, an empty `AGENT_HOST` or `AGENT_MODEL`, an unknown `AGENT_PROVIDER`, or a provider whose API key is missing. Each of those exits with the name of the variable to fix. The process binds `127.0.0.1` on port `8787` by default. A non-loopback `AGENT_HOST` requires `AGENT_HTTP_TOKEN`. **Local showcase (reliable).** Hosted GitHub Pages cannot be assumed to reach `localhost` — browsers treat that as a cross-origin public-site request, and many block it. Run the showcase on the same machine: ```bash pnpm --filter @adapttable/showcase dev ``` Open `/mantine/ai/` (or any adapter AI page). Choose **Connect backend**. The URL defaults to `http://127.0.0.1:8787`. If the example set `AGENT_HTTP_TOKEN`, paste that same value into **Endpoint token** (Bearer for the HTTP endpoint — not a provider API key). Click **Connect**. A valid hello body is required; a random 200 is a failure. Then type a message and **Send**. Writes still go through that kit’s approval and staged-save chrome. **Hosted showcase.** Deploy the example with HTTPS, set `AGENT_ALLOWED_ORIGINS` to `https://orwa-mahmoud.github.io`, set `AGENT_HTTP_TOKEN` and a non-loopback `AGENT_HOST`, then paste the public URL and the same endpoint token into Connect backend. Do not point the hosted page at `localhost`. ### Environment See [ai-http-backend.env.example](../examples/ai-http-backend.env.example). Values are read in this order, and the first one that sets a key wins: 1. the real process environment — no file overrides what the shell, the container or the CI runner already set; 2. the file named by `AGENT_ENV_FILE`; 3. `.env.ai-http` beside the example; 4. `.env` in the working directory. Files are parsed by Node itself (`process.loadEnvFile`), not a bespoke reader. | Variable | Role | | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | `AGENT_PROVIDER` | `openai` (default), `anthropic`, `gemini`, or `deepseek` | | `AGENT_MODEL` | Optional override. Each provider has a default. | | `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` / `GEMINI_API_KEY` / `DEEPSEEK_API_KEY` | Server-side only | | `AGENT_HTTP_TOKEN` | Required when `AGENT_HOST` is not loopback. Bearer on the endpoint | | `AGENT_HOST` | Bind address. Default `127.0.0.1` | | `AGENT_ENV_FILE` | Optional extra env file. Existing process.env keys win | | `AGENT_ALLOWED_ORIGINS` | Comma-separated origins. Defaults to local showcase ports | | `AGENT_PORT` | Default `8787` | | `AGENT_MAX_BODY` | Default `65536` bytes | `AGENT_ALLOWED_ORIGINS` is a CORS list. CORS is a browser convenience, not authentication: it tells a browser which pages may read the response, and nothing at all to curl, a script, or any non-browser client. `AGENT_HTTP_TOKEN` is what actually authenticates a caller — set it on every bind you do not fully control. The example does not log credentials or table contents. It is not an open proxy: it only calls the configured provider. ### Add another provider Implement a `complete({ system, user, signal })` that returns a JSON string, then register it in `completeForProvider`. Arbitrary provider names do not work without that adapter. ## Path 2 — connect an existing backend Implement the protocol below, or adapt your agent runtime so it emits the same JSON. The showcase and `createAgentHttpClient` need no custom glue when the body matches. Junior backends import `agentSystemPrompt` and send that string as the model system prompt. Senior backends skip it and keep their own prompt or tools. The example calls the export; it does not keep a private copy of the skill text. ```ts import { agentSystemPrompt, createAgentHttpClient } from "@adapttable/ai/http"; const client = createAgentHttpClient({ endpoint: "https://your.example/agent", headers: () => ({ authorization: `Bearer ${token}` }), timeoutMs: 20_000, }); await client.connect(session); const { text, results } = await client.send(session, "Filter the Core team"); ``` You can still use your own transport, tool definitions and response transform — this client is optional. See [agent integrations](./ai-integrations.md). ## Protocol `POST` JSON. Schema family: `adapttable.agent.v1`. Import `parseAgentHttpRequest` / `parseAgentHttpResponse` from `@adapttable/ai/http` rather than duplicating the shape; both refuse an unknown `schemaVersion`. ### Request | Field | When | What | | ------------------ | ------------------------------------------------- | ------------------------------------------------------------------------------------------ | | `schemaVersion` | always | `"adapttable.agent.v1"` | | `kind` | always | `"hello"`, `"schema"` or `"turn"` | | `tableId` | always | The table's identity | | `context` | hello, schema, and every turn when pinning is off | `{ contract, selection }` — what this table permits, and what was selected from it | | `contractVersion` | with `context` | Names everything the contract says | | `selectionVersion` | with `context` | Names what was selected from it | | `sessionId` | after a hello | The pin the backend handed back | | `viewRevision` | a pinned turn | The revision the contract was read at | | `view` | every turn | Where the table is **now** — page, size, search, sort, grouping and permitted filter state | | `message` | a turn | What the reader said | | `conversation` | a turn | Earlier exchanges, oldest first | | `toolResults` | a continuation | Results for calls the client just ran | | `audio` | a voice turn | One clip, travelling once | The contract and the view move on different clocks, which is why they are separate fields: pinning the contract does not pin the view, and a backend answering a turn is always answering against the view in that request. ### Response ```ts { schemaVersion: "adapttable.agent.v1", ok?: boolean, // hello / health only sessionId?: string, text?: string, toolCalls?: { id, name, args?, expectedRevision? }[], askUser?: { id, question, options?, allowFreeText }, transcript?: string, // what the backend heard, on a voice turn pin?: { status, contractVersion?, ttlMs? }, continueWithResults?: boolean } ``` **`toolCalls`** is one list, whether a call runs a capability or asks for something: `name` is a capability key, or `describe` / `read`. A call's `id` is a correlation handle unique within the reply — it is **not** the replay identity. The client issues that itself, from the table, the turn, the phase and the call's position, so a backend cannot mint a second write by repeating a string. **`expectedRevision`** is optional. Omitted means the view this request described, which is the ordinary case; a backend that names one is answering for a revision it observed itself, and a stale call is refused with `revision-mismatch` rather than rebased onto the live table. **`askUser`** puts a structured question to the reader instead of asking in prose. The turn stops at that call and resumes when they answer; the answer returns as that call's `toolResults` entry. A client with nowhere to draw one reports `unresolved: "no-reader-channel"` and still says what already ran, and a reader who declines reports `question-unanswered` — different facts. **`pin`** is the backend's answer about the contract it was sent: `acknowledged` (and only that) pins something; `expired` and `unknown` say a pin it once held is gone, which is recoverable by resending the contract; `unsupported` says this backend does not pin at all. A backend that echoes a different `contractVersion`, or none, is not pinned and keeps being sent the contract. None of these is ever the answer to a call whose outcome is unknown: a write is never retried. ### Discovery A turn that needs a guide asks for it in the same `toolCalls` list, and the client answers in one batched round rather than one request per key. Guides are cached per connection and contract version, so a second turn that needs the same guide does not ask again. Send `profile: "compact"` and let discovery do this; send `profile: "full"` when you would rather pay the bytes up front. ### Streaming Ask for `text/event-stream` and the same reply arrives as events: - `text-delta` — a piece of the answer, as it is produced - `transcript` — what the backend heard, on a voice turn - `ask-user` — the structured question - `tool-calls` — the calls, whole, exactly once - `done` — the calls are final - `error` — the turn failed; `{ code, message }` The calls travel whole and only before `done`: a client that loses the connection first has run nothing. A stream that ends without `done` is reported as `stream-incomplete` rather than treated as a short answer. ### Voice `@adapttable/ai/voice` has two modes. In **browser** mode the recognizer runs locally and heard text becomes the composer's draft — never a send, so a misheard word is a typo the reader corrects. In **backend** mode one clip is recorded, released from the microphone on stop, and sent as `audio`; the backend answers with a `transcript`, which the panel shows in place of the reader's own bubble and sends as text on every later round of the turn. ### Any language The wire is a wire. [`examples/ai-http-backend.py`](https://github.com/orwa-mahmoud/adapttable/blob/main/examples/ai-http-backend.py) is the same contract in Python with no model and no framework — standard library only, run through `uv`: ```bash uv run --python 3.12 examples/ai-http-backend.py ``` It reads the contract out of the request, decides one call, and answers in the shape above. Swap its `decide` for a real model call and nothing else changes. The machine-readable schema and the general rules text are generated from `AGENT_HTTP_LIMITS` and `agentInstructions` into `schemas/agent-http.v1.json` and `docs/agent-rules.txt` — build the package and run `node scripts/build-agent-schema.mjs`. ## Live provider test (owner) Automated checks use mocked providers and never spend money. To try a real model: fill `examples/.env.ai-http`, start the example with `pnpm --filter @adapttable/examples ai-http`, run the local showcase, Connect, and send a message. That live test is yours. --- # Customize AdaptTable — slots, classNames, headless prop-getters ▶ **See it working:** [the unstyled adapter in the live demo](https://orwa-mahmoud.github.io/adapttable/demo/?kit=tailwind) — same engine, your own classes. A spectrum, all opt-in: restyle parts with `classNames`, replace parts with `slots`, tune the chrome with props, or theme through your kit's provider. Per-row colour and height are `rowStyle` / `rowHeight` — see [row styling and heights](./row-styling.md). ## `classNames` — per-part styling Restyle without replacing. Mantine and Chakra expose five wrapper hooks (`root`, `toolbar`, `table`, `card`, `footer`); the **unstyled** adapter exposes a hook for every rendered node: ```tsx import { DataTable } from "@adapttable/unstyled"; r.id} classNames={{ table: "w-full text-sm", row: "border-b hover:bg-zinc-50 data-[selected]:bg-blue-50", cell: "px-3 py-2", filtersPopover: "rounded-lg border bg-white shadow-xl", filtersDone: "rounded-md bg-zinc-900 text-white px-3 py-2", }} />; ``` Every unstyled node also carries a stable `data-adapttable-part` attribute — the kebab-case of the `classNames` key (`searchField` → `data-adapttable-part="search-field"`) — plus `data-*` state attributes, so plain CSS, Tailwind, and shadcn tokens all work. The full part map: > **Using shadcn/ui?** `@adapttable/shadcn` is this same unstyled adapter with > the shadcn class preset already applied — import `DataTable` from > `@adapttable/shadcn` and pass `classNames` to override only the parts you > name. ### Toolbar & search | Part | Element | | ------------- | ----------------------------------------------------------------------- | | `root` | The outer wrapper around the whole table. | | `toolbar` | The toolbar row (search, filters, columns, views, your `toolbar`). | | `searchField` | The search field wrapper (input + leading icon). | | `search` | The search ``. | | `searchIcon` | The leading magnifying-glass icon. | | `sortSelect` | The mobile sort-by `` (toolbar in infinite mode, footer when paged). | ### Filters | Part | Element | | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | | `filtersButton` | The Filters trigger button. | | `filtersIcon` | The funnel icon inside the trigger. | | `filtersCount` | The active-filter count badge. | | `exportCsvButton` | The Export CSV toolbar button (`exportCsv()` feature). | | `exportSpinner` | The spinner inside that button while a host-handled export runs. | | `cellSelected` | A cell inside the selected range (`cellNavigation`). Styled kits use their own token. | | `cellSpan` | A spanned cell (`getCellSpan`). `data-cell-span` is on the element (`"2x1"`). Default look is centered + one fill. | | `filtersAnchor` | The popover anchor wrapper around the trigger. | | `filtersPopover` | The anchored popover card (`filtersMode="popover"`). | | `filtersBackdrop` | The drawer backdrop (`filtersMode="drawer"`). | | `filtersPanel` | The drawer panel. | | `filtersHeader` / `filtersTitle` / `filtersClose` | Panel header, its title, and the close button. | | `filtersBody` / `filtersFooter` | The panel content area and its action row. | | `filtersClear` / `filtersDone` | The clear-all and done/apply buttons. | | `filterField` / `filterLabel` | One auto-built field's wrapper and its caption. | | `filterInput` / `filterSelect` / `filterOperator` | Text/date/number inputs, the `select` widget, and a range field's operator `
` and its sections. | | `headerRow` / `headerCell` | The header `` and one `` and one `` and its spanning `` (or antd's grouped row wrapper). | | `group-cell` | The spanning ``, its ``, and one summary `
`. | | `groupRow` / `groupCell` | The grouped-header row and one spanning cell. | | `sortButton` / `sortIndex` | A sortable header's button and its multi-sort badge. | | `row` / `cell` | One body `
`. | | `actionsCell` / `actionButton` | The trailing actions cell and one action button. | | `rowActionsTrigger` / `rowActionsMenu` | The 3-dot control and its menu (`rowActionsLayout="menu"`). Menu items reuse `actionButton`. | ### Row expansion | Part | Element | | ----------------------------- | ----------------------------------------------------- | | `expandHeader` / `expandCell` | The leading chevron header cell and body cell. | | `expandButton` | The expand/collapse chevron (rows and cards). | | `detailRow` / `detailCell` | The full-width detail `
`. | | `cardDetail` | The detail section inside an expanded mobile card. | ### Inline cell editing Opt-in via `editing()` — see [Inline cell editing](./cell-editing.md). When editing is dormant these parts are never mounted. | Part | Element | | -------------------- | ------------------------------------------------------------------- | | `edit-cell-activate` | Invisible activate control (double-click / Enter / F2 begins edit). | | `edit-cell-editor` | Kit-native input / select while the cell is active. | ### Row grouping Opt in with `groupingPanel()` from the kit's `/grouping-panel` subpath — see [Row grouping](./row-grouping.md). The panel owns the grouped headers as well as the configuration strip; when it is dormant these parts are never mounted. | Part | Element | | -------------------------------- | ------------------------------------------------------------------------------- | | `grouping-panel` | The dedicated grouping strip above the table. | | `grouping-drop-zone` | A desktop insertion target for header/chip drag-and-drop. | | `grouping-item` | One active grouping level: chip plus its following insertion target. | | `grouping-chip` | An active grouping field, with logical arrow-key movement and a remove control. | | `grouping-add` | The Add grouping column select, present on desktop and mobile. | | `grouping-aggregations` | Active aggregation chips plus Add aggregation column and Restore defaults. | | `grouping-aggregation-item` | One active aggregation: column name, operation select, remove. | | `grouping-aggregation-operation` | That item's operation select. | | `grouping-aggregation-remove` | That item's remove control. | | `grouping-aggregation-add` | The Add aggregation column control. | | `grouping-aggregation-option` | One checklist row. | | `grouping-aggregations-restore` | Restore defaults. | | `grouping-remove-zone` | Desktop drop target shown while dragging a grouping chip to remove it. | | `grouping-announcer` | Polite live region for add, move, remove, and aggregation-change feedback. | | Part | Element | | ------------------- | --------------------------------------------------------------------- | | `group-row` | Desktop group header `
` / `` inside a group header (most kits). | | `group-footer-row` | The row closing a group when `groupFooters` is set. | | `group-footer-cell` | The spanning cell inside a group footer. | | `group-footer-card` | Group footer block in the mobile card list. | | `group-card` | Group header block in the mobile card list. | | `group-toggle` | Expand / collapse chevron (`aria-expanded`, `expandGroup` labels). | | `group-label` | The group's display label (bucket value). | | `group-count` | Leaf count beside the label (`labels.groupCount`). | | `group-select` | Tri-state checkbox over the group's leaf rows (when selection is on). | | `group-aggregate` | One per-group aggregate cell (`data-column` = column key). | ### Mobile cards & summary | Part | Element | | ---------------------------------------- | -------------------------------------------------- | | `cards` / `card` | The card list and one card. | | `cardRow` / `cardLabel` / `cardValue` | One label/value line inside a card. | | `summary` / `summaryRow` / `summaryCell` | The `
`. | | `summaryCard` | The trailing summary card in the mobile list. | ### Footer, pagination & states | Part | Element | | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | | `footer` / `pager` | The footer bar and the trailing pager group (page-of label + numbered pages). | | `pageButton` / `pageEllipsis` | Every pager button (prev/next + each numbered page; the current carries `aria-current="page"`) and the "…" elision gap. | | `loadMore` / `loadMoreButton` | The infinite-mode sentinel area and its button. | | `empty` / `emptyClear` | The empty state and its clear-filters button. | | `loading` / `refreshIndicator` | The first-load skeleton and the background-refresh bar. | | `error` / `retryButton` | The error state and its retry button. | A few purely structural nodes (`scroll-box`, `virtual-spacer`, the skeleton internals) expose only the `data-adapttable-part` attribute. ### The structural parts every adapter names Seven part names are guaranteed in **every** kit, on the same element in each — so one selector works whichever adapter an app mounts: | Part | Element | | --------------------------- | -------------------------------- | | `table` / `thead` / `tbody` | The `` and its sections. | | `row` / `cell` | One body `` and one `
`. | | `header-cell` | One header ``. | | `toolbar` | The toolbar row above the table. | Two kit-specific shapes to know: antd owns its `` for the selection column, so `selection-cell` sits on a wrapper one element inside it; and a bounded height makes antd split the grid into a header table and a body table, both named `table`. Everything else in the map above is per-adapter — a themed kit names what it renders. ## Slots Replace whole sub-components on any adapter: ```tsx r.id} slots={{ empty: , skeleton: }} /> ``` `skeleton` replaces the first-load skeleton; `empty` replaces the empty-state. ### The error state `error` replaces the load-failure state. It takes a node like the others, and it also takes a function — because an error state is _about_ something: ```tsx r.id} slots={{ error: ({ error, retry, retrying }) => ( ), }} /> ``` `retry` is `undefined` when the source has nothing to ask again — a static `data` array, for instance — so hide your retry control when it is missing rather than rendering one that does nothing. `retrying` is true while a retry is already in flight. Leave the slot off and the built-in state renders, retry button included; translate it via the `errorTitle` / `errorMessage` / `retry` labels. ### Two empty states, one optional slot A table is empty for two different reasons, and they deserve different words: there is no data at all, or a search and filters matched nothing. The built-in filtered state says so and offers a **clear all filters** action. `empty` covers both. Add `noResults` when the filtered case needs its own message — including its own way back: ```tsx r.id} slots={{ empty: , noResults: , }} /> ``` Set only `empty` and it still covers both states, exactly as before — but the built-in clear-filters action goes with it, so give your replacement a way to reset the query. ## Toolbar and status bar `toolbar` has always filled the middle of the toolbar — between the search input and the built-in buttons. `toolbarSlots` fills the two ends: ```tsx , end: }} toolbar={} … /> ``` The order is the same in every kit: `start` · Search · `toolbar` · Filters · Saved views · Columns · Undo/Redo · Export · Add · Print · Density · Fullscreen · `end` · Rows per page. `undoRedoButtons()` adds Undo and Redo to that row. They render only when `editHistory()` is composed, and they disable rather than disappear when there is nothing to put back — a toolbar that reflows while someone is working is worse than a button that is briefly unavailable. The keyboard shortcuts and `table.editHistory` are unchanged and stay the always-on path; this is the visible one, for users who will not find Ctrl+Z. ```tsx import { editing, editHistory, undoRedoButtons, } from "@adapttable/mantine/editing"; import { statusBar } from "@adapttable/mantine/status-bar"; ; ``` `statusBar()` puts a strip under the table: the row range, how many rows are selected, and — with `selectionStats()` composed — what the selected cells add up to. The range is the one the pagination footer shows, from the same arithmetic, so the two never disagree. The strip hosts the selection figures rather than repeating them, so turning it on does not print them twice. Opted-in features that cannot run (virtualize on a paged table, pin under grouping/tree rows, `exportCsv()` with `scope: "all"` on a page-scoped source, edits with no writer) still show as `FeatureNotice` items (`FeatureNoticeKind` is the union). They live on `StatusBarChromeProps.notices` and `TableChrome.featureNotices`, and they render even when `statusBar()` is off — row and selected counts still need the bar. `print(onPrint, true)` adds a Print button beside the view controls. It needs two things, not one: the handler, and `true` as the second argument to draw the button. Either alone draws nothing — a button that opens no dialog would be worse than no button, and a handler on its own stays what it always was, the palette's Print command. The caption is `labels.print`, the same string the command uses. ```tsx import { printTable } from "@adapttable/core/pdf"; import { print } from "@adapttable/mantine/print"; printTable({ rows, columns }), true)]} … />; ``` All of them are off unless asked for: omit them from `features` and nothing renders and nothing is bundled. ## Highlighting a row After a save or an import, the row that changed is somewhere in a list of a thousand. `useHighlight` marks it for a moment: ```tsx const highlight = useHighlight(true); highlight.isRowHighlighted(row.id) ? "flash" : undefined } … />; // after a save highlight.flashRow(saved.id); ``` There is no new prop for this on purpose. `rowClassName` already reaches every adapter, so the highlight is a class you compute — which means it works in every kit and looks like the rest of your design system rather than like ours. Marks are keyed by row id, so one survives the sort, filter or page change that moves the row. Flashing the same row again restarts its clock rather than stacking. `animated` is false when the user has asked for reduced motion. The mark still appears and still clears — it holds steady, and holds longer, because a steady mark is easier to miss than one that moves. Reduced motion means less movement, not less feedback, so branch on `animated` to pick a class rather than to skip the highlight: ```tsx rowClassName={(row) => highlight.isRowHighlighted(row.id) ? highlight.animated ? "flash flash--animated" : "flash" : undefined } ``` `flashCell({ rowId, columnKey })` and `isCellHighlighted` do the same for one cell, for a column's `Cell` renderer to read. ## Density and fullscreen ```tsx import { DataTable } from "@adapttable/mantine"; import { standardFeatures } from "@adapttable/mantine/preset"; r.id} features={standardFeatures()} … />; ``` `standardFeatures()` includes `densityChooser()` — the toggle works with no `density` or `onDensityChange` from the host. The feature owns uncontrolled state and starts at `"comfortable"`. To control density yourself, pass `density` as the source of truth and `onDensityChange` to observe requests: ```tsx import { densityChooser } from "@adapttable/mantine/density"; import { fullscreen } from "@adapttable/mantine/fullscreen"; import type { Density } from "@adapttable/core"; const [density, setDensity] = useState("comfortable"); ; ``` Pairing controlled density with `useDensityUrlState` keeps the choice in the URL beside sort and filters, so a reload and a shared link both reproduce it: ```tsx import { densityChooser } from "@adapttable/mantine/density"; import { fullscreen } from "@adapttable/mantine/fullscreen"; import { useDensityUrlState } from "@adapttable/core"; const { density, onDensityChange } = useDensityUrlState(); ; ``` `fullscreen()` adds a toggle. Fullscreen hides everything outside the table, which is what makes it useful and also what breaks overlays: a menu portalled to `document.body` sits inside the part being hidden, still mounted and still focused. The table's own overlays are re-pointed at the fullscreen element automatically. If you portal your own, take `shell.fullscreen.container` and use it while it is set. The toggle hides itself where the browser will not allow fullscreen — an embedded webview, a sandboxed frame — because a control that cannot work is worse than no control. ## Command palette ```tsx import { commandPalette } from "@adapttable/mantine/command-palette"; import { print } from "@adapttable/mantine/print"; import { printTable } from "@adapttable/core/pdf"; printTable({ rows, columns })), ]} … />; ``` Cmd/Ctrl+K opens a palette listing every action the table can perform. Type to filter, arrows to move, Enter to run, Escape to close. Its entries are the same objects the context menus take. That is the point: an action written once appears in both, and cannot gain a condition in one and not the other. ```tsx import { commandPalette } from "@adapttable/mantine/command-palette"; ; ``` Shortcuts are data, not a key handler, because remapping is not a preference — your app may already own Cmd/Ctrl+K. `mod` means Cmd on a Mac and Ctrl elsewhere, so one chord is right on both. Pass `shortcuts: []` to bind nothing and open the palette from your own control instead. Print lives here rather than in the toolbar: `printTable` opens a browser dialog, so it is the host's call to make. Compose `print(onPrint)` and it becomes a command; leave it out and it is not offered. ## Context menus ```tsx import { contextMenu } from "@adapttable/mantine/context-menu"; ; ``` Right-click a header and it offers that column's actions — sort, filter, pin, hide. Right-click a cell and it offers copy and cut. Each entry appears only when the handler behind it is wired and the column allows it: a menu that lists "Hide column" over a column locked against hiding reads as broken rather than as forbidden. Every route in works, because a right-click-only menu is one half the people who need it cannot reach: | Route | Opens with | | -------- | ------------------------------------ | | Pointer | Right-click | | Keyboard | Shift+F10, or the dedicated menu key | | Touch | Press and hold | Escape closes it and puts focus back where it came from. A press that travels more than a few pixels is a scroll, not a menu. Add your own entries with `{ items }`. They land behind a divider, so a custom action is never mistaken for a built-in one: ```tsx import { contextMenu } from "@adapttable/mantine/context-menu"; target.kind === "row" ? [{ key: "audit", label: "Open audit log", onSelect: () => open(target.rowId) }] : [], }), ]} … />; ``` ## Side panel A popover is right for a control you touch once and dismiss. It is wrong for setting a table up — choosing columns, building a filter, arranging a pivot — because that is iterative: change one thing, look at the rows, change another. A popover closes when you look away, and the rows are behind it while it is open. `sidePanel()` docks that work beside the table instead. It is controlled, because the control that opens it is yours — `toolbarSlots` is where it usually goes: ```tsx import { sidePanel } from "@adapttable/mantine/side-panel"; const [panel, setPanel] = useState(null); setPanel("filters")}>Settings, }} features={[ sidePanel({ panels: [ { key: "filters", label: "Filters", content: }, { key: "columns", label: "Columns", content: }, ], open: panel, onOpenChange: setPanel, side: "end", }), ]} … />; ``` `SidePanelOptions` types it and `SidePanelEntry` is one panel — a `key`, a `label` and the `content` to show. With more than one panel the labels become a real tab strip: one tab stop for the whole strip, arrow keys that wrap and carry the selection, Home and End. Escape closes from anywhere inside. Putting focus back afterwards is the opener's job, since only it knows where focus was. `side` picks the edge — `"end"` (the default) is the right in a left-to-right table and the left in a right-to-left one. Omit `sidePanel()` from `features` and nothing renders, nothing is bundled, and the table's markup is unchanged. Adapters build their panel over `SidePanelChrome` / `SidePanelSlots` / `SidePanelFrameProps` / `SidePanelTabProps` / `SidePanelCloseProps` and dock it with `SidePanelLayout` / `SidePanelLayoutProps`, all from `@adapttable/react/adapter`. ## Density ```tsx r.id} density="compact" /> ``` `"comfortable"` (default) is the roomy layout; `"compact"` tightens row height and padding. With `densityChooser()` composed and no controlled `density` prop, the feature owns the choice and defaults to `"comfortable"`. Each adapter maps density to its kit's table size — MUI `"comfortable"` → `medium` / `"compact"` → `small`, antd → `middle` / `small`, Radix `"2"` / `"1"` — and MUI, Chakra, antd, and Radix offer an explicit `size` prop that overrides the mapping (e.g. antd `size="large"`). ## Export CSV by default, spreadsheets when you ask for them — one factory, one set of scopes, [one button](#spreadsheet-xlsx-export). Opt in with `exportCsv()` from `@adapttable//export` to render a kit-native **Export CSV** button next to Filters / Columns. The file mirrors the current view's data — the active search, filters, and sort — with **the full exportable column set in display order, regardless of viewport**: the same button produces the same file on phone and desktop (`hideOnMobile` never shrinks an export). Cells that a spreadsheet would execute as formulas (`=`, `+`, `-`, `@`, tab or carriage-return prefixes) are neutralised with a leading `'` by default; pass `escapeFormulas: false` in the options object if you need raw output for a non-spreadsheet pipeline. ```tsx import { DataTable } from "@adapttable/mantine"; import { exportCsv } from "@adapttable/mantine/export"; r.id} features={[exportCsv()]} // defaults: export.csv, current page />; r.id} features={[exportCsv({ filename: "people.csv", scope: "all" })]} />; ``` - `scope: "page"` (default) — the current page / loaded slice. - `scope: "all"` — the full filtered+sorted set when the source can reach it (frontend can). A source-owned route requires both actual `allFilteredRows` and a capability contract that permits `"all"`; the declaration alone does not retrieve rows. Otherwise `request` or `fetchAll` must fetch the rest — see [source capabilities](./data-tiers.md#what-a-source-can-do--capabilities). **"All" means every row that matched the filters, not every row on screen.** Display state never shrinks an export: a collapsed tree folder still writes the rows inside it, and pagination does not decide what leaves the table. A grouped export keeps its headers and totals around those rows. A server-backed table holds one page, so `"all"` has to be answered by fetching. There are two ways, and no third: - `request` — hand the whole thing to your backend. The `ExportRequest` it receives carries an `ExportQuery` with `page` and `limit` **undefined** for this scope, precisely so a handler cannot answer with one page. - `fetchAll` — let the table page the query itself and build the file in the browser. It is opt-in because it is a loop of requests, and capped because an unbounded one can hang a tab: `maxRows` defaults to `EXPORT_FETCH_ALL_MAX_ROWS` (50,000), and `onCapped` fires if the cap stopped the export short. `fetchAllExportRows` is the same walk, exported for hand-built downloads. With no executable route, the Export button stays rendered but disabled, with the localized reason on the control and in the status bar. Writing the current page as if it were everything is the one answer that is always wrong. - `scope: "selected"` — the ticked rows, in table order. Selection is a set of ids, so a row checked on page 1 is still in the file while page 3 is on screen. Nothing ticked writes a header-only file. - `scope: "range"` — the highlighted cell rectangle from [cell navigation](./cell-navigation.md). A rectangle names its own columns, so it decides them and `columns` is not consulted. With nothing selected the current page is exported instead. `columns` chooses the file's shape independently of the rows: - `columns: "visible"` (default) — what the user can see, so the file matches the screen. - `columns: "all"` — every defined column, including ones hidden through the column menu, for a complete extract. - `columns: ["name", "email"]` — exactly these, in this order. A key matching no column is ignored, so a stale saved config cannot break the button. The synthetic actions column is never exported under any of them. ### Exporting a different value than the screen shows A cell formatted for reading is worse than useless in a spreadsheet: `"$1,240.00"` cannot be summed and `"3 days ago"` cannot be sorted. Give the column an `exportValue` and the file carries the value underneath while the table keeps rendering the friendly version: ```tsx { key: "budget", accessor: (row) => money.format(row.budget), // what the user reads exportValue: (row) => row.budget, // what the spreadsheet gets } ``` Columns without one export what the table shows, so this is only needed where the two genuinely differ. Formula escaping still applies to whatever is returned. ### Spreadsheet (XLSX) export The same button writes a real `.xlsx` when you hand it the spreadsheet writer: ```tsx import { xlsxWriter } from "@adapttable/core/xlsx"; import { exportCsv } from "@adapttable/mantine/export"; r.id} features={[ exportCsv({ writer: xlsxWriter({ sheetName: "People" }), scope: "all" }), ]} />; ``` Every scope and column option above works unchanged — which rows and columns leave the table is decided before the format is asked for anything. The button relabels itself: it reads **Export XLSX** here, not "Export CSV", from `labels.exportFile(format)` — translated in every bundled locale, and given a caption for a format nobody planned for (a custom writer calling itself `tsv` gets "Export TSV"). CSV keeps `labels.exportCsv`, so its existing translations and any wording you overrode are untouched. Three differences from CSV, all in your favour. Numbers, booleans and `Date` values stay **typed**, so a spreadsheet can sum a column, filter a date, and sort a checkbox instead of reading text that looks like one; text that looks numeric stays text, so a postal code of `01730` arrives as `01730` rather than `1730`; and the sheet is styled for reading — a frozen bold header, column widths from the table, group and tree rows outlined at their depth, group footers and a `summaryRow` grand total in bold. Formula escaping is not needed and is ignored: XLSX keeps formulas in their own element, so a cell reading `=CMD()` is displayed, never executed. A grouped or tree-shaped table exports that structure, not a denormalised leaf list: group headers and footers travel with the leaves, collapsed groups stay collapsed on `scope: "page"`, and `scope: "all"` / `"selected"` include leaves that were folded or paged away — then `"selected"` keeps only the groups that still have a selected leaf. A `scope: "range"` export stays a rectangle — the selection already named its shape. Mobile cards use the same button and the same file; `hideOnMobile` never shrinks an export. It is a **separate entry point** because a table that exports CSV should not ship a ZIP encoder. Import it and you pay for it; do not and none of it reaches your bundle. There is no new dependency either way — `buildTableXlsx` writes the workbook by hand. Any format is reachable the same way. An `ExportWriter` is an extension and a `build` function over the resolved values (`ExportWriteContext` in, `ExportPayload` out); `csvWriter` is the built-in one, and `downloadExportFile` hands a built payload to the browser. A writer receives an `ExportTable` — headers, keys, and one array of values per row, resolved once by `buildExportTable` — rather than rows and columns, so two formats of the same table cannot disagree about what a cell contains, and a writer needs no type argument. ### Before and after the file is written ```tsx import { exportCsv } from "@adapttable/mantine/export"; { if (rows.length > 50_000) return false; // cancel return { filename: `people-${rows.length}.csv` }; // or rename }, onAfterExport: ({ csv, file, filename }) => track("export", { filename }), }), ]} … />; ``` `onBeforeExport` runs once the rows and columns are resolved and before anything is written — the only moment where the file's contents are known and nothing has happened yet. Return `false` to cancel, `{ filename }` to rename, or nothing to continue — a cancelled export builds no file at all. `onAfterExport` receives the text that was written as `csv`, and the built file as `file`; a binary format leaves `csv` empty and carries its bytes in `file.parts`. Headless helpers remain available: `rowsToCsv`, `downloadCsv`, and `downloadTableCsv` from `@adapttable/core`. ### Exporting from your backend Past a certain size the browser is the wrong place to build the file: the rows are not all loaded, holding them would cost more memory than the tab has, and the work blocks the main thread. `onExportAll` hands the page-free current view to the server and keeps the job visible: ```tsx import { exportCsv } from "@adapttable/mantine/export"; { const res = await fetch("/api/people/export", { method: "POST", body: JSON.stringify(query), signal: controls.signal, }); const job = await res.json(); controls.setProgress?.(job.progress); controls.setMessage?.(job.message); return job.url ? { url: job.url } : undefined; }, }), ]} … />; ``` `query` carries search, flat and nested filters, single and multi-sort, grouping, visible and requested column keys, format, and filename — but no page window and no rows. `controls.signal` powers Cancel; `setProgress(0–100)` and `setMessage(text)` update every kit's progress surface. No progress means an indeterminate indicator. Resolve `{ url }` and the table offers the download; resolve nothing when the host delivered it another way; reject for a localized error with Retry. The existing `fetchAll` route still pages in the browser and keeps its 50,000 row default cap. `onExportAll` is the documented route past it. The generic `request` callback also remains for backend takeover of page, selected, or range exports without progress. Host-owned routes do not run `onBeforeExport`/`onAfterExport`, because the table never builds their file. The full settle, cancellation, and accessibility contract is in [Browser and server-built exports](./exporting.md). ### The export pipeline (headless) The export path is exported end to end: `exportableColumns` filters the visible layout to columns with exportable values, `resolveExportColumns` applies a column scope to them, `buildTableCsv` turns rows + columns into CSV text (`RowsToCsvOptions` controls delimiter, BOM and `escapeFormulas`), `resolveExportCsv` normalizes the export options (`ExportCsvOptions`), and `makeExportCsvHandler` wires all of it to a download handler the toolbar button calls. Custom toolbars can reuse any stage. Formats plug in at the last stage only. `csvWriter` and `xlsxWriter` are both `ExportWriter`s — given an `ExportWriteContext` (an `ExportTable` of resolved values, plus the filename) they return an `ExportPayload`, which `downloadExportFile` writes. Nothing earlier in the pipeline knows which format is in play, and `matrixToCsv` is the CSV half of it for anyone assembling values themselves. Custom adapters bind the button with `useExportHandler` from `@adapttable/react/adapter`: it takes the handler above and returns `{ onExportCsv, exportBusy }` (typed `ExportHandlerState`), which is how every built-in adapter gets identical single-flight behaviour. Four supporting types: `ExportRowScope` and `ExportColumnScope` name the two scope unions, `ExportInfo` is what the lifecycle hooks receive, and `ExportContext` carries the selection, full column set and highlighted range that `scope: "selected"`, `columns: "all"` and `scope: "range"` need — the adapters pass all of it automatically, and only a hand-built `downloadTableCsv` call has to supply it. ## Sticky header, offset & scroll box ```tsx r.id} stickyHeader // keep the desktop header pinned while the page scrolls stickyTop={56} // offset under your app header (also offsets the toolbar) maxHeight={420} // fixed-height scroll box instead of page scroll /> ``` `maxHeight` turns the table into a scroll box that also scrolls sideways — the header and pinned columns stick within it, which is what makes column pinning visibly stick. `scrollToTopOnChange` (default `true`) scrolls back to the table when search/filter/page changes, with `scrollTopGap` (default `8`) of breathing room below sticky chrome. ### The surface behind sticky and pinned cells (Mantine) A sticky header and pinned columns need an opaque background, or scrolled rows show through them. That colour and the hairline under the header come from two CSS variables, so a panel whose surface is not the page background can say so: ```css .my-dark-panel { --adapttable-surface: #101418; --adapttable-header-border: #2b3238; } ``` They default to `--mantine-color-body` and `--mantine-color-default-border`, so tables look the same until you set them. Declare them on any ancestor of the table. ## Desktop table assembly The six HTML kits (Mantine, MUI, Chakra, Radix, Base UI, Unstyled; shadcn follows Unstyled) paint from one shared plan on `@adapttable/react/adapter`. Ant Design stays on its native ``. ```ts import { createDesktopRow, DESKTOP_ACTIONS_WIDTH, DESKTOP_EXPANSION_WIDTH, DESKTOP_SELECTION_WIDTH, useDesktopTableAssembly, } from "@adapttable/react/adapter"; ``` `useDesktopTableAssembly(props, options?)` takes `DesktopAssemblyProps` plus optional `DesktopAssemblyOptions` (mostly `DesktopChromeWidths` for the reserved selection / expansion / actions columns — defaults `DESKTOP_SELECTION_WIDTH`, `DESKTOP_EXPANSION_WIDTH`, `DESKTOP_ACTIONS_WIDTH`) and returns a `DesktopTableAssembly`: header leaves (`DesktopHeaderLeaf`), pin state (`DesktopTablePin`), and body slots (`DesktopBodySlot` — rows as `DesktopRowSlot`, groups as `DesktopGroupSlot` / `DesktopGroupEntry`, extras as `DesktopExtraSlot`, virtual pads as `DesktopVirtualPadSlot`). `createDesktopRow` is the memoized row the kits mount; `DesktopRowWiring` is the per-row bundle it receives. Layout changes land once in the plan; each kit still owns pixels. `tableRenderModel` and `getRowProps` stay first-class — this helper is add-only. ## Theming per kit The core is style-free: wrap your app in the kit's provider and AdaptTable renders with that kit's real components, following its theme and dark mode (`prefers-color-scheme`) automatically. ## Animations `animate` works on **every adapter** — a dependency-free row/card entrance stagger that honours reduced motion. Rolling your own? Every animatable row/card carries a `data-stagger` attribute, so leave `animate` off and target those elements with GSAP/Framer Motion. Kit-specific knobs: - **MUI** — `size` (`"small" | "medium"`) overrides the density mapping; `className` lands on the root ``. - **Chakra** — `accentColor` colors primary accents (buttons, badges); `size` (`"sm" | "md" | "lg"`, default `"md"`). - **Ant Design** — `size` (`"small" | "middle" | "large"`), `bordered` for cell borders, `className` on the wrapper. The virtualized scroll area is bounded by the shared `maxHeight` prop. - **Unstyled** — no provider needed; theme entirely through `classNames`, the `data-adapttable-part` hooks above, and your own CSS variables / `data-theme`. ## Custom cells Pass a `Cell` component (receives `{ row, rowIndex }`; define it at module level so its identity is stable) or a lighter `accessor`: ```tsx import type { CellProps } from "@adapttable/core"; function StatusCell({ row }: CellProps) { return ( {row.status} ); } const columns = [ { key: "name", sortable: true }, { key: "status", Cell: StatusCell }, { key: "salary", accessor: (r: Person) => formatMoney(r.salary), align: "end", }, ]; ``` See the [Columns guide](./columns.md) and the [ColumnDef table](./api.md#columndef) for the full column surface (`sortValue`, `i18n`, `group`, `hideOnMobile`, …). --- # React table i18n & RTL — Arabic, Hebrew, 18 locales ▶ **See it working:** [flip the whole table to Arabic RTL in the live demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/rtl/) — a real table you can interact with, not a recording. AdaptTable is **i18n-agnostic at its core** — it never imports an i18n library. Strings come in through a `labels` prop, so you can use your own stack (i18next, react-intl, …) or the ready presets in `@adapttable/i18n`. ## Complete example `labels` translates the chrome, `dir` flips the layout, and `locale` makes per-column `i18n` data paths resolve — three independent props, one line each: ```tsx import { DataTable } from "@adapttable/mantine"; import { getDirection, getLabels } from "@adapttable/i18n"; type Person = { id: string; name: string; nameAr: string; department: { name: string }; hiredAt: string; }; const locale = "ar"; export function People({ data }: { data: Person[] }) { return ( r.id} locale={locale} labels={getLabels(locale)} // Arabic chrome strings dir={getDirection(locale)} // "ar" → "rtl" /> ); } ``` ## Bundled presets The label sets below ship in `@adapttable/i18n`. `getLabels` prefers an exact tag (`"zh-TW"` → Traditional Chinese), then the primary subtag (`"ar-EG"` → Arabic, `"de-AT"` → German), and falls back to English for unknown locales; `hasLocale(locale)` tells you whether a preset exists. | Preset | Language | Direction | | ------- | --------------------- | --------- | | `en` | English | ltr | | `ar` | Arabic | rtl | | `de` | German | ltr | | `es` | Spanish | ltr | | `fa` | Persian | rtl | | `fr` | French | ltr | | `he` | Hebrew | rtl | | `hi` | Hindi | ltr | | `it` | Italian | ltr | | `ja` | Japanese | ltr | | `ko` | Korean | ltr | | `pl` | Polish | ltr | | `pt` | Portuguese | ltr | | `ru` | Russian | ltr | | `tr` | Turkish | ltr | | `ur` | Urdu | rtl | | `zh` | Chinese (Simplified) | ltr | | `zh-TW` | Chinese (Traditional) | ltr | ## Per-column `i18n` data paths `ColumnDef.i18n` maps locales to alternate data paths for the column's **value**. The table `locale` picks the path — exact tag first, then its primary subtag, then `key`: ```tsx // Flat fields: { key: "nameEn", i18n: { ar: "nameAr" } } // Nested objects: { key: "name.en", i18n: { ar: "name.ar" } } ``` The cell, the client-side sort, and the column's declarative filter all follow the resolved path, so searching and filtering match what the user sees. Header **text** stays whatever you pass in `header` — translate it through your label pipeline, not `i18n`. ## RTL RTL is first-class. Pass `dir="rtl"` (or `dir={getDirection(locale)}`) and the adapter applies it through its direction provider and logical CSS: - Layout, alignment, and the filter drawer side flip automatically. - Column pinning is logical: pins use `insetInlineStart`/`insetInlineEnd`, so a "left" pin sticks to the correct edge in RTL too. - Column resizing is direction-aware: the handle sits on the column's inline-end edge, and dragging outward (or pressing the leading arrow key) widens the column in both LTR and RTL. Helpers from `@adapttable/i18n`: - `getDirection(locale)` → `"ltr" | "rtl"` - `isRtlLocale(locale)` — covers ar, he, fa, ur, ps, and more - `RTL_LANGUAGES` — the raw list of RTL primary subtags - `primarySubtag(locale)` — `"ar-EG"` → `"ar"` ## Custom labels `labels` accepts a partial `TableLabels`; missing keys fall back to the English defaults. Count-and-range strings are functions, so any word order works: ```tsx r.id} labels={{ searchPlaceholder: "Search people…", noResults: "Nothing matches your filters", showing: ({ from, to, total }) => `${from}–${to} of ${total}`, pageOf: ({ page, total }) => `Page ${page} of ${total}`, selectedCount: (count) => `${count} selected`, }} /> ``` Need a language without a preset? Spread one and override: ```ts import { en } from "@adapttable/i18n"; const sw = { ...en, search: "Tafuta", noData: "Hakuna data" }; ``` --- # Accessible React data table — keyboard, screen readers, labelled controls ▶ **See it working:** [arrow through a Mantine table and read the live-region transcript](https://orwa-mahmoud.github.io/adapttable/demo/mantine/accessibility/) — Tab in, arrow between cells, and every announcement appears as text. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind. Keyboard walk is documented in [cell navigation](./cell-navigation.md). An accessible data table is one a person can use without a mouse, and one a screen reader can describe. AdaptTable ships that way. There is no `accessible` prop to turn on. **Related:** [Keyboard & cell navigation](./cell-navigation.md) · [i18n & RTL](./i18n-rtl.md) · [Browser and server-built exports](./exporting.md) · [FAQ](./faq.md) ## What is on by default Every table you render already: - uses a real `
` with header and body cells - names every control it draws (Filters, checkboxes, close, Done — not icon-only) - marks sortable headers with `aria-sort` - states the real dataset size when only part of it is in the DOM — a virtualized or paged table carries `aria-rowcount` with each row's absolute `aria-rowindex`, a table whose columns are windowed carries `aria-colcount` with an absolute `aria-colindex` on every body and header cell, and the mobile card list carries `aria-setsize` with each card's `aria-posinset`, so a screen reader says "row 40,001 of 50,000" instead of counting the handful of rows it can reach - says what changed when the rows change — sorting, filtering, paging and page size announce politely through one live region, naming the new order or the new count in the same words the footer shows ("Sorted by Name, ascending", "Page 2 of 4. Showing 26–50 of 87"), because a control that rewrites the table silently gives a screen-reader user no way to tell it worked - honours `prefers-reduced-motion` when rows animate in - honours `forced-colors: active` (Windows High Contrast) and `prefers-contrast: more`: focus, selection, dirty cells, validation errors and find-match highlights each keep an outline, not a fill or a box-shadow, so nothing is signaled by colour alone. Borders, pinned-column edges and overlays use system colors (`Canvas`, `CanvasText`, `Highlight`) Every adapter is audited with `axe` in CI, on desktop and mobile card layouts. ## Try it yourself On the [accessibility demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/accessibility/): 1. Press **Tab** until a cell shows a focus ring. 2. Press the **arrow keys**. The ring moves cell to cell. 3. Open a cell with Enter or a double-click; Escape cancels. If Tab never enters the table or arrows do nothing, that page is failing. Optional row reordering is keyboard-complete: Space lifts, arrows choose a visible target, Space drops, and Escape cancels. Group and tree moves also have a **Move to group…** / **Move under…** menu, so re-parenting never depends on drag precision. Confirm/cancel restores focus to that menu trigger, and a live region announces successful moves, policy rejections, sort conflicts, and cycle guards. Server-built exports are keyboard-complete too. Their kit-native progress surface exposes Cancel while busy, Retry after failure, and a real download link after `{ url }` settles. A polite region announces start, each reported progress value, completion, failure, and cancellation; no progress report stays indeterminate without repeatedly announcing a fake percentage. ## What this page is not The optional spreadsheet grid — one Tab stop, arrow keys through every cell, `role="grid"` — is a separate feature. See [keyboard & cell navigation](./cell-navigation.md). Omit that prop and the grid extras are absent; the default table above still stands. ## High contrast and forced colors Windows High Contrast (and other `forced-colors: active` modes) strips the fills and box-shadows a kit painted. AdaptTable does not ask each kit to re-theme that itself. One stylesheet — `ForcedColorsStyle`, mounted from every adapter — switches those marks to system colors: | Mark | Affordances that survive | | ----------- | -------------------------------------------------------------------------------------- | | Focus | `outline` in `Highlight`, never a box-shadow | | Selection | solid outline (also in the cell style, so it is not CSS-only) | | Find match | dashed outline; the current hit is solid | | Dirty cell | dotted outline — `data-dirty` is never colour-only | | Validation | double outline on `aria-invalid` | | Pinned edge | `CanvasText` border on the pin side | | Overlays | find bar, command palette, saved views, side panel use `Canvas` / `CanvasText` borders | `prefers-contrast: more` thickens the focus outline to 3px. Kits that expose no contrast hook of their own still pick this up: the query is global. The antd adapter keeps its sticky header. The `role="grid"` lives on the wrapper around both of antd's tables so a screen reader walking a cell still finds that cell's `columnheader` in the same grid. ## Notes - Works in all eight adapters. The demo is the same walk on each kit. - Labels you pass through `labels` are the accessible names, including in Arabic and the other [bundled locales](./i18n-rtl.md). --- # Realtime React data table — live row updates, websockets ▶ **See it working:** [watch rows patch in on the Mantine demo](https://orwa-mahmoud.github.io/adapttable/demo/mantine/realtime/) — budgets change while you read them; sort and selection hold. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind. A realtime table is one whose rows update as data arrives — a websocket, a poll, another tab. AdaptTable does not open the socket. You do. When a change lands, you patch the rows you already hold. There is no `realtime` prop. **Related:** [Inline cell editing](./cell-editing.md) · [API](./api.md) · [Data tiers](./data-tiers.md) ## Apply a patch `applyRowPatches` updates the array you pass as `data`. Untouched rows keep their object identity, so React does not redraw the whole page, and scroll, sort, filters and selection survive. ```tsx import { DataTable } from "@adapttable/mantine"; import { applyRowPatches, updateRow } from "@adapttable/core"; export function People({ rows, columns, setRows }) { const byId = (row) => row.id; // Your socket / poll calls this when a row changes. const onMessage = (id, budget) => setRows(applyRowPatches(rows, [updateRow(id, { budget })], byId)); return ; } ``` `insertRow`, `updateRow`, `upsertRow` and `removeRow` build the batch. A later patch sees what an earlier one did. ## Incremental re-evaluation Hand the array `applyRowPatches` returns back as `data` — do not spread it. The result carries a `rowPatchLog`; `useFrontendData` continues the live view and re-runs search, filters, sort, grouping and aggregates for the rows the patch touched, not the whole set. A copy (`[...patched]`) drops the log and rebuilds everything, which is how the scale bench's full-rebuild arm is built. ```tsx setRows(applyRowPatches(rows, [updateRow(id, { budget })], byId)); ``` `createIncrementalView` / `applyRowPatchesToView` / `applyRowPatchLogToView` are the same engine if you hold the snapshot yourself. All eight adapters share it, including the mobile card layout. The scale demo measures both pipelines: `?patch=200` spreads (full rebuild) and `?patch=200&incremental=1` keeps the log. `node scripts/bench.mjs --only patch` prints the burst times. A 2026-08-26 run on this machine was **13.5 s** full rebuild → **9.9 s** incremental (**1.4×**) for 200 updates on 20,000 rows through the live Mantine table. ## Live patches over WebSocket or SSE `@adapttable/core/stream` is a separate entry, so a table that never opens a socket never downloads one. `useRowPatchStream` binds a WebSocket or an SSE endpoint to the rows you already own. Frames become ordinary row patches and go back through your setter — filters, sort, grouping and aggregates happen the way they do for any other change. ```tsx import { useRowPatchStream } from "@adapttable/core/stream"; import { DataTable } from "@adapttable/mantine"; function LiveTable({ initial, columns }) { const [rows, setRows] = useState(initial); const stream = useRowPatchStream({ websocket: "wss://api.example.com/rows", getRowId: (row) => row.id, onPatch: setRows, }); return ( <> {stream.status === "reconnecting" && Reconnecting…} row.id} /> ); } ``` ### The wire format is the patch shape A frame is one patch, or an array of them, as JSON — the same four shapes `applyRowPatches` already takes: ```json [ { "type": "insert", "row": { "id": "9", "name": "Ada" }, "at": 0 }, { "type": "update", "id": "3", "changes": { "status": "active" } }, { "type": "upsert", "row": { "id": "4", "name": "Bo" } }, { "type": "remove", "id": "7" } ] ``` A server that already speaks this needs no translation. One that speaks something else supplies `parse`. **Nothing from the wire is trusted.** A frame that is not JSON, an entry that is not an object, an update with no `changes`, a remove with no id — each is dropped rather than applied. One malformed frame cannot empty a table, and it does not take the connection down either. ### Connection state `status` is one of `idle`, `connecting`, `open`, `reconnecting`, `error` or `closed`, with `error` carrying the reason. `isStreamLive` and `isStreamSettled` are the two questions worth asking: | | | | -------------- | ---------------------------------------------------------- | | `idle` | No url, or `enabled: false`. Nothing is open. | | `connecting` | Opening for the first time. | | `open` | Receiving. | | `reconnecting` | Dropped; a retry is scheduled. | | `error` | Gave up — retries spent, or no socket in this environment. | | `closed` | You closed it. Final. | A dropped **WebSocket** is reopened here, after `reconnect.delayMs` (1000 ms by default) and at most `reconnect.maxAttempts` times. An **EventSource** reconnects on its own, so it is left to do that and simply reported as `reconnecting` — retrying alongside it would give the server two subscriptions for one table. ```tsx useRowPatchStream({ eventSource: "https://api.example.com/rows/stream", event: "patch", // defaults to "message" getRowId: (row) => row.id, onPatch: setRows, }); ``` An authenticated connection, a wrapper, or a test double is `createWebSocket` / `createEventSource`. `openRowPatchStream` is the same connector without React — frames in, status out. `enabled: false` keeps everything idle — nothing is opened, nothing retries. The table never owns your rows. `onPatch` hands you an updater, so it drops straight into a `useState` setter and never races a concurrent update. ## Flash the cells that moved A number that quietly becomes a different number is a number nobody notices. `useChangedCellFlash` from the same `/stream` entry tracks the cells a patch changed and answers `isCellFlashing(rowId, columnKey)`. Pass that into the table; kits set `data-flash` on the cell and on the matching card value, and your stylesheet decides what the pulse looks like — the same seam `data-dirty` already uses. It is off by default. `prefers-reduced-motion` is a hard opt-out of the pulse (unlike `useHighlight`, which still marks the row and holds it steady). An update is diffed, so a field sent back unchanged stays dark. An insert marks the whole row; a remove has no cells left to mark. ```tsx import { useChangedCellFlash, useRowPatchStream, } from "@adapttable/core/stream"; import { rowPatchLog } from "@adapttable/core"; import { DataTable } from "@adapttable/mantine"; function LiveTable({ initial, columns }) { const [rows, setRows] = useState(initial); const flash = useChangedCellFlash({ enabled: true }); useRowPatchStream({ websocket: "wss://api.example.com/rows", getRowId: (row) => row.id, onPatch: (update) => { setRows((prev) => { const next = update(prev); const log = rowPatchLog(next); if (log) flash.mark(log.events); return next; }); }, }); return ( row.id} isCellFlashing={flash.isFlashing} /> ); } ``` ## What this page is not A websocket that changes the row **under an open editor** is a conflict, not this page. That lives under [cell editing](./cell-editing.md#live-update-conflicts). ## Notes - Works in all eight adapters. The demo is the same feed on each kit. - The table never owns your data. A patch is a new array you hand back. - [API reference](./api.md) lists `applyRowPatches`, `applyRowPatchesWithLog` and the patch shapes. --- # AdaptTable API reference — DataTable, columns, hooks The complete public surface. Every symbol ships full TypeScript types and JSDoc, so editor autocomplete mirrors everything below. ## `` props Every adapter (`@adapttable/mantine`, `mui`, `chakra`, `antd`, `radix`, `base-ui`, `shadcn`, `unstyled`) exports `DataTable`. The props below are the shared core surface (`BaseDataTableProps`); kit-specific extras follow in [Adapter extras](#adapter-extras). ### Data | Prop | Type | Default | Description | | ---------- | ------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `source` | `TableSource` | — | Data + state contract from `useFrontendData` / `useQuerySource`; adapters make it optional when you pass `data` instead. | | `rowKey` | `(row: TRow) => string` | — | Stable React key extractor for a row (required). | | `features` | `readonly TableFeature[]` | — | Compose opt-in features from `@adapttable//` subpaths. The import is the switch, and the only way to arm a feature. See [feature composition](./features.md). | Feature factory options are not `DataTable` props. For example, compose `columnMenu()`, `filters(defs)`, `editing(save)` or `virtualize()` in `features`; each feature page documents its options. ### Interactive row grouping Each kit exports `groupingPanel` from `@adapttable//grouping-panel`: ```tsx import { groupingPanel } from "@adapttable/mantine/grouping-panel"; groupingPanel(); groupingPanel(["team", "status"], extras); ``` `groupingPanel(groupBy?, extras?)` owns the ordinary grouping row model, group-header renderers, and the kit-native panel. The optional first argument is a column key or ordered list; the second is `GroupingExtras`. Use plain `grouping()` only for code-fixed grouping with no interactive panel. When `columnMenu()` is also composed, its menu model adds Group by/Ungroup and per-column aggregation choices. ### Columns & layout | Prop | Type | Default | Description | | ----------------------- | ------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `columns` | `ColumnInput[]` | — | Leaf `ColumnDef`s and optional `ColumnGroupDef` parents — see [ColumnDef](#columndef) and [column groups](./column-groups.md). | | `columnLayout` | `ColumnLayoutState` | — | Controlled column layout (hidden/order/pinned/widths). | | `onColumnLayoutChange` | `(next: ColumnLayoutState) => void` | — | Change handler for the controlled column layout. | | `defaultColumnLayout` | `Partial` | — | Initial column layout for the uncontrolled mode. | | `onColumnRename` | `(key: string, name: string) => void` | — | Host persistence channel for a `renameable` column's accepted display name; stable keys never change. | | `maxHeight` | `number` | — | Fixed-height scroll box (px) enabling sideways scrolling + column pinning; omit for page scroll. | | `sortByOptions` | `SortByOption[]` | — | Options for a mobile sort-by select. | | `responsivePriority` | `number` | — | How readily this column is given up when the table is too narrow. Priority 1 is kept longest; omitting it means never dropped. See [mobile](./mobile.md). | | `mobileIdentityColumns` | `number` | `3` | Leading desktop-visible columns kept on mobile even if `hideOnMobile`. | ### Filters & search | Prop | Type | Default | Description | | --------------------------- | ----------------------------------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `filtersMode` | `"popover" \| "drawer" \| "header"` | `"popover"` | Container for a composed `filters()` feature. Popover: anchored card, no backdrop. Drawer: panel + backdrop. Header: per-column icons; hides the Filters button unless the AND/OR tree is on (`toolbarShowsFilters`). | | `closeHeaderFilterOnSelect` | `boolean` | `false` | Close a header-filter overlay after a finished single-control write (a select/boolean value, or a valueless operator such as "Is empty"). Off by default — picking an operator on a field that still has a value input does not dismiss. Outside click and Escape always close. `useHeaderFilterOverlay` / `bindHeaderFilterDismiss` / `headerFilterFieldIsComplete` / `usePointerDismiss` / `HeaderFilterSessionProps` / `HeaderFilterOpenProvider` / `HeaderFilterOpenContext` / `HeaderFilterOpenHost`. | | `filterLabels` | `Record` | — | Per-filter-key chip label resolvers. Declarative `filters()` definitions derive them automatically; needed only for hand-drawn JSX filters (or to override a derived label). | | `extraChips` | `ActiveFilterChip[]` | — | Extra chips driven by non-URL state, merged with the derived chips. | | `activeFilterCount` | `number` | chip count | Override the active-filter count badge. | | `onClearFilters` | `() => void` | — | Clear-filters handler used by the panel + chip strip (built-in `clearExtras` fallback otherwise). | | `searchable` | `boolean` | `true` | Render the built-in search box; pass `false` to hide it. | | `searchPlaceholder` | `string` | — | Placeholder for the search input. | ### Selection & actions | Prop | Type | Default | Description | | ------------------- | --------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `rowActionsLayout` | `RowActionsLayout` | `"buttons"` | How the trailing actions column renders. Omit or `"buttons"` keeps today's horizontal strip. `"menu"` collapses visible actions into a 3-dot menu using each kit's own Menu. Ignored when `renderRowActions` is set. | | `renderRowActions` | `RowActionsRenderer` | — | Replace the trailing actions cell (desktop and mobile cards). Receives the resolved action list (host + built-in duplicate / delete / pin). The column still only appears when there are row actions or row-mode editing. | | `selectionGetId` | `(row: TRow) => string` | `rowKey` | Selection id extractor when it must differ from `rowKey`. | | `selectedIds` | `readonly string[]` | — | Controlled selection; apply `onSelectionChange` requests to your own state. | | `onSelectionChange` | `(selectedIds: string[]) => void` | — | Selection observer (uncontrolled) or change-request handler (controlled). | | `confirm` | `ConfirmHandler` | `window.confirm` | Confirmation handler for actions that declare a `confirm` block. Where no dialog exists (SSR, some webviews), the default DENIES the action and dev-warns — pass your own handler for dialogless environments. | ### Appearance & chrome | Prop | Type | Default | Description | | --------------------------- | --------------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `tableLabel` | `string` | — | Accessible label for the table. | | `labels` | `TableLabels` | English | Pre-translated label overrides; missing keys fall back to English defaults. | | `dir` | `"ltr" \| "rtl"` | `"ltr"` | Text direction. | | `locale` | `string` | — | Active locale tag (e.g. `"ar"`, `"ar-EG"`) driving per-column `i18n` data-path resolution. | | `density` | `"comfortable" \| "compact"` | `"comfortable"` | Row density; each adapter maps it to its kit's table size. With `densityChooser()` composed, omit this to let the feature own uncontrolled state; pass it to control density and pair with `onDensityChange` to observe requests. | | `renderCard` | `(row, card) => ReactNode` | — | Replace a mobile card's body; the shell keeps selection, actions and expansion. See [mobile](./mobile.md). | | `mobileBreakpoint` | `number` | `768` | Width (px) at or below which the card layout takes over. See [mobile](./mobile.md). | | `forceMobile` | `boolean` | viewport | Force the mobile layout instead of resolving from the viewport. | | `toolbar` | `ReactNode` | — | Inline toolbar slot for custom controls (view toggles, etc.). | | `error` | `Error \| null` | `null` | Forwarded error to display in the table's error state (retry via the source's `refetch`). | | `skeletonRows` | `number` | page size | Number of skeleton rows while loading. | | `stickyHeader` | `boolean` | `false` | Keep the desktop table header sticky while scrolling. | | `stickyToolbar` | `boolean` | `stickyHeader` | Keep search and page-size pinned with the header on page-scroll tables. Pass `false` to let the toolbar scroll away. | | `stickyTop` | `number` | `0` | Inset in px for the sticky header (and the sticky toolbar) so they clear an app bar. | | `scrollToTopOnChange` | `boolean` | `true` | Scroll back to the table when search/filter/page changes. | | `scrollTopGap` | `number` | `8` | Extra gap below sticky chrome when scrolling back. | | `isCellFlashing` | `(rowId: string, columnKey: string) => boolean` | — | Mark cells a patch just changed (`data-flash` on the cell and on the matching card value). Pair with `useChangedCellFlash` from `@adapttable/core/stream`. Omit and nothing is marked. See [realtime](./realtime.md). | | `onEditStart` | `EditEventHandler` | — | Observe an editor opening (cell, row or batch). Cannot change the outcome. | | `onEditCancel` | `EditEventHandler` | — | Observe a cancel. Not fired when a successful commit merely closes the editor. | | `onEditCommit` | `EditEventHandler` | — | Observe a value reaching the host, after parse and validation. | | `onValidationFail` | `EditEventHandler` | — | Observe a validator refusing a value; the editor stays open. | | `onEditError` | `EditEventHandler` | — | Observe a save promise rejecting. | | `onEditConflict` | `EditConflictHandler` | — | A row changed under an open editor. Return `"keep"` or `"take"`; omit and `editConflictPolicy` decides. | | `editConflictPolicy` | `"keep" \| "take" \| "ask"` | `"ask"` | What to do when the host does not choose. `"ask"` surfaces Keep mine / Take theirs. | | `rowVersion` | `(row: TRow) => string \| number` | — | Host version of a row. Any change under an open editor is a conflict, not only the edited column. | | `summaryRow` | `(rows: readonly TRow[]) => Partial>` | — | Map the current page's rows to per-column footer summary cells. | | `tableFooter` | `ReactNode` | — | Free slot under the table, above the pager. Not the column-aligned summary row. | | `onGroupByChange` | `(groupBy: readonly string[]) => void` | — | Notified after a grouping change, with the keys as a list. The chrome always applies the change itself; take full control via `source.setGroupBy`. | | `groupAggregates` | `(rows: readonly TRow[]) => Partial>` | — | Per-group aggregate cells — **same signature as `summaryRow`**. Called with each group's leaf rows. | | `collapsedGroupIds` | `readonly string[]` | — | Controlled collapsed group keys (ephemeral — not URL-synced). | | `onCollapsedGroupIdsChange` | `(ids: string[]) => void` | — | Controlled collapse channel; uncontrolled mode uses internal state. | ### Virtualization | Prop | Type | Default | Description | | --------------------- | -------- | ------- | -------------------------------------------------------------- | | `estimateRowSize` | `number` | `56` | Desktop row-size estimate in px. | | `estimateCardSize` | `number` | `132` | Mobile card-size estimate in px. | | `virtualOverscan` | `number` | `8` | Extra rows/cards rendered before and after the virtual window. | | `virtualScrollMargin` | `number` | — | Override for the measured window-mode list offset. | ### URL & persistence URL props (`urlSync`, `urlKey`, `urlAdapter`) and the `data` / `onQueryChange` tiers live on the adapter components, not the core prop surface — see [Adapter extras](#adapter-extras) and [URL-synced state](./url-state.md). The two tiers are typed as `DataModeProps`, and the server one's callback is `TableQueryHandler`: the consolidated query plus the `AbortSignal` for the request it starts. ### Callbacks | Prop | Type | Default | Description | | -------------- | --------------------------------- | ------- | -------------------------------------------------------------------------------------------------- | | `onRowClick` | `(row: TRow) => void` | — | Row activation on click/Enter; interactive children (actions, checkboxes, links) never trigger it. | | `onRowsChange` | `(rows: readonly TRow[]) => void` | — | Called whenever the materialized source rows change. | | `prefetch` | `(row: TRow) => void` | — | Hover-prefetch callback fired on desktop row mouse-enter. | ## Feature composition `features={[rowReorder(fn, options)]}` from `@adapttable//row-reorder` (or the `@adapttable//features` barrel, or `@adapttable/react/features`). The import is the switch, and it is the only way in — see [feature composition](./features.md) and, for a v2 table, [upgrading from v2](./migrate-from-v2.md). Types: `TableFeature` · `TableFeatureHost` · `FeaturePatch` · `FeatureApplyInput`. The merge is `applyTableFeatures`. Live registration is `setup(host)` on the same `TableFeature`: `registerFilterType`, `extendFilterType`, `registerEditor`, `registerAggregator`, `registerWriter`, `registerColumnMenuAction`, `registerPanel`, `registerCommand`, `registerContextMenuItems`, `onDispose`. Adapters run this through `useTableFeatures`. The host belongs to that table: `featureHostOf` / `rememberFeatureHost` thread it into chrome, `FeatureHostProvider` / `useFeatureHost` hand it to hooks in the tree, and `bindFeatureHostFn` scopes a mapper (summary, group aggregates) to the table that invoked it. `RowOf

` derives the row type from a wrapper's `rowKey` prop so adapter feature hosts retain the consumer's row contract. A feature whose behaviour is a hook carries a `provider` instead — `FeatureProviderContribution`, whose component receives `FeatureProviderProps` and wraps the table. `FeatureProviders` nests them in feature-id order. It publishes through `FeatureStateScope` under a `FeatureStateKey` from `featureStateKey`, and anything below reads it with `useFeatureState`. A provider sits above the chrome, so it reads the live rows and labels through `useTableRuntime` (`TableRuntime`, `TableRuntimeView`) rather than being handed them (`featureIds()`, optional `query` for page/search/sort); chrome offers them with `usePublishTableRuntime`. A feature also fills named positions: `renders` is a list of `FeatureRender` entries, each pairing a `FeatureSlotKey` from `featureSlotKey` with what to draw — built with `slotRender`, which keeps the props typed — chrome asks through `FeatureSlot`, and the positions themselves are named constants: `STATUS_BAR` (the footer strip, filled by `statusBar` or `selectionStats` from `@adapttable//status-bar` — a single slot, so the pair share one element and it draws once), `FIND_BAR` (`findInTable` from `@adapttable//find-in-table`), `BATCH_EDIT_BAR` (`batchEditing` from `@adapttable//batch-editing`), `COMMAND_PALETTE` (`commandPalette` from `@adapttable//command-palette`), `CONTEXT_MENU` (`contextMenu` from `@adapttable//context-menu`), `SIDE_PANEL` (`sidePanel` from `@adapttable//side-panel`), `COLUMN_MENU` (`columnMenu` from `@adapttable//column-menu`), `BULK_BAR` (`bulkActions` from `@adapttable//bulk-actions`), and `ACTIVE_FILTER_CHIPS` plus `FILTERS_FORM` (`filters` from `@adapttable//filters` — one feature fills both the chip strip and the panel body). And `useFeatureSlotFilled` says whether anyone answered. All from `@adapttable/react/adapter`; see [feature composition](./features.md). Column-name controls stay kit-owned through `ColumnMenuSlotProps.onRenameColumn` and the optional `COLUMN_HEADER_RENAME` render slot (`ColumnHeaderRenameSlotProps`). `useColumnRenameEditor` (`UseColumnRenameEditorOptions` in, `ColumnRenameEditorState` out) centralizes trim/validation, Escape cancellation, focus restoration and the commit announcement without drawing an input in core. `SharedTableRenderProps.onRenameColumn` carries the same commit channel to semantic-table header controls; Ant Design intentionally uses the menu path. Adapter authors bind those slots without copying feature lifecycle through `createAdapterEditingFeatures`, `createAdapterFiltersFeature`, `createAdapterGroupingFeature`, `createAdapterRowDetailFeatures`, `createAdapterRowReorderFeature`, `createAdapterContextMenuFeature`, `createAdapterCommandPaletteFeature` and `createAdapterAgentApprovalFeature`. Each accepts kit-owned `AdapterFeatureComponent`s and returns ordinary feature factories. Their contracts are `AdapterEditingComponents` — whose `EditableCell` is handed `EditableCellRenderProps`, the slot's props with the display already worked out from the row, the column's `Cell` or its accessor, so no kit repeats that rule — `AdapterEditingFeatures`, `AdapterFiltersComponents`, `AdapterFiltersFeature`, `AdapterGroupingComponents`, `AdapterGroupingFeature`, `AdapterRowDetailComponents`, `AdapterRowDetailFeatures`, `AdapterRowReorderComponents`, `AdapterRowReorderFeature`, `AdapterContextMenuFeature` and `AdapterCommandPaletteFeature`; normalized `AdapterContextMenuProps` / `AdapterCommandPaletteProps` types keep the seam typed. `createAdapterStandardFeatures` takes `AdapterStandardFeatureFactories` and returns a `StandardFeaturesFactory` using `StandardFeatureOptions`; individual feature imports remain independent. `ContextMenuLiveGate` and `OptionalSidePanel` are the two invariant root-layout helpers. None imports a kit or belongs on the app-facing core entry. Chrome asks for the rest of a kit's parts the same way, one slot per part. Around a cell: `EDITABLE_CELL` (`EditableCellSlotProps`, the editor and the dirty mark), `FILL_HANDLE` (`FillHandleCellSlotProps`), `EXPAND_TOGGLE` (`ExpandToggleSlotProps`), `TREE_CELL` and `TREE_TOGGLE`, `COLUMN_SELECT`, `COLUMN_GROUP_TOGGLE` and `FILTER_HEADER`. Around a row: `ROW_EDIT_ACTIONS`, `ROW_REORDER_HANDLE`, `ROW_REORDER_BUTTONS` and `ROW_REORDER_ANNOUNCER`, `GROUP_HEADER_ROW` (`GroupHeaderRowSlotProps`) and its mobile card `GROUP_HEADER_CARD` (`GroupHeaderCardSlotProps`). Around the table: `TOOLBAR_EXTRAS` (`ToolbarExtrasSlotProps` — undo/redo, export, print, density, fullscreen), `SAVED_VIEWS` (`SavedViewsSlotProps`), `GRID_FOCUS_ANNOUNCER`, the filter surfaces `FILTER_POPOVER` / `FILTER_DRAWER` (`FilterOverlaySlotProps`) with `FiltersFormSlotProps` and `ActiveFilterChipsSlotProps`, and `CHROME_BODY` (`ChromeBodySlotProps`), which `virtualize` fills with the windowed body a plain table never downloads — `ChromeBodyGate` is what asks for it, falling back to the plain body. A kit that assembles its own table body — antd renders through its own `

` — asks `KEYED_WINDOW` (`KeyedWindowSlotProps`) for a window over a keyed list instead, so the virtualizer stays behind the same import there too. A kit dresses a core feature with its own parts through `extendFeature`. The heavy pieces of body assembly a feature can replace — cell spanning, extra rows, the resize handle — are typed as `AssemblyFns`, and `virtualize` takes `VirtualizeOptions`. A feature whose behaviour is a hook rather than a component fills a **live** slot instead: the hook mounts inside the table and hands its state back down, so a table that never imported the feature never carries it. Chrome-shaped state arrives through `ChromeExtrasGate` (`ChromeExtraSlotProps`) — `COLUMN_LAYOUT_LIVE`, `FILTER_CHIPS_LIVE`, `GROUPING_LIVE`, `TREE_LIVE`, `SELECTION_LIVE`, `ROW_ACTIONS_LIVE`, `PINNING_LIVE`, `EXPANSION_LIVE` and `EDITING_LIVE` — and interaction state through `HistoryLiveGate` (`EDIT_HISTORY_LIVE`, `EditHistoryLiveSlotProps`) and `ShellLiveGate`: `FIND_LIVE` (`FindLiveSlotProps`), `CELL_NAV_LIVE` (`CellNavLiveSlotProps`), `EXPORT_LIVE` (`ExportLiveSlotProps`), `FULLSCREEN_LIVE` (`FullscreenLiveSlotProps`), `SELECTION_STATS_LIVE` (`SelectionStatsLiveSlotProps`), `COMMAND_PALETTE_LIVE` and `CONTEXT_MENU_LIVE` (`ContextMenuLiveSlotProps`). An unfilled live slot passes an inert stand-in through, so the same chrome renders either way. Factories: `feature` (ad-hoc) · `rowReorder` · `rowPinning` · `cellSpan` · `extraRows` · `rowAppearance` · `rowDetail` · `nestedTable` · `editing` · `rowEditing` · `batchEditing` · `editHistory` · `dirtyIndicators` · `grouping` · `tree` · `virtualize` · `columnMenu` · `resizableColumns` · `collapsibleColumnGroups` · `exportCsv` · `cellNavigation` · `findInTable` · `fullscreen` · `commandPalette` · `contextMenu` · `sidePanel` · `bulkActions` · `filters` · `filterTypes` · `headerFilters` · `savedViews` · `selectionStats` · `densityChooser` · `print` · `statusBar` · `undoRedoButtons` · `multiSort` · `fitColumns` · `columnSelectionCheckbox`. Kit `/pivot` re-exports `PivotPanel` plus the `@adapttable/core/pivot` engine. ## ColumnDef | Prop | Type | Default | Description | | ---------------- | ---------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `key` | `string` | — | Unique id (required); also the backend `sortBy` value and — absent `accessor`/`Cell` — the row's dot-path for the cell value. | | `header` | `ReactNode` | humanized key | Header content; omit it and the header derives from `key` (`"hiredAt"` → "Hired At"). | | `renderHeader` | `(ctx: ColumnHeaderContext) => ReactNode` | — | Replace the header caption. The cell still owns sort, resize and the menu; `ctx.controller` exposes them. | | `renderFooter` | `(ctx: ColumnFooterContext) => ReactNode` | — | Replace one summary-row cell. `value` is the `summaryRow` result for this key. | | `headerTooltip` | `string` | — | Native tooltip on the header caption. | | `renameable` | `boolean` | `false` | Offer the kit-native Rename column action when the table also has `onColumnRename`. | | `headerActions` | `ReactNode` | — | Host controls after the caption, before the resize handle. | | `group` | `string \| readonly string[]` | — | Presentational header group shortcut. A string is one level; a path stacks rows. Contiguous same-path columns merge; a reorder splits the group. Prefer a `ColumnGroupDef` with `children` when the group has collapse options. See [column groups](./column-groups.md). | | `groupShow` | `ColumnGroupShow` (`"open" \| "closed" \| "always"`) | `"open"` | When this leaf sits under a collapsible group: expanded only, collapsed only, or both. | | `i18n` | `Record` | — | Per-locale data paths for the column's value (`{ key: "nameEn", i18n: { ar: "nameAr" } }`); cell, client-side sort and filter follow the resolved path. | | `filter` | `ColumnFilter` | — | Declarative filter for this column: a bare type (`"dateRange"`) or a definition without `key`/`label`. | | `Cell` | `ComponentType>` | — | Component rendered per row (receives `{ row, rowIndex }`); define at module level so its identity is stable. | | `accessor` | `(row: TRow) => ReactNode` | — | Lightweight alternative to `Cell`; returns cell content. | | `sortValue` | `(row: TRow) => SortableValue` | — | Primitive extractor used by the client-side sort comparator; unused for server-sorted data. | | `exportValue` | `(row: TRow) => unknown` | — | Value written to a CSV export when the file should carry something other than the formatted cell (a number rather than `"$1,240.00"`). | | `formatValue` | `(row: TRow) => string` | derived | The cell as plain text, for contexts that cannot render JSX — screen-reader announcements, `aria-label`, tooltips, the clipboard. | | `parseValue` | `(draft: string, row: TRow) => unknown` | — | Turns an edited draft into the value committed by `editing()`. See [cell editing](./cell-editing.md). | | `sortable` | `boolean` | `false` | Enable sorting for this column. | | `colSpan` | `number \| ((row: TRow) => number)` | `1` | Columns this cell covers. Covered neighbours are omitted. See [row and column spanning](./row-spanning.md). | | `rowSpan` | `number \| ((row: TRow) => number)` | `1` | Rows this cell covers. Stays inside one tbody. | | `width` | `number \| string` | — | Column width passed through to the rendered header/cell. | | `align` | `"start" \| "center" \| "end"` | `"start"` | Text alignment within the cell. | | `mobileLabel` | `string` | `header` | Label used on mobile card layouts; falls back to a string `header`. | | `hideOnMobile` | `boolean` | `false` | Hide this column entirely on mobile layouts. | | `hideOnDesktop` | `boolean` | `false` | Hide this column entirely on desktop layouts. | | `lockPosition` | `boolean` | `false` | Gray out the column menu's reorder grip. | | `lockVisibility` | `boolean` | `false` | Gray out the column menu's show/hide control. | | `lockWidth` | `boolean` | `false` | Gray out resize and per-column auto-size. | | `lockPin` | `boolean` | `false` | Gray out the column menu's pin control. | | `editable` | `boolean \| ((row: TRow) => boolean)` | — | Opt-in cell editing for this column (requires the `editing()` feature; omit either and nothing changes). | | `editor` | `"text" \| "number" \| { type: "select"; options }` | `"text"` | Widget for the active cell when `editable` is set. | | `editValue` | `(row: TRow) => string` | — | Draft seed when display formatting differs from the value you want to edit. | | `meta` | `Record` | — | Arbitrary metadata adapters (or your own code) may read back. | ## ColumnGroupDef A parent header with `children`. Collapse options live here, not on the table. | Prop | Type | Default | Description | | ----------------- | ------------------------------ | ---------- | ------------------------------------------------------------------------------------------------------- | | `header` | `string` | — | Caption on the spanning header cell, and the group's id. | | `children` | `ColumnInput[]` | — | Nested groups or leaf columns. | | `collapsedKey` | `string` | — | Leaf `key` to keep when this group is collapsed. Omit with `collapsedRender` omitted for an arrow stub. | | `collapsedRender` | `(row: TRow) => ReactNode` | — | Cell shown for every row while collapsed. Takes precedence over `collapsedKey`. | | `marryChildren` | `boolean` | `true` | Keep these children adjacent through reorder. The flat `group` shortcut still splits on drag. | | `align` | `"start" \| "center" \| "end"` | `"center"` | Spanning header alignment. Omit and it stays `"center"` — the previous hardcoded look. | | `headerTooltip` | `string` | — | Optional native tooltip. The collapse chevron does not show one. | `ColumnInput` is `ColumnDef \| ColumnGroupDef`. See [column groups](./column-groups.md). ## FilterDef | Prop | Type | Default | Description | | ------------- | ------------------------ | --------------- | -------------------------------------------------------------------------------------------------------------------------------- | | `key` | `string` | — | State key in the filter bag and the `f_` URL param (required); doubles as the row's dot-path for the client-side predicate. | | `column` | `string` | `key` | Column the header filter row places this widget under, when the bag key and the column key differ. | | `type` | `string` | — | Built-in `FilterType` or a custom type registered by `filterTypes()`. | | `label` | `string` | humanized `key` | Widget + chip label. | | `options` | `FilterOptionsSource` | — | Choices for `select`/`multiSelect`/`checklist` labels: a static `FilterOption[]`, `"auto"`, or an async loader. | | `getValue` | `(row: TRow) => unknown` | `key` as path | Row-value extractor for the client-side predicate. | | `placeholder` | `string` | — | Placeholder for text-like inputs. | Range types persist two inclusive state keys: `dateRange` → `${key}From`/`${key}To`, `numberRange` → `${key}Min`/`${key}Max`. Every operator-first widget also writes `f_Op` (`TEXT_OPS` / `NUMBER_OPS` / `DATE_OPS`) so the comparison survives the URL and Saved Views. Headless: `readRangeWidget`, `writeRangeWidget`, `writeRangeFilter`, `useTextFilterWidget`, `useRangeFilterWidget`, `useBooleanFilterWidget`. A `boolean` filter is any / true / false (`f_=true|false`); omitting the param is any. A `dateRange` `relative` operator stores a token (`today`, `last:7`, …) in `${key}From` — never a resolved calendar day — and `resolveRelativeRange` is the only place that token becomes a window. An AND/OR tree lives in `ft=1.{…}` (`parseFilterTree` / `serializeFilterTree`); the frontend ANDs it with the flat bag, and a server that sets `supports.filterTree` receives `query.filterTree`. Each adapter mounts `FilterTreeBuilder` (`FilterTreeBuilderProps`) as the panel UI over that tree — kit controls, same part names. The shared layout (no form controls) is `FilterTreeChrome` / `FilterTreeChromeProps` / `FilterTreeClassNames` / `FilterTreeSlots` / `FilterTreeSelectProps` / `FilterTreeInputProps` / `FilterTreeButtonProps` / `FilterTreeDisclosureProps` / `FilterTreeOption` on `@adapttable/react/adapter`. `filterDefs` lets the chrome label tree chips. A `checklist` filter is the Excel-style distinct-values widget (`ChecklistFilter` / `ChecklistFilterProps` / `useChecklistFilter` / `collectChecklistValues`); adapters draw it. The shared layout is `ChecklistChrome` / `ChecklistChromeProps` / `ChecklistClassNames` / `ChecklistSlots` / `ChecklistSearchProps` / `ChecklistButtonProps` / `ChecklistCheckboxProps`. It prefers `source.facets` (own-filter excluded via `computeFilterFacets` / `rowsExcludingFilter` / `FacetMap` / `FacetCounts`) and falls back to `allFilteredRows`. A server that sets `supports.facets` receives `query.facets` and returns the same map on the page (`PaginatedResponse.facets`, `PageSelector.facets`). Without either surface the widget stays hidden. `headerFilters()` selects `filtersMode="header"` (`resolveFilterMode` / `FilterChromeMode` / `toolbarShowsFilters`): each adapter mounts a per-column filter icon (`FilterHeaderTrigger`) on the same extra bag and hides the toolbar Filters button unless `source.setFilterTree` is set, so the AND/OR tree still has a chrome. The shared layout is `FilterHeaderChrome` / `FilterHeaderControlChrome` / `FilterHeaderChromeProps` / `FilterHeaderControlChromeProps` / `FilterHeaderClassNames` / `FilterHeaderSlots` / `FilterHeaderSearchProps` / `FilterHeaderSelectProps` / `FilterHeaderRangeProps` / `FilterHeaderMultiProps` / `FilterHeaderOption` on `@adapttable/react/adapter`. Helpers `filterDefForColumn` / `headerFilterStickTop` stay on core. Nested kit dropdowns (Select, DatePicker) are not "outside" — the overlay stays open until a true outside click, Escape, or (when `closeHeaderFilterOnSelect` is on) a finished single-control write (`useHeaderFilterOverlay` / `bindHeaderFilterDismiss` / `headerFilterFieldIsComplete` / `usePointerDismiss` / `HeaderFilterSessionProps` / `HeaderFilterOpenProvider` / `HeaderFilterOpenContext` / `HeaderFilterOpenHost`). Desktop only. Never stacked with the popover or drawer. `filterTypes()` merges `FilterTypeSpec`s onto `defaultFilterRegistry` (`builtInFilterSpecs` / `resolveFilterRegistry` / `createFilterRegistry` / `emptyFilterRegistry`). A spec supplies widget (`FilterWidgetKind`), operators, predicate, chips, tree projection, and optional `render` (`FilterWidgetRenderProps`). `FilterTypeRegistry.register` / `extend` and the `filterTypes` prop are not v3 APIs — register with `TableFeatureHost.registerFilterType` / `extendFilterType` in `feature.setup(host)`, or `features={[filterTypes(specs)]}`. Lookups: `filterTypeSpec` / `filterWidgetKind` / `filterTypeOps` / `filterTypeDefaultOp` / `renderRegisteredFilter`. ## Adapter extras Props beyond the core surface, with per-kit availability. | Prop | Type | Default | Available on | Description | | --------------------------- | ---------------------------------------------------- | -------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `data` | `readonly TRow[]` | — | all | Frontend tier: raw rows the table filters/sorts/pages; with `onQueryChange` it is the current server page. | | `total` | `number` | — | all | Server tier: total row count across all pages (drives the pager). | | `loading` | `boolean` | — | all | Server tier: a request is in flight. | | `onQueryChange` | `(query: TableQuery, info: { signal, key }) => void` | — | all | Server tier: fired with the consolidated query whenever it changes (mount included); fetch and hand back `data` + `total`. `info.key` identifies the request. | | `responseKey` | `string` | — | all | Server tier: the `onQueryChange` `info.key` the current `data` answers, so aggregate metadata belongs to the rows on screen. | | `aggregates` | `readonly QueryAggregate[]` | — | all | Server tier: aggregates to send as `query.aggregates`; requires `supports.aggregates`. Reader overrides layer over these. | | `supports` | `QuerySupport` | — | all | Server tier: capabilities this endpoint answers. `supports.facets` unlocks `query.facets`. | | `facetKeys` | `readonly string[]` | checklist keys | all | Server tier: keys sent as `query.facets`. Defaults to every `checklist` definition. | | `facets` | `FacetMap` | — | all | Server tier: distinct-value counts from the last fetch, surfaced on the source for the checklist. | | `closeHeaderFilterOnSelect` | `boolean` | `false` | all | Close a header-filter overlay after a finished single-control write. Off by default so an operator pick on a multi-input field stays open. | | `urlKey` | `string` | — | all | Namespace for this table's URL params (`urlKey="left"` → `left.q`, `left.page`, …). | | `urlAdapter` | `UrlStateAdapter` | History API | all | URL-state backend for the `data`/`onQueryChange` tiers (router adapter, `createMemoryAdapter()` in tests). | | `urlSync` | `boolean` | `true` | all | `false` keeps all state in memory — the address bar never changes, any `urlAdapter` is ignored. | | `slots` | `{ skeleton?, empty?, noResults?, error? }` | — | all | Replace sub-components. `empty` covers both empty states; `noResults` overrides just the filtered one; `error` takes a node or a `(state) => node` receiving the error and its retry (see customization). | | `classNames` | `DataTableClassNames` | — | mantine, chakra, radix, base-ui, unstyled | Per-part class overrides — five parts on Mantine/Chakra/Radix/Base UI (`root`/`toolbar`/`table`/`card`/`footer`), every part on unstyled. | | `className` | `string` | — | mui, antd | Class name applied to the root wrapper. | | `animate` | `boolean` | `false` | all | Animate rows/cards on mount (dependency-free; honors reduced motion). | | `size` | kit-specific union | — | chakra, antd, radix, base-ui, bootstrap | Explicit kit table size, overriding the density mapping. MUI uses `density`; the listed kits retain native values density cannot express. | | `accentColor` | kit accent union (chakra: `string`) | — | chakra, radix, base-ui | Accent color for primary controls (buttons, badges, active page). | | `bordered` | `boolean` | `false` | antd | Render the table with cell borders. | Mantine also publishes the spacing its density mapping uses, as `DENSITY_SPACING` (`DensitySpacing`) from `@adapttable/mantine/density`, so a host can match its own chrome to the table's rows. Each adapter also re-exports the core source builders and types, so one import path covers everything. ## Headless hooks All from `@adapttable/core`. ### Data sources - `useFrontendData(options): TableSource` — in-memory source: filters, sorts, and slices a raw array from URL state. Pass `filterTreeFn` (usually `evaluateFilterTree`) to apply an AND/OR tree. `getRowId` (default `defaultFrontendRowId`) matches `applyRowPatches` so a `rowPatchLog` can continue the live incremental view. - `useQuerySource(options): TableSource` — wraps your `useInfiniteQuery`-style hook into the same contract. - `useServerData(options): TableSource` — hand-rolled-fetch server tier: emits one consolidated `TableQuery` per change, aborting superseded requests via `AbortSignal`. - `useTableData(options): { source, runtime }` — tier resolution (source ▸ server ▸ frontend) plus the declarative filter runtime; what every adapter calls internally. ### URL state & persistence - **The codecs themselves**, for reading and writing the query string without a table — see [URL state](./url-state.md#reading-and-writing-the-params-yourself). `parseTableUrlState(search)` reads a whole query string; `updateTableUrlState(search, patch)` returns the next one; `applyTableUrlState` / `captureTableUrlState` move that state on and off a live table. Per param: `PARAM_PAGE`, `PARAM_LIMIT`, `PARAM_SEARCH`, `PARAM_FIND`, `PARAM_SORT_BY`, `PARAM_SORT_DIR`, `PARAM_GROUP_BY`, `PARAM_GROUP_AGGREGATES`, `PARAM_COL_HIDDEN`, `PARAM_DENSITY`, `PARAM_FORMULA`, `PARAM_PIVOT`, with `readPage`, `readLimit`, `readSortDir`, `readSortLevels` / `writeSortLevels`, `readColumnLayout` / `writeColumnLayout`, `readExtra` / `writeExtra`, `readFilterTreeParam` / `writeFilterTreeParam`, and `readRowPins` / `writeRowPins`. - `useTableUrlState(options?): UseTableUrlStateResult` — page / limit / search / sort / grouping / group-aggregation overrides / extra-filter bag in the query string, with setters (`setPage`, `setSearch`, `setSort`, `toggleSortLevel`, `setExtra`, `setExtras`, `setFilterTree`, `setGroupBy`, `setGroupAggregateOverrides`, `clearExtras`, `clearAll`). Group keys use `groupBy`; overrides use `groupAgg`. - `useColumnLayoutUrlState(options?): { layout, onLayoutChange }` — URL-persisted column layout (hidden / order / pinned / widths / names). - `useColumnLayoutStorageState(options): { layout, onLayoutChange }` — the localStorage counterpart (user preference rather than shareable link). - `useSavedViews(options: UseSavedViewsOptions): { views, save, apply, remove }` — named snapshots of this table's URL params, persisted to storage. - `createHistoryAdapter()` / `createMemoryAdapter(initial?)` / `getHistoryAdapter()` → `UrlStateAdapter`. ### Rendering & orchestration - `useDataTable(options): UseDataTableResult` — derived state + prop-getters: `getTableProps`, `getHeaderRowProps`, `getHeaderCellProps`, `getSortButtonProps`, `getRowProps`, `getCellProps`, `getSearchInputProps`. - `useTableChrome(props): TableChrome` — shared adapter orchestration: layout, confirm, chips, body region (`emptyVariant`, `isRefreshing`), `clearFilters`, footer. - `useVirtualChromeBodyData(chrome, props): ChromeBodyData` — body data-flow wiring: window virtualization + the infinite-scroll sentinel. `usePlainChromeBodyData` is the same shape without the virtualizer, for a table that never composed `virtualize`; `VirtualItemMeta` is one windowed entry and `virtualColumnSpan` is the span a windowed row's spacer cells cover. - `useDataTableShell(props, renderAutoForm): DataTableShellResult` — the whole adapter shell in one call: resolved tier, chrome, and the `DataTableShellTableProps` / `DataTableShellToolbarProps` bundles a kit spreads, plus `DataTableShellChromeProps` and `DataTableShellGroupingPanelProps`. `DataTableShellView` mounts the gates below it and hands back the finished view; `finishDataTableShell` folds a body into a shell for an adapter that assembles its own. - `useColumnLayout(options): UseColumnLayoutResult` — headless visibility / order / pinning / width / name / collapsed-group state (`visibleColumns`, `toggleVisible`, `move`, `setPinned`, `setWidth`, `setName`, `resetName`, `pinOffset`, `reset`, `toggleColumnGroup`). - `useSearchInput(...)` — debounced search-input state behind `getSearchInputProps`. - `useSelection(options): SelectionState` — page-scoped selection with select-all and cross-page "all matching". - `useRowExpansion(): RowExpansionState` — multi-open row expansion keyed by row id. - `useActiveFilterChips(options)` / `useExtraChips(options)` — removable chips from URL filter state / from non-URL state. - `useFilterOptions(def): { options, loading }` — resolves static, `"auto"`, and async filter-option sources for custom forms. - `useBulkActionRunner(options): BulkActionRunner` — runs bulk actions through the confirm handler. - `useColumnDragState()` — drag-reorder state for custom column menus. - `useHorizontalOverflow()` — scroll-overflow detection for pinned-column affordances. - `useFilterTriggerToggle()` / `useChromeScrollReset()` — filter-trigger open state / scroll reset on query change. ### The neutral engine Framework-free — see [concepts](./concepts.md#the-engine-and-why-it-has-no-react-in-it). - `createTableEngine(options: CreateTableEngineOptions): TableEngine` — build an engine over in-memory rows. Filter, sort, page and group run here, with no React in the import graph. - `TableEngine` — the handle: `snapshot()`, `rows(scope)`, `dispatch(op)`, `configure(patch)`, `invalidate(axes, next)`, `subscribe(axes, listener)`, `cellValue`, `rowByKey`, `rowKey`, `getColumn`, `dispose()`. - `TableEngineReader` — the read half: `snapshot()`, `rows(scope)`, `rowByKey`, `cellValue`, `getColumn`. The engine is one, and so is the candidate below. - `stageCandidate(patch, next?)` / `candidate` / `commitCandidate()` / `discardCandidate()` — a private candidate a binding builds while it renders. `snapshot()`, `rows()`, the revision tokens and every subscriber stay on the committed state until `commitCandidate()` publishes it, so a render that suspends or is abandoned changes nothing anyone else can read. Tokens move only where the columns, the rows or the view values actually differ: adopting a fresh `data` array or a new `rowKey` closure is not news. Any other write — a `dispatch`, an `invalidate` — takes the candidate with it. React hosts get this through `useTableEngine` and `useFrontendData` and never call it. - `TableSnapshot` — query state plus `page` (the page actually shown), `requestedPage`, `lastPage`, `total` and `revisions`. - `TableOperation` — a person's action: `setSort`, `setSearch`, `setPage`, `setLimit`, `setFilters`, `setGroupBy`, `setSelection`. - `TableRowScope` — `"page"`, `"visible"` or `"full"`. - `TableRevisions` / `TableRevisionAxis` — the four counters (`data`, `view`, `schema`, `policy`) and the axis names you subscribe to. - `cellValue(row, column, locale?)` — resolve one cell's value, following a column's `i18n` path. - `createNeutralTable(engine, tableId, binding?): NeutralTable` — wrap an engine as the shape `@adapttable/ai` reads: rows, revisions, and an `operations` map of what is wired right now. `NeutralTableBinding` is how a host supplies the visible rows and that map. - `devWarn(message)` — development-only warning, printed once per message. ### The builder tier `@adapttable/react/adapter` publishes what the eight kits are made of, for anyone wiring a ninth. App code rarely reaches for these; each is here because an adapter or a plugin genuinely needs it. | Name | What it is | | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `useTableEngine(options)` | Subscribe a React tree to one `TableEngine`. Two calls are two isolated tables. | | `revisionToken(revisions)` | Collapse the four revision counters into one comparable string. | | `engineSearchText(row)` / `cellSortValue` / `resolveColumnPath` | The engine's default searchable text, its sort-value resolution, and a column's `i18n` path. | | `FeatureRegistration` / `NeutralFeatureHost` / `currentFeatureHost()` / `runWithFeatureHost` | How a feature registers with the host, and the ambient host a registration runs against. | | `collectFeatureNotices(input)` / `CollectFeatureNoticesInput` | The dev-time notices a table raises when a feature is asked for something it cannot do. | | `groupedEntriesForStrategy(options)` / `GroupedEntriesForStrategyOptions` / `groupingComputationKind` / `GroupingComputationKind` | Which grouping computation a view needs, and the entries it produces. | | `groupingDragKey` / `moveGroupingKey` / `moveGroupingKeyBy` / `removeGroupingKey` / `hasGroupingColumnDrag` / `GROUPING_COLUMN_DND_MIME` | Reordering the grouping panel's keys by pointer or keyboard. | | `EMPTY_COLUMN_LAYOUT` / `applyColumnOrder` / `applyColumnNames` / `declaredColumnLayout` / `declaredColumnName` | The column-layout value and the pure functions that move it. | | `MIN_COLUMN_WIDTH` / `MAX_COLUMN_WIDTH` / `FALLBACK_PIN_WIDTH` | Resize clamps, and the width a pin assumes when a column declares none. | | `responsiveColumns(...)` / `ResponsiveColumns` / `ResponsiveFit` | Which columns survive at a given width, and what had to be dropped. | | `flattenReactColumnTree` / `resolveNeutralColumnHeaders` / `columnLetter` | Flatten a grouped column tree, resolve neutral headers, and the spreadsheet letter for an index. | | `resolveAssembly(...)` | The desktop table assembly a kit renders from. | | `readEditableCellValue` / `stepEditableCell` | Read the value under an editable cell, and step the editor to the next one. | | `contextMenuItems(...)` / `ContextMenuModelOptions` | The context-menu model, before a kit draws it. | | `RowPinLookup` / `RowReorderDigest` | Pin membership by row id, and the memo digest a reorder compares. | | `FilterEngine` / `FILTER_ENGINE_IMPL` / `FILTER_PREFIX` / `withFilterType` / `isEmptyFilterValue` / `applyFilterExtends` | The filter engine behind the declarative filters, its param prefix, and the helpers that extend it. | | `applyQuerySupport` / `applyGroupLeafSelection` / `appendByKey` | Narrow a query to what an endpoint supports, apply a leaf selection, append keyed entries. | | `DEFAULT_ROW_SIZE_PX` / `MOBILE_BREAKPOINT_PX` / `VIRTUAL_OVERSCAN` / `MAX_LIMIT` | The row-height estimate, the mobile breakpoint, the virtualizer overscan, and the page-size ceiling. | | `CssProperties` / `ElementRef` / `isRtlElement` / `isBrowser` / `safeLocalStorage` / `safeSheetName` / `resetDevWarnings` | Style and environment helpers: a style object, a ref that may be a callback, direction, environment probes, a spreadsheet-safe sheet name, and the dev-warning reset a test calls. | | `AgentHttpError` / `openAiToolNameMap` / `FakeSocket` | The HTTP bridge's structured failure, the reversible OpenAI tool-name map, and the in-memory socket the realtime tests drive. | ### Utilities - `useTableVirtualization(options): TableVirtualization` — headless row/card windowing: page scroll by default, element scroll inside a `maxHeight` box. - `useInfiniteScroll(options)` — IntersectionObserver sentinel ref that auto-loads the next page in infinite mode. - `useScrollToTableTop(options)` — sticky-chrome-aware scroll restoration. - `useDebounce(value, ms)`, `useMediaQuery(query)`, `useIsMobile()`, `usePrefersReducedMotion()`, `useColorScheme(preference)`. - `useExportHandler(handler)` — binds the Export button: makes a host-handled export single-flight and reports `exportBusy`. From `@adapttable/react/adapter`. Building blocks for columns, rows and queries: - `computed({ key, deps, value, format })` — a derived column, cached per row, consistent across display, sorting, filtering and export. See [columns](./columns.md). - `aggregate(spec, options)` — builds the `summaryRow` / `groupAggregates` mapper. See [row grouping](./row-grouping.md). - `applyRowPatches(rows, patches, getRowId)` with `insertRow` / `updateRow` / `upsertRow` / `removeRow` — apply changes without a refetch, preserving row identity. `applyRowPatchesWithLog` returns the `RowPatchLog` / `RowPatchEvent`s; `rowPatchLog` reads the log `applyRowPatches` attaches (spreading the array drops it). See [cell editing](./cell-editing.md). - `tableQueryKey(query, options)` / `tableQueryBaseKey(query, options)` — stable cache keys for TanStack Query or SWR. See [data tiers](./data-tiers.md). **Headless cell editing.** `useCellEditing` is the state machine (one active cell, a draft string, the Enter/Escape/Tab flow) and returns `CellEditingState`; `EditableCellGate` / `EditableCellGateProps` is the activation wrapper every adapter renders, with `EditableCellControls` / `EditableCellActivateControlProps` / `EditableCellConflictButtonProps` on the main entry for the kit activate control and conflict / undo buttons. The adapter entry names those three contracts `EditableCellSlots`, `EditableCellActivateProps` and `EditableCellButtonProps`. A commit arrives as `CellEditCommit` and the active address as `CellEditTarget`. The keyboard vocabulary is `CellEditKeyAction` / `CellEditKeyOutcome` / `CellEditNavigation`. A custom editor receives `EditableCellController` / `EditableCellEditorCtrl` and an `EditableCellMode`; `EditableCellEditing` is the bundle adapters get from the chrome. `MultiSelectEditorChrome` / `MultiSelectEditorChromeProps` is the multi-select editor for kits whose select holds one value — a named group of the kit's own checkboxes, filled through `MultiSelectEditorSlots` / `MultiSelectEditorCheckboxProps`. `CellEditor` / `CellEditorOption` describe the column's `editor` descriptor, resolved by `resolveCellEditor` and `normalizeEditorOptions`, with drafts parsed by `parseCellEditValue`. Beyond text, number and select it names `boolean`, `date`, `datetime`, `time` and `multi-select`: `editorInputType` maps one to its input's `type`, `isBooleanEditor` / `isSelectEditor` / `isMultiSelectEditor` tell them apart, `booleanDraft` / `isDraftChecked` and `formatMultiDraft` / `readMultiDraft` (joined by `MULTI_SEPARATOR`) are the draft shapes those two hold, and `commitBooleanDraft` / `multiDraftFromSelect` from `@adapttable/react/adapter` are the draft helpers the kits use for those two. Each adapter draws the checkbox and multi-select with its own controls. A ninth kind, `{ type: "custom", render }`, is a host's own component: `CustomCellEditorRender` is the callback and `CustomCellEditorCtrl` what it receives (`draft`, `setDraft`, `commit`, `cancel`, `onKeyDown`, `onBlur`, `focusRef`, plus the validation trio), with `isCustomEditor` telling it apart. A cell whose value moved under the draft also carries `CustomCellEditorConflict` — the incoming value and the two ways out of it — so an editor can put the choice inside its own surface; the table draws its notice either way, in cell, row and batch editing alike. `EditableColumnLike` is the minimal column shape editing reads, and `isCellEditable` / `hasEditableColumns` are the two predicates the chrome uses. **A save the reader can see.** `onCellEdit` may return a promise; `useCellSaveState(options)` (`UseCellSaveStateOptions` in, `CellSaveState` out) tracks it, exposing a `CellSaveStatus` per cell and a `FailedCellSave` — the previous row, the attempted value and the message — for one that rejected. `onEditRollback` puts the row back and `formatEditError` words the failure; `labels.undoEdit` names the control the failed cell offers. **Editing a row as one unit.** `rowEditing` + `onRowEdit` change the commit unit from a cell to a row: `useRowEditing(options)` (`UseRowEditingOptions` in, `RowEditingState` out, holding `RowEditDrafts`) opens every editable field together and hands the host one patch of what changed. `RowEditCell` (`RowEditCellProps`) and `rowEditControls` (`RowEditControlsOptions` in, `RowEditControls` out) stay on `@adapttable/react/adapter`. Each adapter mounts `RowEditActions` (`RowEditActionsProps`) over `RowEditActionsChrome` / `RowEditActionsChromeProps` / `RowEditActionsSlots` / `RowEditButtonProps`; `labels.editRow` and `labels.saveRow` name them, as accessible names over each kit's own pencil, check and cross; `rowEditIcons` (`RowEditIcons`) replaces a glyph with your own node, or `false` to show the label as text. A row action marked `editsRow` becomes that trigger instead: `resolveRowEditTrigger(actions, rowEditing, row, rowId)` (`RowEditTrigger` out) wires it to the row's form and reports whether the built-in control still draws itself. An incoming row under an open form asks the same question a cell does, of every field that moved: `useEditConflict` finds them through `reconcileRow` (`ReconcileLiveRowEdit` in) and `isRowConflict` — and across a batch through `reconcileBatch` (`ReconcileLiveBatchEdit` in), `CellConflictAsk` is what one field's notice needs and `EditConflictChange` names each field that moved, and `rowEditConflict` (`RowEditConflict` out) tells the row's controls an answer is outstanding. **Changing many rows at once.** `batchEditing` + `onBatchEdit` hold every change until one save: `useBatchEditing(options)` (`UseBatchEditingOptions` in, `BatchEditingState` out) counts pending ROWS, marks changed cells, and produces the `BatchRowEdit[]` — `{ row, rowId, patch }` — the host receives in a single call. `BatchEditCell` (`BatchEditCellProps`) renders a field per editable cell. Each adapter mounts `BatchEditBar` (`BatchEditBarProps`) over `BatchEditBarChrome` / `BatchEditBarChromeProps` / `BatchEditBarSlots` / `BatchEditButtonProps`; `labels.pendingRows`, `labels.saveAll` and `labels.cancelAll` name them. **Lifecycle events.** `onEditStart`, `onEditCancel`, `onEditCommit`, `onValidationFail` and `onEditError` observe a commit; they never own it. The shared payload is `EditEvent` (`row`, `rowId`, `columnKey`, `value`, `previousValue`, `unit` of `EditUnit` `"cell" \| "row" \| "batch"`, optional `error`). `EditEventHandler` is the callback shape and `EditLifecycle` the five together. `useCellEditing` accepts `UseCellEditingOptions` for start and cancel; a throw from any handler is swallowed so analytics cannot rewind a commit. The same events fire on a mobile card — the commit unit does not change with the layout. **Live-update conflicts.** A refetch or a websocket that changes the row under an open editor is a conflict, not a discard. `onEditConflict` (`EditConflictHandler`) receives an `EditConflict` (`row`, `previous`, `rowId`, `columnKey`, `draft`, `incomingValue`, `previousValue`) and may return `"keep"` or `"take"` (`EditConflictChoice`); returning nothing defers to `editConflictPolicy` (`EditConflictPolicy`, default `"ask"`). `"ask"` surfaces Keep mine / Take theirs on the same channel validation already owns (`aria-describedby`, `data-conflict`). `rowVersion` makes any version change a conflict, not only the edited column. `useEditConflict` (`EditConflictState` in, `ReconcileLiveEdit` the inspect input) is the headless state; `liveRowChanged` is the comparison. `labels.editConflict`, `labels.keepMine`, `labels.takeTheirs` and `labels.theirsValue` name the notice — `theirsValue` is the incoming cell so Take theirs is a choice, not a blind swap. The same notice appears on a mobile card. **Adding, duplicating and deleting rows.** `onAddRow` puts an Add control in the toolbar; `onDuplicateRow` and `onDeleteRow` put icon-only Duplicate row and Delete row on every row, after the host's own `rowActions`, under the keys `DUPLICATE_ROW_ACTION_KEY` and `DELETE_ROW_ACTION_KEY`. The labels are the tooltip and accessible name. A delete confirms first unless `confirmDeleteRow={false}`. `useRowMutations(options)` (`UseRowMutationsOptions` in, `RowMutationsState` out, taking the `RowMutationHandlers`) is the state behind them; `labels.addRow`, `labels.duplicateRow`, `labels.deleteRow` and `labels.deleteRowConfirm` name them, and `labels.rowActionsMenu` names the 3-dot trigger when `rowActionsLayout="menu"`. The table stores nothing — a new row arrives through the source like any other. **Dirty marks.** `dirtyIndicators` turns them on; `useDirtyCells(options)` (`UseDirtyCellsOptions` in, `DirtyCellState` out) holds which cells hold an unconfirmed change, with `isDirty` / `isRowDirty`, a `count`, and `confirm` / `confirmRow` / `confirmAll` for a host that settles its own state. `rowIsDirty(editing, rowId)` from `@adapttable/react/adapter` is what a row reads. `cellFlashAttr` / `rowFlashSignature` are the same door for `data-flash`. **Validation gates the commit.** A column's `validate` (`CellValidator`) judges one value; the table's `validateRow` (`RowValidator`) judges the row an edit would produce, with `applyEdit` saying how the edit lands on it. `EditValidationState` carries what the table knows — which cells hold a message, which are still checking — and reaches a kit through the editing controller; `ValidationTarget` names the cell a check is about and `ValidationCheckResult` is what one returned. `resolveCommitValue(options)` is the row, column and parsed value a validator sees before the host does. `editorValidationProps(ctrl)` from `@adapttable/react/adapter` is the `aria-invalid` / `aria-describedby` / `aria-busy` a kit's editor spreads, and `editorBusyProps(ctrl)` is just the busy flag for a kit whose own input owns the other two. `stopEditKeys(event)` keeps Enter, Escape and Tab inside the open editor: all three mean something to the grid as well, and the editor is where the user is typing. See [cell editing](./cell-editing.md). **The grouped row model.** `buildGroupedFlatModel` turns rows into the flat `GroupedFlatEntry` list adapters render (a group header or a leaf row); `GroupAggregatesFn` is the per-group mapper, `groupValueKey` buckets a value, `formatGroupLabel(value, blankLabel)` renders its header text, `groupSelectionState` gives a group checkbox its tri-state over the group's leaf ids, `groupRowLayout` places a group header's cells so each subtotal sits under the column it totals (`GroupRowLayout` in, `GroupRowCell`s out) while `groupAggregateEntries` lists just the numbers for a mobile card, `windowGroupedEntries` slices the model to virtual window indices, and `useGroupCollapse` / `GroupCollapseState` hold which groups are collapsed. See [row grouping](./row-grouping.md). **The export pipeline, stage by stage.** `resolveExportCsv` normalizes the `exportCsv()` feature configuration, `exportableColumns` drops the synthetic actions column, `resolveExportColumns` applies a column scope (`ExportColumnScope`), `buildTableCsv` renders the text (`RowsToCsvOptions` controls delimiter, BOM and formula escaping), and `makeExportCsvHandler` wires the lot to the toolbar button. `ExportRowScope` names the row scopes — including `"range"`, the highlighted cell rectangle — `ExportContext` carries the selection, full column set and range those scopes need, `ExportInfo` is what the lifecycle hooks receive. Handing an export to a backend sends an `ExportRequest`, whose `ExportQuery` carries the view's search, filters, sort and grouping — with `page` and `limit` undefined for `scope: "all"`, so "everything" cannot be answered with one page. `onExportAll` instead receives an `ExportAllQuery`: the page-free search, flat and nested filters, primary and multi-sort, grouping, visible/requested column keys, format, and filename. Its `ExportAllControls` carries an `AbortSignal` plus optional progress and message setters; `ExportAllResult` is `{ url }` or empty. `FetchAllExport` is the opt-in that lets the table page a server source itself, capped at `EXPORT_FETCH_ALL_MAX_ROWS` (50,000) unless `maxRows` says otherwise; `fetchAllExportRows` performs that unchanged browser walk. `ExportHandlerState` is what `useExportHandler` returns — the click handler, `exportBusy`, an `ExportStatus`, `ExportProgressState`, and the text `ExportAnnouncer` reads out. `ExportProgressChrome` (configured by `ExportProgressChromeProps`) maps that shared state into an adapter's required `ExportProgressSlots`; `ExportProgressSurfaceSlotProps`, `ExportProgressAction`, and `ExportProgressDownload` type the kit-owned surface. Terminal states expose `onDismiss` / a Dismiss action; busy work keeps Cancel only. `LiveRegion` (with `LiveRegionProps`) is the polite region underneath it and `GridFocusAnnouncer`'s, and `ExportAnnouncerProps` types the announcer itself. See [browser and server-built exports](./exporting.md). **Lean live stubs.** A ninth adapter that mounts chrome by hand still has to hand the table a find/grid/export/history object when those features are off. `DISABLED_FIND`, `DISABLED_EXPORT`, `disabledHistory` and `windowedTableAria` are the inert shapes the shell already uses — so the adapter root never imports the hooks. **High contrast.** `ForcedColorsStyle` (and `ensureForcedColorsStyles`, which it calls) inject `FORCED_COLORS_CSS` once per page so Windows High Contrast and `prefers-contrast: more` keep focus, selection, dirty cells, validation and find hits visible as outlines. Adapters mount it; app code rarely needs to. See [accessibility](./accessibility.md). **Status announcements.** Sorting, filtering and paging rewrite the body with nothing a screen reader can perceive, so the table says what changed through one polite region. `useDataTableShell` returns the message as `statusAnnouncement` and adapters render it with `TableStatusAnnouncer` (`TableStatusAnnouncerProps`) beside their table, so the region is in the DOM before it has anything to say. Custom markup composes the message itself with `useTableStatusAnnouncement` (`TableStatusAnnouncementOptions` in — the row set, the page window and the sorted column). The sort sentence comes from the `sortedBy` and `sortingCleared` labels; the counts reuse `showing`, `pageOf` and `noResults`, so what a user hears matches what the footer shows. See [accessibility](./accessibility.md). **File formats.** An `ExportWriter` turns an `ExportWriteContext` — an `ExportTable` of values resolved once by `buildExportTable`, plus the filename — into an `ExportPayload`, which `downloadExportFile` hands to the browser. `csvWriter` is the built-in one, and `matrixToCsv` writes CSV from values a caller assembled itself. `exportButtonLabel` gives the button a caption naming the format it produces — `labels.exportCsv` for CSV, `labels.exportFile(format)` for anything else. `@adapttable/core/xlsx` adds `xlsxWriter` for real spreadsheets — typed numbers, booleans and dates, basic styling, group/tree outline and aggregate rows, no new dependency, and a separate entry so a CSV export never ships it — with `buildTableXlsx` underneath for building a workbook by hand. An `ExportTable` may carry `rowMeta` (`ExportRowMeta`, `ExportRowRole`) and `widths` when the view is grouped or a tree; a flat table omits them. `buildTableXlsx` accepts that view as `ExportViewEntry` rows — group headers, leaves and aggregates — so a host building a workbook by hand can pass the same shape the table does. `viewFromGroupedEntries` and `viewFromTreeEntries` build that view from the grouping or tree model, `filterExportView` drops groups a scope emptied, `exportViewFromChrome` picks which model is showing, and `summaryExportValues` turns a `summaryRow` into file values. See [customization](./customization.md#spreadsheet-xlsx-export). **PDF export and print layout.** `@adapttable/core/pdf` adds `pdfWriter` for the export button and `buildTablePdf` for a host assembling rows by hand. Print is a different verb: `openPrintLayout` (an `ExportTable`) and `printTable` (rows and columns) load `buildPrintDocument` into a hidden iframe. `buildPrintTableHtml` is the `
` alone; `printStyles` is the stylesheet. `PrintLayoutOptions` / `PdfWriterOptions` / `PrintPageSize` / `PrintPageBreak` configure title, direction, paper and how groups meet a page boundary. Both option types take `font` — a TrueType file as bytes — which the PDF writer subsets and embeds so the download draws Arabic, CJK or any script the built-in face cannot; Arabic is shaped into its contextual forms and reordered right to left. See [PDF export and print layout](./export-pdf.md). **Sparkline columns.** `@adapttable/react/sparkline` adds `Sparkline` / `sparklineColumn` so a cell can draw a bar, line or area chart without a chart library and without pulling the mark into the base bundle. `SparklineProps` / `SparklineKind` / `SparklineColumnSpec` type the surface. `finiteSparklineValues` drops non-finite points, `sparklineSummary` is the default accessible label, and `sparklineExportValue` writes the series as text so CSV and xlsx never get an SVG. See [sparkline columns](./sparkline.md). **Row patches.** `RowPatch` is the union applied by `applyRowPatches`, with `InsertPatch`, `UpdatePatch`, `UpsertPatch` and `RemovePatch` as its members. `applyRowPatchesWithLog` returns a `RowPatchLog` of `RowPatchEvent`s; `rowPatchLog` reads the log attached to the result array (a spread copy drops it). **Incremental re-evaluation.** `createIncrementalView` builds an `IncrementalView` from an `IncrementalViewConfig`; `applyRowPatchesToView` and `applyRowPatchLogToView` re-run search, filters, sort, grouping and aggregates for touched rows only. `configureIncrementalView` merges grouping / summary extras without walking the set when only those changed. `incrementalViewOf` / `attachIncrementalView` link a derived array to the snapshot (`incrementalViewConfig` reads it back); `incrementalSearchText` is the default projector. **Live row patches.** `useRowPatchStream` from `@adapttable/core/stream` (`UseRowPatchStreamOptions` in, `RowPatchStreamState` out) binds a WebSocket or SSE endpoint to the rows a host owns: frames become ordinary row patches and go back through the host's own setter. `openRowPatchStream` (`OpenRowPatchStreamOptions`, `RowPatchStreamHandle`, `RowPatchStreamReconnect`) is the connector without React, over a `StreamSocket` / `StreamSocketEvent` a host can supply itself. `parseRowPatchFrame` reads the table's patch shape as JSON and drops anything malformed. `RowPatchStreamStatus` is what the connection is doing, with `isStreamLive` and `isStreamSettled` as the two questions worth asking. `useChangedCellFlash` (`UseChangedCellFlashOptions` in, `ChangedCellFlashState` out) tracks the cells a patch just changed so the host can pass `isCellFlashing` — a pulse, not a locate-the-row highlight, and never against `prefers-reduced-motion`. See [realtime](./realtime.md). **Row reordering.** `rowReorder(handler, options)` from `@adapttable//row-reorder` arms flat, grouped, and tree reorder. `RowReorderHandler` is an ordinal write: dataset positions for a flat source, sibling positions for grouped/tree rows. `RowReorderOptions` adds `movePolicy` (`RowMovePolicy`: `"auto" | "confirm" | "never"`), `onGroupMove` (`RowGroupMoveHandler`), `onTreeMove` (`RowTreeMoveHandler`), and async `confirmMove` (`RowMoveConfirmHandler`). Requests are `RowMoveRequest`; group destinations use `RowGroupRef` / `RowGroupLevel`, and tree destinations use `RowTreeParentRef`. `treeMoveCreatesCycle` guards re-parenting and `rowDropPosition` resolves the `RowDropPosition` before/inside/after pointer zone. `RowReorderDecision` is the headless seam's reorder/move/reject result. `ROW_REORDER` is the `FeatureStateKey` its provider publishes under, so custom chrome reads `RowReorderState` (`TableRowReorderState` from the main entry) with `useFeatureState(ROW_REORDER)`. `hostConfirmPending` is the inert-controls flag while a host-owned `confirmMove` is outstanding. `applyRowReorder`, `datasetIndex`, `useRowReorder`, `rowReorderSignature`, `rowReorderDropStyle`, `REORDER_COLUMN_KEY`, `REORDER_COLUMN_WIDTH`, and `ROW_DND_MIME` are the headless primitives. Nested move menus use `RowMoveMenuModel` / `RowMoveTarget`; the adapter seam is `RowMoveMenuSlotProps`, `RowMoveMenuItemProps`, and `RowMoveConfirmationProps`. Each adapter mounts `RowReorderHandle` / `RowReorderHandleProps` and `RowReorderButtons` / `RowReorderButtonsProps` over `RowReorderHandleChrome` / `RowReorderHandleChromeProps` / `RowReorderHandleSlots` / `RowReorderHandleSlotProps` and `RowReorderButtonsChrome` / `RowReorderButtonsChromeProps` / `RowReorderButtonsSlots` / `RowReorderMoveButtonProps`. `RowReorderAnnouncer` stays on `@adapttable/react/adapter`. Move-menu, confirmation, success, policy, sort, and cycle labels extend `RowReorderLabels` / `TableLabels`. See [row reordering](./row-reordering.md). **Row and column spanning.** `getCellSpan` / `ColumnDef.colSpan` / `ColumnDef.rowSpan` produce a per-row `TableBodyCell` list (`BodyCell` on the adapter entry) through `buildBodyCells`, `cellsForRow`, `coveredAddressSet`, `rowSpanSignature`, `spanningArmed`, `bodyCellsHaveRowSpan` and `cellSpanMark`. `cellSpanAppearance` (`"merged"` / `"plain"`) is how the origin cell is painted. Arrow keys skip a covered cell; CSV writes the origin once. Types: `GetCellSpan`, `GetCellSpanArgs`, `CellSpanRequest`, `CellSpanAppearance`, `TableBodyCell`. See [row and column spanning](./row-spanning.md). **Full-width and separator rows.** `extraRows` is a list of `ExtraRow` (`kind: "separator" | "fullWidth"`, `beforeRowId`, `render`); the spliced main-entry shape is `TableExtraEntry` (`ExtraEntry` on the adapter entry). A named extra (`beforeRowId`) stays a full-width row in front of that person through reorder and pin; when a Team span would paint through it, `EXTRA_OVER_SPAN_STYLE` sits the extra on top of that column. `insertExtraRows` / `insertExtrasBeforeRows` / `extraRowsForSection` / `isExtraEntry` / `extraRowsArmed` / `EXTRA_ROW_PARTS` are the kit helpers. Label: `rowSeparator`. See [full-width and separator rows](./full-width-rows.md). **Row styling and heights.** `rowStyle` / `rowHeight` resolve through `resolveRowStyle` / `resolveRowHeight`. Height wins over `style.height`. `rowStyleSignature` is the memo digest; `rowStyleArmed` is whether either hook was passed; `estimateFromRowHeight` is the virtualizer estimator. Types: `RowStyle`, `RowHeight`. See [row styling and heights](./row-styling.md). **Row pinning.** `pinnedRowIds` / `onPinnedRowIdsChange` take a `RowPinState` (`{ top, bottom }` of `RowPinSide`). `applyRowPin(state, rowId, side)` is the in-memory helper; `partitionPinnedRows` splits a list into top / scroll / bottom; `EMPTY_ROW_PIN_STATE` is the empty lists. `useRowPinning` returns `RowPinningState`; `rowPinSignature` is the memo digest. Action keys: `PIN_TOP_ACTION_KEY`, `PIN_BOTTOM_ACTION_KEY`, `UNPIN_ROW_ACTION_KEY`. URL: `useRowPinningUrlState` (`UseRowPinningUrlStateOptions` / `UseRowPinningUrlStateResult`) writes `rowPin=id:top,id:bottom`. Labels: `pinToTop`, `pinToBottom`, `unpinRow` (`RowPinLabels`). `rowSourceIndex(entry)` is the dataset index when pinning remapped the window. From `@adapttable/react/adapter`: `pinnedRowStickyStyle` / `pinnedRowCellStyle`, `pinnedRowPart` / `pinnedRowSticky`, `orderedCardEntries`, `useOffsetHeight`, `PINNED_TOP_PART` / `PINNED_BOTTOM_PART`. See [row pinning](./row-pinning.md). **Pinned summary rows.** `pinnedSummaryRows({ top, bottom })` (`PinnedRows`) sticks host-owned objects outside the row model — sort, filter, grouping, pagination and selection never see them, so they coexist with grouped and tree tables. Identities are `pinnedSummaryRowId(side, index)` (`PINNED_SUMMARY_KEY_PREFIX` + `:side:index`); `PinnedSummaryEntry` / `isPinnedSummaryRowId` / `pinnedSummarySideFromId` / `pinnedSummaryEntries` / `allPinnedSummaryEntries` / `resolvePinnedRows` / `EMPTY_PINNED_ROWS` are the helpers. Parts: `PINNED_SUMMARY_TOP_PART` / `PINNED_SUMMARY_BOTTOM_PART` via `pinnedSummaryPart`. Labels: `pinnedSummaryRow`, `pinnedSummaryTop`, `pinnedSummaryBottom`. Not URL or Saved Views — the objects are host data. See [pinned summary rows](./pinned-summary-rows.md). **Filter internals.** `FILTER_TYPES` lists the built-in types, `filterLabel` resolves a filter's caption, `filterStateKeys` names the URL keys one filter owns, `hasActiveHeaderFilter` says whether any of them holds a value worth marking the column with — a cleared text field leaves `""` and a cleared multi-select leaves `[]`, and neither is a filter — `FilterRuntime` is what `buildFilterRuntime` returns, and `ResolvedFilterOptions` is what `useFilterOptions` resolves. `isDeclarativeFilters` tells the array form from JSX. A form reads its values through `FilterFormSource` with `listFilterValues` and `scalarFilterText`. Count filters: `COUNT_OPERATORS` / `COUNT_OPERATOR_SYMBOL` / `CountOperator`, state via `countFilterExtra` / `countFilterStateFromExtra` / `CountFilterState` / `isCountFilterComplete` / `clearCountFilterExtra` / `sanitizeCountFilterParams`, and a chip label from `countFilterChipLabel`. Range widgets: `useRangeFilterWidget` / `RangeWidgetState` / `RangeFieldWidget` / `RangeOp` / `RangeOpArity` / `RANGE_SUFFIXES` / `RANGE_OPS` / `RANGE_OP_LABEL_KEYS` / `RangeOpLabelKeys` / `writeRangeFilter`. Operator registry: `TEXT_OPS` / `NUMBER_OPS` / `DATE_OPS` / `TEXT_OP_LABEL_KEYS` / `NUMBER_OP_LABEL_KEYS` / `DATE_OP_LABEL_KEYS` / `FilterOp` / `TextOp` / `NumberOp` / `DateOp` / `FILTER_OP_SUFFIX` / `filterOpKey` / `isFilterOpKey` / `isValuelessFilterOp` / `isListFilterOp` / `isBetweenFilterOp` / `parseTextOp` / `parseNumberOp` / `parseDateOp` / `readFilterOp` / `parseListOperand` / `parseNumberList` / `isEmptyRowValue` / `formatFilterChip` / `filterOpLabel` / `useTextFilterWidget` / `TextFieldWidget` / `useBooleanFilterWidget` / `BooleanFieldWidget` / `BooleanChoice` / `parseBooleanChoice` / `coerceBooleanValue`. Relative dates: `RELATIVE_NAMED` / `RELATIVE_PRESETS` / `RELATIVE_PRESET_LABEL_KEYS` / `RelativeDateToken` / `RelativeDateRange` / `RelativePreset` / `parseRelativeToken` / `isRelativeDateToken` / `countedRelativeToken` / `splitRelativeToken` / `joinRelativeToken` / `relativeTokenLabel` / `resolveRelativeRange`. AND/OR trees: `FILTER_TREE_PARAM` / `FILTER_TREE_VERSION` / `parseFilterTree` / `serializeFilterTree` / `isActiveFilterTree` / `evaluateFilterTree` / `conditionToExtra`. Mutations: `emptyFilterTree` / `addFilterTreeCondition` / `addFilterTreeGroup` / `removeFilterTreeNode` / `replaceFilterTreeNode` / `setFilterTreeCombinator` / `walkFilterTreeConditions` / `FilterTreeNode`. Builder (on each adapter): `FilterTreeBuilder` / `FilterTreeBuilderProps`. Layout (on `@adapttable/react/adapter`): `FilterTreeChrome` / `FilterTreeChromeProps` / `FilterTreeClassNames` / `FilterTreeSlots` / `FilterTreeSelectProps` / `FilterTreeInputProps` / `FilterTreeButtonProps` / `FilterTreeDisclosureProps` / `FilterTreeOption`. Chips: `useFilterTreeChips` / `UseFilterTreeChipsOptions` / `filterTreeChipLabel`. Checklist (on each adapter): `ChecklistFilter` / `ChecklistFilterProps`. Layout (on `@adapttable/react/adapter`): `ChecklistChrome` / `ChecklistChromeProps` / `ChecklistClassNames` / `ChecklistSlots` / `ChecklistSearchProps` / `ChecklistButtonProps` / `ChecklistCheckboxProps`. Headless: `useChecklistFilter` / `ChecklistFilterState` / `collectChecklistValues` / `ChecklistValue` / `CHECKLIST_VIRTUALIZE_AT` / `CHECKLIST_ITEM_HEIGHT` / `CHECKLIST_LIST_HEIGHT`. Header row (on each adapter): `FilterHeaderRow` / `FilterHeaderControl` / `FilterHeaderRowProps` / `FilterHeaderControlProps` (`closeOnSelect`). Layout (on `@adapttable/react/adapter`): `FilterHeaderChrome` / `FilterHeaderControlChrome` / `FilterHeaderChromeProps` / `FilterHeaderControlChromeProps` / `FilterHeaderClassNames` / `FilterHeaderSlots` / `FilterHeaderSearchProps` / `FilterHeaderSelectProps` / `FilterHeaderRangeProps` / `FilterHeaderMultiProps` / `FilterHeaderOption`. Helpers: `filterDefForColumn` / `headerFilterStickTop` / `resolveFilterMode` / `toolbarShowsFilters` / `FilterChromeMode`. Facets: `computeFilterFacets` / `rowsExcludingFilter` / `FacetMap` / `FacetCounts`. The tree is a `QueryFilterGroup` of `QueryCondition`s (`isFilterGroup` narrows a child). See [filtering](./filtering.md). **Keyboard cell navigation.** `useGridFocus(options)` is the focus grid — `UseGridFocusOptions` in, `GridFocusState` out (`getGridProps`, `getCellPropsAt`, `getRowPropsAt`, `getColumnHeaderProps`, `selectColumn`, `focusCell`, `announcement`, `enabled`), and `` wires it for you. The move arithmetic is separate and pure: `moveGridFocus(from, move, bounds)` over a `GridCell` and `GridBounds`, with `GridFocusMove` naming the intents and `gridFocusMoveForKey(press, dir)` maps a `GridKeyPress` to one (applying the RTL swap). `sameGridCell` compares addresses. `GRID_CELL_ATTR` / `gridCellAttr(cell)` are the `data-grid-cell` attribute focus uses to find a cell in the DOM. `GridFocusAnnouncer` / `GridFocusAnnouncerProps` render the live region and come from `@adapttable/react/adapter`. `contextMenuCopyTarget(gridFocus, target)` returns a `ContextMenuCopyTarget` saying what a context-menu Copy should take — the clicked cell, or the selection it landed inside — resolving the address through `gridFocus.cellAt(rowId, columnKey)` so the sort, filter, page and virtual window are all followed. See [cell navigation](./cell-navigation.md). **The column-selection checkbox.** `columnSelectionCheckbox` adds a checkbox to every column header that selects that column — the touch and screen-reader path into the same state Ctrl/Cmd+click reaches. `GridFocusState` resolves it: `columnCheckbox` is true when the option and `cellNavigation` both are, `isColumnSelected(col)` answers whether the selection is exactly that column, and `toggleColumn(col)` selects it or clears. The control is core chrome with a kit checkbox in it — `ColumnSelectCheckboxChrome` / `ColumnSelectCheckboxChromeProps` / `ColumnSelectCheckboxProps` / `ColumnSelectSlots` from `@adapttable/react/adapter`, with `columnSelectLabel(label, column)` composing `labels.selectColumn` and the column's name. See [cell navigation](./cell-navigation.md). **Cell range selection.** Shift with a movement key or a shift-click extends a rectangle from its anchor. `CellRange` is the pair of corners and `CellRangeBounds` the sorted edges; `cellRangeBounds` sorts a range dragged up or left, `isInCellRange` tests membership, `cellRangeSize` multiplies rather than enumerating, `extendCellRange` moves the head while keeping the anchor, `singleCellRange` / `isSingleCell` cover the one-cell case, and `cellRangeIndices` lists the rows and columns for an exporter. See [cell navigation](./cell-navigation.md). **Clipboard.** `clipboardRangeText(options)` turns the selected rectangle into the tab-separated text a spreadsheet reads (`ClipboardRangeOptions` in), and `writeClipboardText` puts it on the clipboard, answering whether it landed rather than throwing. Coming back the other way, `readClipboardText` returns the clipboard's text or `null` when the browser refuses it, `parseClipboardTable` parses tab-separated text into a grid of raw strings (quoted tabs and newlines intact), and `pasteRangeEdits(options)` maps that grid onto a range (`PasteRangeOptions` in) as `CellEdit` values — one per cell, already through the column's `parseValue`, ready for the same handler an inline edit uses. `cellPasteHandler(options)` resolves who receives them — `onCellPaste` when given, otherwise `onCellEdit` one cell at a time, `undefined` when the table takes no edits at all (`CellPasteHandlerOptions` in). On `` the props are `onCellPaste` and `onCellCut`. See [cell navigation](./cell-navigation.md). **Fill handle.** `fillDirection(source, to)` says which way a drag from the selection's corner is filling (`FillDirection`, or `null` inside the selection), `fillTargetRange(source, to)` is the rectangle it would cover — what the preview highlights — and `fillRangeEdits(options)` turns the gesture into `CellEdit` values (`FillRangeOptions` in), continuing an arithmetic series when the source is one and repeating otherwise. `cellFillHandler(options)` resolves the recipient (`CellFillHandlerOptions`), and `batchEditHandler(batch, onCellEdit)` is the rule both it and `cellPasteHandler` follow. Adapters export their kit-owned `FillHandle` and render it over `FillHandleChrome` / `FillHandleChromeProps` / `FillHandleSlots` / `FillHandleSlotProps` from `@adapttable/react/adapter`; on `` the prop is `onCellFill`. See [cell navigation](./cell-navigation.md). **Undo and redo.** `useEditHistory(options)` remembers gestures and replays them through `onCellEdit` (`UseEditHistoryOptions` in, `EditHistoryState` out — `undo`, `redo`, `canUndo`, `canRedo`, `clear`, `record`), with `EditHistoryEntry` the recorded pair. `useTableEditHistory(props)` is the table-level wiring — it takes the `editHistory` prop (`TableEditHistoryProps`) and returns the history plus the commit channel that records each inline edit as a one-cell gesture. `asGesture(apply, record)` wraps a cell-edit handler as one undo entry. `asBatchGesture(apply, record)` does the same for `onBatchEdit`, so one save is one undo. `readCellValue(row, column)` reads a cell's current value unstringified — what an undo puts back. On `` the prop is `editHistory`. See [cell editing](./cell-editing.md). **Server-side grouping.** A source that declares `supports.grouping` receives `query.groupBy` (the keys, outermost first) and, with `supports.aggregates`, `query.aggregates` from `useQuerySource`'s `aggregates` option. It answers with `QueryGroupRow` values — `value`, `count`, optional `aggregates`, `groups` and `rows` — on the source's `groups` field (`QueryGroupsPage` types a whole page), and `serverGroupEntries(options)` (`ServerGroupEntriesOptions`) lays them out as the same entries local grouping produces. `groupLeafCount(entry)` is the count a header shows: the server's when it grouped, the rows in hand otherwise. See [row grouping](./row-grouping.md). **Column sizing.** A `ColumnDef` takes `width`, `minWidth`, `maxWidth` and `flex`; `` makes the columns share the container. `columnFlexShares(options)` computes each flexible column's percentage (`ColumnSizingOptions`), `columnSizeStyle(column, shares, userWidth)` is the style a cell carries — dragged width first, then the column's own, then its share — and `fittedTableStyle(fitColumns)` is what the `
` needs for percentages to mean anything. See [column management](./column-management.md). **Column auto-sizing.** `measureColumnWidth(root, key)` returns the width a column needs for its widest rendered cell — measured from the DOM by the `data-column-key` every cell carries — and `autoSizeColumns(root, keys, setWidth)` sizes a whole set, returning how many it could measure. A cell that already fits is not grown again on a later click. A resize handle sizes its own column on double-click, and the column menu's action calls `shell.autoSizeColumns`. See [column management](./column-management.md). **Column virtualization.** `useColumnWindow(options)` windows the horizontal axis (`UseColumnWindowOptions` in, `ColumnWindow` out — the columns to render and the `paddingStart` / `paddingEnd` that hold the rest open), and `ColumnSpacer` / `ColumnSpacerProps` from `@adapttable/react/adapter` render one of those spacers. The render model swaps the windowed columns in, so an adapter maps over `model.columns` as before and renders `model.columnSpacers` either side. On `` the prop is `virtualizeColumns`. See [virtualization](./virtualization.md). **Virtualized row detail.** `useRowPairMeasurer(virtualizer, enabled)` returns `RowPairMeasurer` — `row(index)` and `detail(index)` ref callbacks — which report a row and its open panel as one height through the virtualizer's `resizeItem` (`ResizableVirtualizer`). It is what lets `renderRowDetail` and `virtualize` be used together; adapters take it as `measureRowPair` in place of `measureElement` when the table can expand rows. See [virtualization](./virtualization.md). **Row grouping.** `groupBy` takes a key or an ordered list; `parseGroupBy(value)` turns any of its forms (`GroupByInput`) into the key list and `formatGroupBy` back into the single comma-separated value state is stored as. `groupingPanel(groupBy?, extras?)` from each kit's `/grouping-panel` subpath adds the interactive strip and still accepts every `GroupingExtras` option. It publishes `GroupingPanelState`: ordered `groupBy`, the `aggregations` model every surface reads, drag/drop and keyboard bindings, `add` / `remove` / `moveBy`, and `setAggregateOperation` / `addAggregate` / `removeAggregate` / `restoreAggregateDefaults`. A column's `aggregatable` (`false` / `true` / `{ default?, operations }`) is the one offer the panel, the column menu, a URL and a request all resolve. `GroupAggregateOverride` is a built-in name, a host operation id, or `"none"`; `GroupAggregateOverrides` maps those session choices by column. `serializeGroupAggregateOverrides` / `parseGroupAggregateOverrides` encode both the column key and the operation id (`encodeURIComponent`, split on the first `:`), so custom ids with colons, commas, percents or Unicode round-trip. `serializeAggregationDerivedKey` is the cache key for the effective set. Built-in `budget:sum` URLs stay compatible. `withGroupAggregateOverrides` layers them over a developer `groupAggregates` mapper and refuses a disallowed id at execution, while `withQueryAggregateOverrides` does the same for server `query.aggregates`. `queryAggregateOps` reads those requested functions back as the operations a column is told about — `TableSource.groupAggregations`, which `useQuerySource` and `useServerData` publish as of the response being drawn — `useQuerySource` from the query's `dataUpdatedAt` read against its request, `useServerData` from the `responseKey` a host echoes back, without which a request it cannot place is reported as unknown. The developer's original `aggregates` option is published separately as `queryAggregates`, so Restore defaults never treats the current response as the initial query. An absent key preserves the host request; `"none"` removes it only while `readerControlAllowed` is true. `TableSource.honorsAggregates` is false when a server groups but drops `query.aggregates`, which hides the aggregation controls rather than letting them mutate unused local state. A column's `formatAggregate` says how the result reads, taking the value and an `AggregateFormatContext` — the column key, and the operation where the table knows it. It is applied where the cell is drawn: `groupRowLayout` and `groupAggregateEntries` take the group's `GroupAggregateOps` and hand each cell through it, and `groupAggregateNode` does one cell for a kit that lays out its own group rows. Adapter authors bind the `GROUPING_PANEL` slot with `createAdapterGroupingPanelFeature(AdapterGroupingPanelComponents)`. `GroupingPanelChrome` takes `GroupingPanelChromeProps` / `GroupingPanelSlotProps` and a `GroupingPanelSlots` object whose required pieces receive `GroupingPanelSurfaceProps`, `GroupingPanelDropZoneProps`, `GroupingPanelChipProps`, `GroupingPanelSelectProps`, `GroupingPanelRemoveZoneProps`, `GroupingPanelAggregationItemProps`, `GroupingPanelAggregationRemoveProps`, `GroupingPanelChecklistProps`, `GroupingPanelChecklistOption`, and `GroupingPanelRestoreProps`; select entries are `GroupingPanelOption`s and native event wiring uses `GroupingDragProps` / `GroupingDropProps`. `GroupingPanelInteractions` carries those callbacks, `GroupingChipKeyboardProps` defines the equal keyboard path, and `GroupingDragState` describes the active drag while `GroupingDragSource` names its header-or-chip origin. Every kit exports its kit-owned `GroupingPanel`; Radix additionally names `RadixGroupingPanelProps`. Column-menu plugins return a `ColumnMenuItem`: either an ordinary action or a `ColumnMenuChoice` made of `ColumnMenuChoiceOption`s. `buildGroupedFlatModel(options)` walks the tree into the flat `GroupedFlatEntry` list adapters render — each group entry carrying its `level`, its `groupBy` key, its `path` and the leaves of its whole subtree — and `groupIndentStyle(level)` from `@adapttable/react/adapter` is the indent every kit applies. `GroupSort` names the orderings `groupSort` accepts (`"label"`, `"label-desc"`, `"count"`, `"count-desc"`, or a comparator) and `GroupNode` is what it and `groupFilter` receive: `value`, `label`, `level`, `groupBy` and the group's `leafRows`. Paging is `groupPageSize` / `groupRowPageSize`: `useGroupPaging()` holds how much has been revealed (`GroupPagingState`, whose `paging` is a `GroupPaging`), the model emits a `groupMore` entry for the rest, and each adapter mounts `GroupMoreButton` / `GroupMoreButtonProps` over `GroupMoreButtonChrome` / `GroupMoreButtonChromeProps` / `GroupMoreButtonSlots` / `GroupMoreButtonSlotProps`. `groupRowParts(kind)` names the `data-adapttable-part` values for each of the three rows a grouped body renders (`GroupRowKind`), and `GroupToggleSpacer` holds the chevron's width on the two rows that have no chevron, so a footer lines up with the header it closes. Expansion is the `collapsedGroupIds` / `onCollapsedGroupIdsChange` pair — `useGroupCollapseUrlState(options)` keeps it in the URL (`UseGroupCollapseUrlStateOptions` in, `UseGroupCollapseUrlStateResult` out, serialized by `readCollapsedGroups` / `writeCollapsedGroups` under `PARAM_GROUP_CLOSED`), and the table's grouping bundle carries `expandAll`, `collapseAll` and `collapseToDepth`. See [row grouping](./row-grouping.md). **Tree data.** A hierarchy the data declares, not one derived from values, so it is a separate model from grouping. `TreeShape` is how a host declares it — `getChildren` for nested rows, `getParentId` for a flat list with a parent column, `hasChildren` for children not fetched yet. `buildTreeEntries(options)` (`BuildTreeEntriesOptions` in) flattens it into the `TreeEntry` list adapters render, each entry carrying its `level`, `path`, `descendantIds` and `loading`; `useTreeExpansion(options)` holds the open set (`TreeExpansionState`), `treeColumnKey(columns, declared?)` picks the column that carries the chevron, and `filterTreeRows(options)` keeps a match together with every ancestor that leads to it. `treeIndentStyle(level)` indents a cell and `treeCardStyle(level)` a mobile card; `bodyRowEntries(rows, tree)` returns the `BodyRowEntry` list a body maps over, tree or flat. Each adapter mounts `TreeCell` / `TreeCellProps` and `TreeToggle` / `TreeToggleProps` over `TreeCellChrome` / `TreeCellChromeProps` / `TreeToggleChrome` / `TreeToggleChromeProps` / `TreeToggleSlots` / `TreeToggleButtonProps`. Lazy branches are `hasChildren` + `onLoadChildren`: `useLazyChildren(options)` holds which nodes are fetching (`LazyChildrenState`, `UseLazyChildrenOptions` in) and the tree bundle carries `loadingIds` / `failedIds`. A server-side tree is `supports: { tree: true }` plus `expandedIds` on `useServerData` / `useQuerySource`, which sends the open ids as `query.expandedIds`. **A real table under a row.** `nestedTable` takes a `NestedTableFor` and returns a `NestedTable` — a `label` and a `table(defaults)` that mounts the kit's own component. `NestedTableDefaults` is what it receives: `urlSync: false`, `searchable: false`, the parent's `density`, `labels` and the `tableLabel`. `nestedTableDefaults(label, parent)` builds them and `nestedTableDetail(options)` turns the declaration into the `renderRowDetail` the table places under a row (both from `@adapttable/react/adapter`, with `NestedTableParent` for what the parent contributes). See [tree data](./tree-data.md). See [tree data](./tree-data.md). **Find in table.** `findMatches(options)` returns every cell whose text contains the query, in absolute addresses (`FindMatchesOptions` in); `matchKey(cell)` / `matchKeySet(matches)` make membership a constant-time question and `stepMatch(index, total, step)` wraps the walk. `useFindInTable(options)` is the bar's state — `open`, `query`, `matches`, `index`, `current`, `next`, `previous` (`UseFindInTableOptions` in, `FindInTableState` out). The query is shareable table state: pass the same `urlAdapter` / `urlSync` / `urlKey` the table already uses and a `find` param rides beside `q`. `FIND_URL_WRITE_DEBOUNCE_MS` is the trailing debounce on that write (replace-state only). Each adapter mounts `FindBar` / `FindBarProps` over `FindBarChrome` / `FindBarChromeProps` / `FindBarSlots` / `FindSearchProps` / `FindButtonProps` / `FindButtonKind`. `useFindFocus(current, focusCell, selectRange)` is what takes the table's focus to the match the walk is on. Cells carry `data-cell-match` / `data-cell-match-current`, which `isMatchedCell` / `isCurrentMatchCell` read and `cellHighlightStyle(props, base, selected)` resolves into one background. On `` the prop is `findInTable`. See [cell navigation](./cell-navigation.md). **Selection statistics.** `selectionStats(options)` returns `SelectionStats` — `cells`, `numeric`, and `sum` / `average` / `min` / `max`, each `null` when the selection holds no numbers (`SelectionStatsOptions` in). Adapters export their kit-owned `SelectionStatsBar` and render it over `SelectionStatsChrome` / `SelectionStatsChromeProps` / `SelectionStatsSlots` / `SelectionStatsSlotProps` / `SelectionStatPart` from `@adapttable/react/adapter`; it is empty below two cells. On `` the prop is `selectionStats`. See [cell navigation](./cell-navigation.md). **Highlighting a row.** `useHighlight(enabled)` returns a `HighlightState`: `flashRow(rowId)`, `flashCell({ rowId, columnKey })` (a `HighlightedCell`), `clear()`, `isRowHighlighted` / `isCellHighlighted`, and `animated`. Marks are keyed by row id rather than position, so one survives the sort, filter or page change that moves the row. Flashing the same row again restarts its clock instead of stacking. Under `prefers-reduced-motion` the mark still appears — `animated` goes false and it holds steady, and longer, because a steady mark is easier to miss than one that moves. Reduced motion means less movement, not less feedback. **`PivotPanel`** is each adapter's pre-wired configuration panel — import it from your kit and pass `fields`, `config` and `onChange`. It is `PivotPanelChrome` with that kit's slots already filled. **`SavedViewsPanel`** is each adapter's pre-wired management panel — import it from your kit and pass the views plus the five handlers. shadcn's names its own `SavedViewsPanelProps`: the views, the five handlers, `labels`, `footer`, `className`, and a `classNames` map merged per key over the shadcn preset. It is the panel's whole contract, with no slot type to fill in — the kit has already filled them. **Saved-view storage and versioning.** `useSavedViews` takes a `SavedViewsStore` (`list` / `save` / `remove`, all async) that replaces localStorage, a `SavedViewVisibility` (`"private"` | `"team"`) for new views, and a `SavedViewMigration` for views behind `SAVED_VIEW_VERSION`. Its result adds `rename`, `move`, `setDefault`, `defaultView` and `reload`. A store's fourth member, `reorder(names)`, is optional and persists the list's order — without it a store keeps every other operation and `move` reorders for the session only. See [saved views](./saved-views.md). **The saved-views management panel.** `SavedViewsPanelChrome` from `@adapttable/react/adapter` is a titled card listing every saved view; `SavedViewsPanelChromeProps` takes the views, the five handlers, and an optional `footer` rendered inside the card under the list. Applying a view is clicking its name; rename, move, set-default and delete are an icon cluster described by `SavedViewRowControl`, keyed by `SavedViewControlKey`. `SavedViewsPanelSlots` names the four kit-supplied pieces — `SavedViewsPanelSurfaceProps`, `SavedViewsPanelRowProps`, `SavedViewsPanelInputProps` and `SavedViewsPanelEmptyProps`. Reordering is buttons, and renaming is an inline input that Escape abandons. See [saved views](./saved-views.md). **Your router's URL adapter.** `routerUrlAdapter(options)` builds a `UrlStateAdapter` from a router's current search string and its navigate; `RouterUrlAdapterOptions` is that pair. It depends on no router, so React Router, TanStack Router and Next.js all take two lines. See [URL state](./url-state.md). **Adaptive capabilities.** `@adapttable/ai` is the optional, provider-neutral agent contract. The root exports `AGENT_SCHEMA_VERSION`, `CAPABILITY_KEYS`, `CapabilityKey`, `WritePolicy`, `ApprovalPolicy`, `CommitPolicy`, `RowAddressScope`, `createAgentSession`, `CreateAgentSessionOptions`, `buildManifest`, `enabledKeys`, `guideOf`, `summaryOf`, `validateSchema`, `eligibleSuggestions`, `assertUniqueSuggestions`, and the types `AgentApply`, `AgentAggregateOperation`, `AgentAggregationColumn`, `AgentAggregations`, `AgentAggregationsPatch`, `AgentCapabilityContext`, `AgentCapabilityDefinition`, `AgentCellEdit`, `AgentColumn`, `AgentFilter`, `AgentFilterOption`, `AgentLimits`, `AgentManifest`, `AgentManifestAggregation`, `AgentObservation`, `AgentPolicy`, `AgentRowAddressing`, `AgentSession`, `ApprovalOutcome`, `ApprovalResult`, `ApprovalSubject`, `CapabilityGuide`, `CatalogEntry`, `ExecuteError`, `ExecuteResult`, `JsonSchema`, `ResolvedRow`, `RowKeyRef`, `RowPositionRef`, `RowReadQuery`, `RowRef`, `RowWindow`, `RowWindowRow`, `WriteExecuteResult`, `WriteProposal`, `WriteRowResult`, `sharedApproval`, `SharedApproval`, `ResolvedApproval`, `agentFiltersFromDefs`, `FilterCatalogColumnPatch`, `agentColumnsFromNeutral`, `TableAgentColumnPatch`, `LiveObservationOptions`, `NeutralQueryOverlay`, `monotonicRevision`, `observationFromNeutral`, `readRowsFromNeutral`, `resolveRowFromNeutral` and `revisionToken`, and the assistant contracts `AssistantAction`, `AssistantConversation`, `AssistantOutcome`, `AssistantOutcomeStatus`, `AssistantPlanner`, `AssistantProposal`, `AssistantRequest`, `AssistantSuggestion`, `AssistantTurn`, `AssistantExchange`, `AssistantTransport`, `AssistantTransportReply`, `AssistantTurnInput`, `AssistantSendInput`, `AssistantResumeInput`, `AssistantResumeHandle`, `CapabilityProgress` and `CapabilityPresentation`, plus the receipt readers `receiptFromResult`, `receiptsFromResults`, `turnStatus` and the types `AssistantReceipt`, `AssistantReceiptStatus`, `AssistantTurnStatus`. `@adapttable/ai-react` exports `useTableAssistant`, `TableAssistantOptions`, `TableAssistantState`, `AssistantMessage`, `AssistantStatus`, `AssistantInterruption`, `AssistantResumeHandle`, `AgentProgress`, `AssistantSuggestion`, `tableAgent`, `TableAgentOptions`, `TableAgentColumnPatch`, `TABLE_AGENT_STATE`, `TableAgentBridge` and `SharedApproval`. `@adapttable/ai/http` adds `assistantHttpTransport`. Receipts come from `receiptFromResult` and `receiptsFromResults` as `AssistantReceipt`, whose optional `AssistantReceiptSubject` names what changed so the panel can say "Filter applied — Team is Core" rather than a capability key. `subjectFor` builds that subject for a built-in capability from what the session returned, carrying the columns and values as `AssistantReceiptTerm` pairs — label and formatted value, never a key — so the panel joins them in the reader's language through `assistantReceiptTerms`. The turn's transports fill `AssistantTransportReply.subjects` with it; a host capability has no entry, because only its own runner knows what it did. Each kit ships the panel on its own `@adapttable//assistant` entry, exporting `TableAssistant` and `tableAssistant`. Core chrome exports `TableAssistantChrome` (`TableAssistantChromeProps`, `TableAssistantProps`), `TABLE_ASSISTANT`, `createAdapterTableAssistantFeature`, `TableAssistantPresentation` — `"floating"` for a nonmodal window over the page, `"panel"` for a surface the host places, `"sheet"` for the kit's modal — with `TableAssistantBoundary` scoping a floating window to the viewport or to a container of your own. `TableAssistantSlots` collects `TableAssistantPanelProps`, `TableAssistantSheetProps`, `TableAssistantWindowProps`, `TableAssistantButtonProps`, `TableAssistantComposerProps` and `TableAssistantBadgeProps`, plus `TableAssistantMenuProps` and `TableAssistantMenuItem` — the examples menu in the composer, which each kit draws with its own menu primitive. `TableAssistantAvatars` sets the mark beside each speaker, each one a `TableAssistantFace`: a string is read as a name and drawn as its initials, anything else renders as given — an image, a kit's own Avatar. Either side left out keeps the built-in face. `greeting` sets the assistant's opening line, and an empty one opens the panel silent. The view types are `TableAssistantView`, `TableAssistantMessageView`, `TableAssistantReceiptView` with `TableAssistantReceiptSubject` — what an action changed, supplied by whoever ran it — and `TableAssistantSuggestionView`, plus `assistantIsBusy` and `assistantIsUsable`. Each published kit exports `agentApproval` and `AgentApproval` (`AgentApprovalProps`). Core chrome exports `AgentApprovalChrome` (`AgentApprovalChromeProps`), `AGENT_APPROVAL`, `AGENT_APPROVAL_STATE`, `AgentApprovalPending`, `approvalReview`, `ApprovalReview`, `ApprovalReviewItem`, `APPROVAL_PREVIEW_LIMIT`, `ApprovalReviewChrome`, `ApprovalReviewChromeProps`, `ApprovalReviewSlots`, `AgentApprovalDecision`, `AgentApprovalOperation`, `AgentApprovalProposal`, `AgentApprovalButtonProps`, `AgentApprovalListProps` and `AgentApprovalSlots`. Portable adapters live on subpaths: `@adapttable/ai/json` (`toJsonTools`, `JsonFunctionTool`, `executeJsonTool`, `JsonToolCall`, `parseEnvelope`, `executeEnvelope`, `AgentEnvelope`), `@adapttable/ai/openai` (`toOpenAITools`, `toOpenAIToolName`, `fromOpenAIToolName`, `OpenAIFunctionTool`, `OpenAIFunctionDefinition`, `OpenAIToolsOptions`, `executeOpenAITool`, `OpenAIToolCall`, `OpenAIToolCallFunction`), `@adapttable/ai/mcp` (`toMcpTools`, `McpTool`, `toMcpResources`, `McpResource`, `mcpListChanged`, `executeMcpTool`), `@adapttable/ai/http` (`createAgentHttpClient`, `connectAgentHttp`, `runAgentHttpTurn`, `parseAgentHttpRequest`, `parseAgentHttpResponse`, `agentSystemPrompt`, `AgentSystemPromptInput`, `AGENT_HTTP_SCHEMA`, `AgentHttpKind` (`hello` / `schema` / `turn`), `AgentHttpMessage`, `AgentHttpUnresolved`, `sessionId`, `viewRevision`, `pinCatalog`, `AgentHttpPinAck`, `AgentHttpAudio`, `AgentHttpRequest`, `AgentHttpResponse`, `AgentHttpError`, `AgentHttpClientOptions`, `AgentHttpTurnResult`). See [adaptive capabilities](./agent-capabilities.md), [`@adapttable/ai`](./ai.md), [agent integrations](./ai-integrations.md) and [connect a backend](./ai-http.md). **Server queries.** `parseTableQuery(input, schema)` from `@adapttable/server` validates a request against a `QuerySchema` and returns a `ServerTableQuery` — page, limit, offset, search, sort chain, grouping, filters, filter tree, pivot, the folded pivot groups in `pivotCollapsed`, and cursor, plus a `QueryRejection[]` naming everything it refused. `QueryInput` is a `Request`, `URL`, query string or `URLSearchParams`; `ServerFilterValue` is one filter's value. See [server queries](./server-queries.md). **The query model without React.** `@adapttable/core/query` is the half of the model a backend needs and no more: the `ft=1.{…}` codec (`parseFilterTree`, `serializeFilterTree`, `isActiveFilterTree`, `FILTER_TREE_PARAM`, `FILTER_TREE_VERSION`), the `pivot=rows:…` codec (`serializePivot`, `deserializePivot`, plus `serializePivotState` / `deserializePivotState` for the whole `PivotUrlState` — the `config` and the folded `collapsed` keys), the `formula=key:text` codec (`serializeFormulaColumns`, `deserializeFormulaColumns`, `FormulaColumnSpec`), `isFilterGroup` for walking a tree, and the types they speak in — `QueryCondition`, `QueryFilterGroup`, `SortLevel`, `SortDirection`, and the pivot pair `PivotConfig` (`rows`, `columns`, `measures`, `subtotals`, `grandTotals`) and `PivotMeasure` (a column `key`, an `agg`, an optional `label`). Every name is the same one `@adapttable/core` exports, from the same module; this entry only omits the hooks, so it carries no `"use client"` boundary and no React import and loads where React is not installed. See [server queries](./server-queries.md#decoding-a-parameter-yourself). **Formulas.** `buildFormulaColumns(specs)` from `@adapttable/core/formula` turns `FormulaColumnSpec`s into columns, returning a `FormulaColumnsResult`: the columns, the `errors` that would not parse, and any `cycles`. A value is a tagged `FormulaValue` (`FormulaErrorCode`, `FORMULA_ERRORS`, `FORMULA_BLANK`), built with `formulaNumber` / `formulaText` / `formulaBoolean` / `formulaError` or read off a row with `toFormulaValue`, rendered with `formulaDisplay`, compared with `formulaSortValue`, and tested with `isFormulaError`. `parseFormula` returns a `ParseResult` holding a `FormulaNode` tree (`BinaryOp`), `formulaRefs` names what a formula reads, `evaluateFormula` runs one against a `FormulaScope`, and `FORMULA_FUNCTIONS` lists the built-ins, including `POWER` and `SQRT`. See [formulas](./formulas.md). **Formulas in the URL.** `useFormulaUrlState({ urlAdapter, urlSync, urlKey, defaultFormulas })` from `@adapttable/core/formula` returns a `UseFormulaUrlStateResult` — the `formulas` to hand `buildFormulaColumns`, and an `onFormulasChange` that persists them; `UseFormulaUrlStateOptions` names the options and `FORMULA_URL_WRITE_DEBOUNCE_MS` is the trailing debounce on the URL write. `serializeFormulaColumns` and `deserializeFormulaColumns` are the encoding on its own, exported from `@adapttable/core/formula` and from the React-free `@adapttable/core/query`; reading produces `FormulaColumnSpec`s and never evaluates anything. Saved views capture the parameter with the rest. **The pivot engine.** `pivot(rows, options)` from `@adapttable/core/pivot` returns a `PivotResult`: `columnTree`, a tree of `PivotColumnNode`s carrying each dimension value's `label`, `path`, header `span` and `children`; `columnLeaves`, the rendered columns left to right as `PivotColumnLeaf`es (a stable `key`, the column `path`, the `measure` shown in it, and `total` for the grand-total column); `rows`, the body as `PivotRow`s (`key`, `path`, `depth`, a `PivotRowKind` of `"leaf"` / `"subtotal"` / `"grandTotal"`, `label`, `cells` in `columnLeaves` order, and the `count` of source rows behind the line); and `rowDepth`, how many dimensions sit down the side. `PivotOptions` carries the `columns` — so dimension and measure values resolve through `sortValue` exactly as sorting and grouping do — a `format` for a computed cell, and the `collapsed` subtotal keys. A row with no value for a dimension buckets under `PIVOT_BLANK` instead of vanishing, and the grand-total line's key is `PIVOT_GRAND_TOTAL_KEY`. See [pivot tables](./pivot.md). **Editing a pivot configuration.** The panel's non-widget half, so every kit's buttons agree on what a move means. A `PivotField` is a column `key` plus the `label` to show it under, and `PIVOT_ZONES` lists the `PivotZone`s a field can sit in — `"rows"`, `"columns"`, `"measures"` — in panel order. `availableFields(fields, config)` is what no axis has claimed yet; `assignField(config, key, zone, index)` places a field (past the end appends, and a dimension leaves the other axis rather than pivoting twice); `removeField(config, zone, index)` takes one off; `moveField(config, zone, index, delta)` is the keyboard step within a zone; and `setMeasureAgg(config, index, agg)` changes what a measure computes. Each returns a new `PivotConfig`, starting from `EMPTY_PIVOT_CONFIG`. `isPivotReady(config)` is false while no measure has been chosen — a half-built configuration the panel shows and the table waits on, not an error. `measureLabel(measure, fields)` is the caption the panel and the column header share. See [pivot tables](./pivot.md). **Pivot state in the URL.** `usePivotUrlState({ urlAdapter, urlSync, urlKey, defaultConfig })` from `@adapttable/core/pivot` returns a `UsePivotUrlStateResult` — the `config` to hand both the panel and `pivot`, an `onConfigChange` that persists it, the folded `collapsed` set to pass as `pivot`'s `collapsed` option, and `onCollapsedChange`; `UsePivotUrlStateOptions` names the options. An empty pivot writes no parameter. See [URL state](./url-state.md). **Pivoting on the server.** `serverPivotResult(page, options)` from `@adapttable/core/pivot` turns a server's answer into the same `PivotResult` the local engine returns, so one rendering path serves both tiers. A `QueryPivotPage` is the column-dimension `columns` paths in display order, the body `rows`, and the `total` line when the server computed one; each `QueryPivotRow` is a row `path` (empty for the grand total), its `cells` in column-then-measure order, optional `totals` for the grand-total column, a `count`, and `subtotal` when the line totals the ones beneath it. Absent cells render empty rather than zero. `ServerPivotOptions` is the `config` that was sent — for the measures and their order — plus the same `format`. See [server queries](./server-queries.md). **The pivot configuration panel.** `PivotPanelChrome` from `@adapttable/react/adapter` renders the three zones and the controls that move fields between them; `PivotPanelChromeProps` takes the fields, the config and an `onChange`. `PivotPanelSlots` names the five kit-supplied pieces — `PivotPanelSurfaceProps` (the body), `PivotZoneProps` (a titled zone), `PivotFieldProps` (one field with its move and remove controls), `PivotAddProps` (the add control) and `PivotAggProps` (a measure's aggregation chooser). Keyboard-first by construction: the move controls are buttons, so the panel needs no pointer. See [pivot tables](./pivot.md). **A pivot, as table props.** `pivotTableModel(result, options)` from `@adapttable/core/pivot` turns a `PivotResult` into a `PivotTableModel` — the `columns`, `rows`, `rowKey` and `summaryRow` a `DataTable` takes — so the pivot is rendered by your kit rather than by markup of your own. The column tree becomes `column.group`, the grand total becomes the footer, and the row-header column is keyed `PIVOT_ROW_COLUMN_KEY`. `PivotTableModelOptions` are the `fields` that caption the measures, the `labels` behind the grand-total captions, the corner cell's `rowHeader`, the per-level `indent`, and `renderRowHeader` — where a fold control goes, since core ships no controls. See [pivot tables](./pivot.md#rendering-it-with-your-kit). **Replacing a mobile card's body.** `renderCard(row, card)` returns the card's content; the shell renders around it. `renderCard` has the type `ReactMobileCardRenderer` from `@adapttable/react`, and `card` is a `MobileCardModel`: `index`, `selected`, `expanded`, and `fields` — a `MobileCardField` per column carrying its `column`, resolved `label` (`undefined` when the column asked for none) and `value`, the same node the built-in would have shown. See [mobile](./mobile.md). **Replacing the error state.** `slots.error` is a `Slot`: a node, or a function receiving the `TableErrorState` the built-in was showing — `error`, `retry` (absent when the source cannot re-fetch, so a static `data` array offers no dead button) and `retrying`. Adapters derive it with `tableErrorState(source)` and resolve the slot with `fillSlot(slot, state)`, both from `@adapttable/react/adapter`, where the slot and the node it resolves to are React content. `@adapttable/core` exports the neutral pair for a non-React host. See [customization](./customization.md). **Density chooser and fullscreen toggle.** `densityChooser()` puts a density control in the toolbar. Without a controlled `density` prop, the feature owns the choice and starts at `"comfortable"`; pass `density` to control it and `onDensityChange` to observe requests. `fullscreen()` puts a fullscreen toggle beside it, and that button hides itself where the browser will not allow fullscreen at all. Adapters build both from `viewControlsToolbar(props, fullscreen)` / `ViewControlsToolbar` in `@adapttable/react/adapter`. Density is an always-resolved read/write contract passed to the toolbar slots; whether the density button draws is decided by feature composition. Fullscreen stays present-or-absent from the feature and browser support. Adapters with a custom chrome path call `useResolvedDensity(props)` directly; its `ResolvedDensity` result carries the same resolved value and request callback. **Fullscreen.** `useFullscreen(element)` promotes the table and returns a `FullscreenState`: `active`, `supported`, `toggle`, `exit`, and — the part that matters — `container`. The Fullscreen API hides everything outside the promoted element, so an overlay portalled to `document.body` stays mounted, focused and announced while being completely invisible. Hand `container` to each kit's portal target and menus keep working; ignore it and they vanish. State is read from the document rather than remembered, because Escape and the browser's own control both leave fullscreen without asking. **Density in the URL.** `useDensityUrlState(options)` returns a `Density` (`"comfortable"` | `"compact"`) and `onDensityChange` to spread onto the table when you want the choice in the URL beside sort and filters — a reload or a shared link reproduces it. Pair with a controlled `density` prop; the chooser works without it. `UseDensityUrlStateOptions` / `UseDensityUrlStateResult` type it. Choosing the default removes the parameter rather than restating it. **Command palette.** `commandPalette` opens a palette on Cmd/Ctrl+K listing every table action — `true` for the built-ins, or `CommandPaletteOptions` (`{ commands, shortcuts }`) to add your own and remap the chord. A `Command` IS a `ContextMenuItem`, so an action is written once and offered in both places rather than drifting between them; `tableCommands(options)` builds the target-free ones (print, export, clear filters) and `filterCommands(commands, query)` is the case- and accent-folded substring match the input runs. `onPrint` on `` is what makes Print appear. Shortcuts are data: `Shortcut` is a chord and a command key, `DEFAULT_SHORTCUTS` is Cmd/Ctrl+K, and `useShortcuts(options)` binds them — `mod` means Cmd on a Mac and Ctrl elsewhere. Adapters build theirs over `CommandPaletteChrome` / `CommandPaletteChromeProps` / `CommandPaletteSlots` / `CommandPaletteSurfaceProps` / `CommandPaletteInputProps` / `CommandPaletteItemProps` and arm it with `useCommandPalette` (returning a `TableCommandPalette`; `UseCommandPaletteOptions` is the hook's input), from `@adapttable/react/adapter`. See [customization](./customization.md#command-palette). **Context menus.** `contextMenu` arms right-click menus for headers, rows and cells — `true` for the built-ins, or `ContextMenuOptions` (`{ items }`) to append your own behind a divider. `ContextMenuItem` is one entry (`key`, `label`, `onSelect`, and optional `disabled` / `danger` / `separatorBefore`); `ContextMenuTarget` is what was clicked; `ContextMenuActions` are the handlers the built-in entries call. Every route in works: right-click, Shift+F10, the menu key, and a long press. Adapters build theirs over `ContextMenuChrome` / `ContextMenuChromeProps` / `ContextMenuSlots` / `ContextMenuSurfaceProps` / `ContextMenuItemProps` and arm it with `useTableContextMenu` (`TableContextMenuOptions` is the hook's input; it returns a `TableContextMenu`: `regionProps` to bind once, plus `items`, `at` and `close`). `ContextMenuPoint` is the click coordinates the chrome hands the surface. All from `@adapttable/react/adapter`. The surface slot receives an `anchorRef` — a zero-size element at the click point — because every kit's menu positions against an element rather than coordinates. See [customization](./customization.md#context-menus). **Context-menu targets.** `resolveContextTarget(from, rowFor)` works out which header, row or cell an event happened in, returning a `ResolvedContextTarget` — the target and the element to put focus back on — or `null` when there is no menu there. It reads the `data-adapttable-part` names and `ROW_ID_ATTRIBUTE` (`data-row-id`), which every kit's rows and header cells carry, so an adapter binds one set of handlers to the element containing all three rather than to each of them. Precedence: a cell inside a row wins, a header cell is neither, and a click on the row outside any data cell is a row target. **Side panel.** `sidePanel` docks table settings beside the table instead of in a popover over them. `SidePanelOptions` types it — `panels`, `open`, `onOpenChange`, `side` — and `SidePanelEntry` is one panel (`key`, `label`, `content`). It is controlled, because the control that opens it is the host's. Adapters build theirs over `SidePanelChrome` / `SidePanelChromeProps` / `SidePanelSlots` / `SidePanelFrameProps` / `SidePanelTabProps` / `SidePanelCloseProps` and dock it with `SidePanelLayout` / `SidePanelLayoutProps` from `@adapttable/react/adapter`; the tab strip's keyboard contract lives in core, not in each kit. See [customization](./customization.md#side-panel). **Status bar.** `statusBar` puts a strip under the table reading the row range, how many rows are selected, and what a multi-cell selection adds up to. Adapters export their kit-owned `StatusBar` over `StatusBarChrome` / `StatusBarChromeProps` / `StatusBarSlots` / `StatusBarSlotProps` / `StatusBarItem` from `@adapttable/react/adapter`. It hosts the selection figures rather than repeating them: with `enabled` false the chrome renders those alone, which is why an adapter has one element here and no branch. The row range comes from the same arithmetic the pagination footer uses. `StatusBarChromeProps.notices` / `TableChrome.featureNotices` carry `FeatureNotice` values (`FeatureNoticeKind` names the inert opt-in) so a silent no-op stays visible even when `statusBar` is off. See [customization](./customization.md#toolbar-and-status-bar). **Toolbar regions and undo/redo.** `toolbar` fills the middle of the toolbar; `toolbarSlots` (`ToolbarSlots` — `start`, `end`) fills either end. `undoRedoButtons` adds Undo and Redo, which render only when `editHistory` is armed and disable rather than disappear; `undoRedoToolbar(wanted, history, labels)` from `@adapttable/react/adapter` is the one rule both wiring paths resolve that with. Labels are `undoEdit` and `redoEdit`. `printButton` adds a Print button, which renders only when `onPrint` is also wired; `printToolbar(wanted, onPrint, labels)` resolves that pair the same way (`PrintToolbar` is the resolved `{ onPrint, label }`), and the caption is `labels.print`. See [customization](./customization.md#toolbar-and-status-bar). **Reading a cell as text.** `columnText(column, row)` returns a column's cell as a string for anything that cannot render JSX. It resolves `formatValue` → `exportValue` → `sortValue` → `accessor` when that yields a primitive → the key's data path, and never returns `undefined`. The data path is used only for a column that renders no cell of its own: a column with `accessor: () => null` shows an empty cell, so reading its path would announce a value the user cannot see. See [columns](./columns.md). **Odds and ends.** `ComputedColumnSpec` is the declaration [`computed`](./columns.md) takes. `TableQueryKeyOptions` options the cache-key builders. `HeaderSelectionState` is the header checkbox's tri-state. `defaultSearchText` is the default searchable-text projector (a row's own values, flattened). `columnMenuLabel` gives a column its readable name in the menu (header string → `mobileLabel` → key). `runRowAction` runs a row action through the confirmation handler. `visibleRowActions` drops `isHidden` entries from a resolved list. `LayoutStorage` is the slice of the `Storage` API the column-layout hook needs, injectable for tests. `SavedViewsMenu` / `SavedViewsLabels` are the adapters' saved-views control and its strings, and `ToolbarChromeProps` is the toolbar's kit-agnostic prop surface. The CLI exports `Kit`, the union of UI kits `@adapttable/cli init` can detect. **Locale exports.** `@adapttable/i18n` exports one label set per locale, named by its tag: `ar`, `de`, `en`, `es`, `fa`, `fr`, `he`, `hi`, `it`, `ja`, `ko`, `pl`, `pt`, `ru`, `tr`, `ur`, `zh`, `zhTW`. See [i18n & RTL](./i18n-rtl.md). Adapter-machinery names (`headerGroupRows`, `insertExtraRows`, `useFullscreen`, `columnMenuActions`, `BodyCell`, …) resolve only from `@adapttable/react/adapter`. The aggregate `useChromeBodyData` hook is gone; choose `usePlainChromeBodyData` or `useVirtualChromeBodyData`. Notable non-hook helpers: `rowsToCsv` / `downloadCsv` / `downloadTableCsv` (CSV export — or compose `exportCsv()` for a built-in button), `sortRows` / `sortRowsMulti` / `compareValues` / `nextSort`, `computePagination`, `headerGroupRow` / `headerGroupRows` / `htmlGroupedHeaderPlan` / `groupedHeaderChildRule` / `groupedHeaderCellStyle` / `groupedHeaderLabelStyle` / `groupedHeaderAlign` / `columnGroupStubStyle` / `COLUMN_GROUP_STUB_WIDTH`, `columnGroupPath` / `columnGroupId` / `COLUMN_GROUP_ID_SEP` / `COLUMN_GROUP_STUB_PREFIX` / `COLUMN_GROUP_RENDER_PREFIX` / `isColumnGroupStubKey` / `isColumnGroupRenderKey` / `isColumnGroupSummaryKey` / `columnGroupHeaderCaption`, `flattenColumnTree` / `isColumnGroup` / `marriedOrderHolds` / `applyCollapsedColumnGroups` / `toggleCollapsedColumnGroup`, `columnHeaderLabel` / `columnHeaderController` / `resolveColumnHeader` / `resolveColumnFooter` / `columnsHaveFooter`, `ColumnHeaderController` / `ColumnHeaderContext` / `ColumnFooterContext`, `columnMenuRows` / `filterColumnMenuRows` / `columnMenuActions` / `showAllColumns` / `hideAllColumns` / `unpinAllColumns` / `resetColumnLayout`, `ColumnMenuAction` / `ColumnMenuActionContext`, `columnRowDragProps` / `columnDropProps` / `columnReorderKeyProps` / `columnResizeHandleProps` (RTL-aware), `pinnedCellStyle` / `edgePinStyle` / `PIN_Z`, `tableMinWidth` / `resolveColumnWidth` / `parsePxWidth`, `rowClickProps`, `resolveFilterDefs` / `buildFilterRuntime` / `filterPredicate` / `showSimpleFilterFields` / `materializeAutoOptions` / `clearedFilterExtras`, `builtInFilterSpecs` / `defaultFilterRegistry` / `resolveFilterRegistry` / `createFilterRegistry` / `emptyFilterRegistry` / `filterTypeSpec` / `filterWidgetKind` / `filterTypeOps` / `filterTypeDefaultOp` / `renderRegisteredFilter`, `mergeProps`, `stableKey`, `getPath`, `humanizeKey`, `resolveLabels` / `defaultLabels`, `pageSizeOptions`, and the constants `DEFAULT_LIMIT` (25), `PAGE_SIZE_OPTIONS`, `SEARCH_DEBOUNCE_MS` (300), `AUTO_OPTIONS_LIMIT` (50), `FILTER_AI_OPTIONS_LIMIT` (same 50, the assistant option cutoff), `ACTIONS_COLUMN_KEY` (`"actions"`). ## Feature composition types | Export | What it is | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TableFeature` | A composed feature: `id`, optional `apply`, `setup`, `provider` and `renders`. Row-aware factories return this and infer `TRow` from their callback. | | `StaticTableFeature` | A feature whose configuration says nothing about rows — `grouping("team")`, `virtualize()`, `columnMenu()`. Composes into any table with no type argument. | | `TableFeatureHost` | What `setup(host)` registers against: filter types, editors, aggregators, writers, column-menu actions, panels, commands, context-menu items. | | `StaticFeatureHost` | The same host minus the two row-shaped registrations, which is what a static feature sees. | | `standardFeatures(options?)` | On each kit's `/preset` entry: the zero-configuration members plus the ones whose options you supply. Returns a plain array you can extend. | | `StandardFeatureOptions` | `grouping`, `bulkActions`, `filters`, `savedViews` — each the argument its own factory already takes. | | `AdapterGroupingPanelFeature` | The overloaded kit `groupingPanel()` factory after an adapter supplies its group headers and panel chrome. | | `GroupingExtras` | Everything `grouping` takes beyond the key, including the row-shaped `groupAggregates` and `groupSort`. | | `StaticGroupingExtras` | The subset that says nothing about the row — paging, collapse state, footers — so `grouping(key, thoseOnly)` stays row-independent. | | `FeatureProps` | What a feature's `apply()` writes — the props v3 removed from ``. A host composes the feature instead; see [upgrading from v2](./migrate-from-v2.md). | | `ComposedTableProps` | `BaseDataTableProps` plus `FeatureProps`: the shape the table works with once features have applied, which is what an adapter's internals read. | See [feature composition](./features.md). ## Source capabilities What the data layer behind the table can actually do. A control that needs more than the source has is turned OFF and says why, instead of quietly doing something narrower — `scope: "all"` writing one page, `groupBy` doing nothing, "select all N matching" over rows nobody can name. | Export | What it is | | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TableSourceCapabilities` | The declaration on `TableSource.capabilities`: `fullDataset`, `grouping`, `selectAcrossPages`, `exportScope`, `totalCount`. | | `GroupingCapability` / `ExportScopeCapability` / `TotalCountCapability` | `"client" \| "server" \| false`, `"all" \| "page"`, and `"exact" \| "loaded"`. | | `sourceCapabilities(source, support?)` | The one place the answer is decided: the source's own declaration when it has one, otherwise inferred from its shape (and `QuerySupport.grouping` when given). | | `CapabilitySource` | The handful of fields that read consults — enough to ask without holding a whole source. | | `capabilityReason(capability)` | The sentence a kit puts on the control it disabled. Localized copy overrides it through `TableLabels`. | | `offersAllMatching(selection, total)` | Whether the "select all N matching" banner applies: the source can reach past the page, the page is fully selected, and more rows match. | | Capability | Runtime consequence | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `fullDataset` | When false, only the page on screen is reachable. | | `grouping` | When false, `groupBy` is ignored, the status bar carries `noticeGroupingUnavailable`, and dev mode warns. | | `selectAcrossPages` | When false, selection stays on the rows on screen; the banner never appears and `selectAllMatching()` no-ops. | | `exportScope` | `"all"` permits a source-owned full export only when `allFilteredRows` actually supplies the rows. A declaration alone is not transport. `request` or `fetchAll` is an independent executable route; with none, Export all stays disabled. | | `totalCount` | `"loaded"` means `total` is what has loaded rather than the match count. | Omitting `capabilities` changes nothing: the same answers are inferred from the source's shape. See [data tiers](./data-tiers.md#what-a-source-can-do--capabilities). ## Types | Type | What it is | | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `TableSource` | The uniform data + state contract a table consumes (rows, total, `defaultLimit`, `allFilteredRows`, `allSearchedRows`, `facets`, loading flags, state read/write). Server grouping that cannot honour `query.aggregates` sets `honorsAggregates: false`. | | `TableQuery` | The consolidated server-tier query: `page`, `limit`, `search`, `sortBy`, `sortDir`, `sortLevels`, `filters`. | | `TableQueryParams` | Baseline query params a backend list endpoint receives. | | `QuerySupport` | What a server endpoint can answer: `grouping`, `aggregates`, `filterTree`, `facets`, `cursor`. | | `QueryExtensions` | The optional query fields those capabilities unlock, carried on `TableQuery`. | | `QueryAggregate` | One aggregate to compute: `{ key, fn }` where `fn` is a `AggregateFn` or your backend's own name. | | `aggregate` | Builds a `summaryRow` / `groupAggregates` mapper from a declaration — see [row grouping](./row-grouping.md). | | `AggregateName` / `AGGREGATE_NAMES` | The built-in aggregate names: `sum`, `avg`, `count`, `min`, `max`. | | `Aggregator` / `AggregateSpec` / `AggregateOptions` | A custom aggregate function, the per-column declaration, and `aggregate()`'s options (`columns`, `format`). | | `Aggregatable` / `AggregatableConfig` / `AggregateOperation` / `CustomAggregateOperation` | A column's reader-controlled offer: `false` / `true` / `{ default?, operations }`. `operations` is built-in names or `{ id, label, calculate? }`. | | `AggregateOperationId` | A built-in name or a host's own stable id. Sent to a server; never a function. | | `ResolvedAggregatable` / `ResolvedAggregateOperation` / `resolveAggregatable` / `resolveAggregatableColumns` / `impliedOperations` | The validated offer for one column or every column. `aggregatable: true` reads `impliedOperations` from the declared filter/editor type. | | `allowsOperation` / `allowsReaderOperation` / `offerableOperations` / `AggregationSourceSupport` | The one execution gate: a column must offer the id, and the source must be able to compute it. | | `AggregationModel` / `AggregationModelInput` / `AggregationItem` / `AggregationCandidate` / `AggregationOrigin` / `aggregationModel` | The list the panel and column menu both draw: active items, addable candidates, whether the reader is still at the developer's defaults, and `hasDefaults` when restore would put a developer baseline back. | | `resolveEffectiveAggregation` / `EffectiveAggregation` / `EffectiveAggregationInput` / `EffectiveAggregationKind` / `effectiveAggregateOps` / `readerControlAllowed` / `columnAggregationSignature` | Shared precedence for display and execution: a valid reader override or permitted suppression, then a valid executable column default, then the original host mapper or request. `EffectiveAggregationKind` is that one owner. `effectiveAggregateOps` is the map the calculator and the first server request both use. | | `toAggregateInstant` / `toAggregateOrdered` / `AggregateOrderedValue` | How `min` / `max` compare a value: a `Date`, a finite number, or a strict ISO date / datetime / time string (`AggregateOrderedValue`). Locale forms are skipped, not guessed. | | `addAggregation` / `removeAggregation` / `restoreAggregationDefaults` / `reconcileAggregations` / `initialOperation` / `declaredByDeveloper` / `AGGREGATE_SUPPRESSED` | Reader transitions and URL/saved-view cleanup. Removing a developer default writes `AGGREGATE_SUPPRESSED` (`"none"`) only while the reader may still choose that column. | | `computedAggregateKeys` | Column keys that already have an aggregate cell in the current group rows — enough to show a read-only opaque mapper, not enough to name its operation. | | `CUSTOM_AGGREGATE` / `DeclaredAggregates` / `declaredAggregates` / `withDeclaredAggregates` / `GroupAggregatesMapper` | Metadata on `aggregate()`: a declared mapper's operation ids, or `CUSTOM_AGGREGATE` when the host supplied a function. `withDeclaredAggregates` tags a mapper so Restore defaults can name those ids. | | `AggregateFn` | The standard aggregate names: `sum`, `avg`, `count`, `min`, `max`. | | `QueryCondition` / `QueryFilterGroup` | A leaf condition (`key`, `op`, `value`) and a nestable AND/OR group of them. | | `isFilterGroup` | Narrows a filter-tree child to a nested group while walking the tree. | | `PaginatedResponse` | Standard envelope: `items`, `total`, `page`, `limit`, `hasNext`. | | `ColumnLayoutState` | `{ hidden, order, pinned, widths, names?, collapsedGroups? }` — the column-layout shape. | | `ColumnGroupDef` / `ColumnInput` | A parent header with `children`, or a leaf-or-parent union. See [column groups](./column-groups.md). | | `ColumnGroupShow` | `"open" \| "closed" \| "always"` — when a leaf under a collapsible group is visible. | | `ColumnGroupRecord` / `FlattenedColumns` | Collapse policy for one parent, and the `{ leaves, groups }` result of `flattenColumnTree`. | | `TableLabels` | Every string the table renders; all keys optional, English defaults fill gaps. | | `RowAction` / `BulkAction` | Action definitions with `disabledReason`, `isHidden`, optional `confirm` wiring. | | `RowActionsLayout` | `"buttons" \| "menu"` — omit / `"buttons"` is the strip; `"menu"` is the 3-dot control. | | `RowActionsRenderContext` / `RowActionsRenderer` | What `renderRowActions` receives (`row`, `actions`, `confirm`, `labels`) / the renderer type. | | `visibleRowActions` | Filters `isHidden` actions out of a resolved list. Default layouts use this; a custom cell can keep hidden ones. | | `BulkActionContext` | `{ allMatching, total }` — scope handed to a bulk action handler. | | `ActionConfirm` | Confirmation dialog wiring (`title`, `message`, `confirmLabel`, `danger`). | | `ActionAiOptions` / `ActionApprovalPolicy` / `ApprovalPresentation` | What a row or bulk action says about being invoked by an agent — approval overrides that inherit field by field from the shared assistant configuration / `"required"` or `"automatic"`, whether a human is asked / `"widget"`, `"table"` or `"modal"`, where they are asked. Distinct from `confirm`, which is a person confirming their own click. See [agent capabilities](./agent-capabilities.md). | | `ConfirmHandler` / `ConfirmRequest` | The injectable confirmation seam (`(request) => void`). | | `defaultConfirm` | The built-in `ConfirmHandler` (`window.confirm`; DENIES when no dialog exists). | | `ActiveFilterChip` / `ChipLabelResolver` | One removable chip (`key`, `label`, `onRemove`) / value → chip-label function. | | `UrlStateAdapter` | The router seam: `getSearch()`, `setSearch(search, { push? })`, `subscribe(onChange)`. | | `SavedView` | `{ name, search }` — one captured view. | | `FilterDef` / `FilterType` / `FilterOption` / `FilterOptionsSource` / `FilterAiOptions` | The declarative filter surface (see [FilterDef](#filterdef)), including what the assistant may see. | | `FilterTypeSpec` / `FilterTypeRegistry` / `FilterWidgetKind` | One registered type / the immutable registry / which built-in widget to draw. `register` / `extend` are deprecated — use `TableFeatureHost.registerFilterType` / `extendFilterType`. | | `FilterWidgetRenderProps` | Props a custom `FilterTypeSpec.render` receives. | | `CellProps` | `{ row, rowIndex }` — what a `Cell` component receives. | | `SortDirection` / `SortLevel` | `"asc" \| "desc"` / one entry in the multi-sort chain. | | `Direction` | `"ltr" \| "rtl"`. | | `ColorScheme` | `"light" \| "dark" \| "auto"`. | | `PaginationMode` | `"infinite" \| "paged" \| "auto"` (`"auto"` resolves by viewport: mobile → infinite). | | `FilterValue` / `ExtraFilters` | One URL-round-tripped filter value / the keyed bag of them. | | `ColumnMetadata` / `ColumnModel` | The neutral column the engine reads, and its alias on the main entry. `ColumnDef` is the React column that extends it. | | `ColumnModelFilter` / `ColumnModelEditor` | A neutral column's declarative filter and editor, before a binding narrows them — `ColumnDef` narrows both to `ColumnFilter` and `CellEditor`. | | `DisplayValue` | What a binding stores for a cell it will render — a primitive, or an object only that binding understands. The engine hands it back untouched and stringifies it only when it is already a primitive. React's faces of these say `ReactNode` instead. | | `MobileCardRenderer` | The neutral mobile-card renderer. A React table wants `ReactMobileCardRenderer`, whose field values are `ReactNode`. | | `SummaryRowFn` | Map a set of rows to per-column summary cells — one shape for `summaryRow` and `groupAggregates`. The React face of core's neutral `GroupAggregatesFn`, so its values are `ReactNode`. | | `ReactComputedColumnSpec` | Input to `computed` from `@adapttable/react`: the neutral spec with a React `column`. | | `ReactFormulaColumnsResult` | What `buildFormulaColumns` from `@adapttable/react/formula` returns — the neutral result with `ColumnDef` columns. | | `ReactColumnGroupDef` | A parent header whose children are React `ColumnDef`s. | | `ReactUseColumnLayoutResult` | `useColumnLayout`'s result over React columns. | | `ReactMobileCardField` / `ReactMobileCardModel` / `ReactMobileCardRenderer` | One card field / a card's model / a custom card body, with `ReactNode` values rather than the neutral model's `DisplayValue`. See [mobile cards](./mobile.md). | | `ReactColumnResizeHandleProps` / `toReactColumnResizeHandleProps` | Resize-handle props typed for a React element / convert the neutral props to them. For adapter authors, from `@adapttable/react/adapter`. | | `TableEngineConfigPatch` | Live configuration a binding replays onto a committed engine: the incremental query options plus `locale`, `paginationMode`, `page` and `limit`. | | `CapabilityPlan` / `CapabilityStaging` | A governed write's side-effect-free proposal / whether a capability honours `commit: "stage"`. See [AI integrations](./ai-integrations.md). | | `CapabilityPartial` | Whether a capability's `execute` applies `plan.payload` rather than its own arguments, and may therefore be offered for row-by-row approval. Defaults to `"unsupported"`. See [agent capabilities](./agent-capabilities.md). | | `ApprovalSubject` / `ApprovalResult` | What a reader is asked to confirm — the rows a write touches, or the operation behind a write that enumerates none / what they decided: a boolean for the whole write, or the positions they approved. See [agent capabilities](./agent-capabilities.md). | | `SharedApproval` | The assistant's own approval defaults — the policy string it already took, or `{ policy, presentation }`. An action overrides either field on its own through `ai`. See [agent capabilities](./agent-capabilities.md). | | `SortableValue` | Comparable primitive returned by a sort-value extractor. | | `SortByOption` | `{ value, label }` for the mobile sort-by select. | | `GetCellSpan` / `GetCellSpanArgs` / `TableBodyCell` / `BodyCell` / `CellSpanRequest` / `CellSpanAppearance` | Span callback / `{ row, column, rowIndex, columnIndex, sectionRows, sectionRowIndex }` / one rendered body cell on the main / adapter entry / `{ colSpan?, rowSpan? }` / `"merged" \| "plain"`. See [row and column spanning](./row-spanning.md). | | `ExtraRow` / `TableExtraEntry` / `ExtraEntry` / `ExtraRowKind` | Host-injected slot / the spliced main-entry / adapter-entry shape / `"separator" \| "fullWidth"`. See [full-width and separator rows](./full-width-rows.md). | | `insertExtraRows` / `insertExtrasBeforeRows` / `extraRowsForSection` / `isExtraEntry` / `extraRowsArmed` / `EXTRA_ROW_PARTS` / `EXTRA_OVER_SPAN_ROW_STYLE` / `EXTRA_OVER_SPAN_STYLE` / `extraHostFillStyle` / `extraCountBeforeRowIds` / `inflateBodyCellRowSpans` / `extraCoveredTableSlots` / `extraUncoveredColSpans` | Splice extras into a `kind`-tagged list / splice named extras in front of a pin section / extras whose target sits in this section / narrow one / whether any were asked for / the part names kits stamp / lift an extra above a continuing span / extra cell padding and RTL align / the host `rowStyle` fill for that extra's person / count extras in front of ids / grow a row span so extras do not drop the last person / slots an extra would leave open / uncovered colSpans for that extra. | | `RowStyle` / `RowHeight` | Per-row style callback / a number or `(row, index) => number`. See [row styling and heights](./row-styling.md). | | `resolveRowStyle` / `resolveRowHeight` / `rowStyleSignature` | Merge style + height / read one height / memo digest of the resolved style. | | `rowStyleArmed` / `estimateFromRowHeight` | Whether either hook was passed / virtualizer `estimateSize` from `rowHeight`. | | `RowPinState` / `RowPinSide` | `{ top, bottom }` id lists / `"top" \| "bottom"`. See [row pinning](./row-pinning.md). | | `RowPinningState` / `RowPinLabels` | Headless pin state and the three action strings. | | `ExportCsvProp` | The configuration accepted by `exportCsv()`: `true`, an `ExportCsvOptions`, or absent. See [export](./customization.md#export). | | `WidthColumn` | A column reduced to what width resolution reads — its `key` and declared `width`. | | `GroupedHeaderAlign` | Alignment of a spanning group header: `"start"`, `"center"` or `"end"`. See [column groups](./column-groups.md). | | `BuildGroupedFlatModelOptions` | What `buildGroupedFlatModel` flattens — `rows`, `groupBy`, `columns`, `getRowId` and the collapse/aggregate options. See [row grouping](./row-grouping.md). | | `HorizontalOverflow` | `useHorizontalOverflow`'s result — a callback `ref` and `overflowing`, true only while the content is wider than its wrapper. | | `FeatureNoticeAppearance` | How an inert feature already looks to the person at the table: `"off"`, `"disabled"` or `"one-page"`. | | `SESSION_ATTR` | `data-adapttable-header-filter` — the attribute tying a header filter's trigger to its overlay, so one editing session is identifiable across both. See [filtering](./filtering.md). | | `TableCommandOptions` | What `tableCommands` needs for the table-wide entries: labels, plus `onPrint` / `onExport` / `onClearFilters` and `hasFilters`. | | `UseShortcutsOptions` | What `useShortcuts` needs: `enabled`, the `shortcuts` list, `onCommand`, and where to listen. | | `ValidationCheckResult` | The outcome of `EditValidationState.check` — `allowed`, and `error` when it is not. See [cell editing](./cell-editing.md). | | `editableCellController` | Derives one cell's editing controller. Returns `mode: "display"` whenever the host passed no `onCellEdit`, so a table that never opted in renders exactly as before. | | `FeatureHostState` | The registrations a feature's `setup` collected — filter types, queued patches, menu factories, side-panel entries — as the table reads them. See [feature composition](./features.md). | | `FilterTypeExtend` | One queued `extendFilterType` patch: the `type` being extended and the `patch` merged onto its spec. | | `ColumnMenuActionFactory` | A plugin's extra column-menu actions, appended after the built-ins. | | `ContextMenuItemsFactory` | A plugin's extra context-menu entries, appended after the built-ins. | ## Development warnings Misconfiguration warns once per message in development (silent in production): - no data tier, or both `source` and `data`/`onQueryChange` provided; - duplicate column keys (sorting, selection, and column layout all target keys); - unresolvable sorts (no matching column, or a non-primitive accessor without `sortValue`); - a column `filter` whose key is also defined in the `filters` array (the array wins); - `options: "auto"` on a tier with no full dataset, and async options that fail to load; - two tables sharing a URL namespace without distinct `urlKey`s; - `virtualize` combined with `renderRowDetail` (detail panels are unmeasured sibling rows). ## Companion types Every documented hook and component exports its option/result/prop types under predictable names — `useFoo` ships `UseFooOptions` (and `UseFooResult` where the return type is named), a component `Foo` ships `FooProps` — and each companion follows its owner's stability tier. The full set: `UseDataTableOptions`, `UseFrontendDataOptions`, `UseServerDataOptions`, `UseTableDataOptions` / `UseTableDataResult`, `UseTableUrlStateOptions`, `UseSavedViewsResult`, `UseSelectionOptions`, `UseColumnLayoutOptions`, `UseColumnLayoutStorageStateOptions` / `UseColumnLayoutStorageStateResult`, `UseColumnLayoutUrlStateOptions` / `UseColumnLayoutUrlStateResult`, `UseRowPinningUrlStateOptions` / `UseRowPinningUrlStateResult`, `UseQuerySourceOptions` (`selectPage` plus optional `selectorKey` to re-project unchanged pages), `UseActiveFilterChipsOptions`, `UseExtraChipsOptions`, `UseBulkActionRunnerOptions`, `UseBulkBarStateOptions`, `UseInfiniteScrollOptions`, `UseScrollToTableTopOptions`, `UseTableVirtualizationOptions`, `MountStaggerOptions`. ## The adapter contract Everything the eight built-in adapters are made of ships from its own entry point, **`@adapttable/react/adapter`** — the same public surface a ninth adapter would use; there are no private channels. Same package, same semver promise as the main entry. This tier is aimed at adapter authors; app code rarely (if ever) imports from it: ```ts import { useDataTableShell, paginationSlots } from "@adapttable/react/adapter"; ``` A handful of names stay on the main entry even though adapters also use them, because app-facing signatures reach them (`PinSide` in the column layout state, `PaginationInfo` from `computePagination`, the `useDataTable` prop-getter payload types, `ResolvedPaginationMode`, `TableLayout`). **Orchestration.** `useDataTableShell(props, renderAutoForm)` is the whole shared engine behind a batteries-included `` — it resolves the data tier, builds the declarative-filter runtime, wires the chrome, and returns the `tableProps` / `toolbarProps` bundles (`DataTableShellTableProps`, `DataTableShellToolbarProps`, `DataTableShellChromeProps`, `DataTableShellGroupingPanelProps`). `useStickyToolbarLayout` and `resolveStickyToolbar` park search and page-size with a sticky header. `DataTableShellProps` is its kit-agnostic prop surface and `DataModeProps` the discriminated `mode` union inside it (`mode="server"` requires `onQueryChange` at compile time). `tableRenderModel(props)` / `TableRenderModel` derive the shared render prelude from `SharedTableRenderProps`; `TableBodyRegion` names which body region renders (desktop rows, mobile cards); `VirtualTableRow` is one materialized virtual row/card entry. HTML kits assemble the desktop table through `useDesktopTableAssembly` (`DesktopAssemblyOptions` / `DesktopAssemblyProps` in, `DesktopTableAssembly` out) and `createDesktopRow` — wiring, not pixels. The reserved chrome widths are `DESKTOP_SELECTION_WIDTH`, `DESKTOP_EXPANSION_WIDTH`, `DESKTOP_ACTIONS_WIDTH` (override via `DesktopChromeWidths`). The plan names `DesktopHeaderLeaf`, `DesktopTablePin`, `DesktopRowWiring`, `DesktopRowSlot`, `DesktopBodySlot`, `DesktopGroupSlot`, `DesktopGroupEntry`, `DesktopExtraSlot`, and `DesktopVirtualPadSlot`. Ant Design stays on its native `
`. See [customization](./customization.md#desktop-table-assembly). `useResolvedAdapter` resolves the URL backend the way the shell does; `PageSelector` projects a fetched page to rows, an optional total, and optional `facets`, and `InfiniteQueryLike` is the minimal `useInfiniteQuery` shape `useQuerySource` reads (structural — TanStack Query stays a type-only peer). **Render plumbing.** The prop-getter payload types (`TableElementProps`, `RowElementProps`, `CellElementProps`, `SearchInputElementProps`, `SortButtonElementProps`, `RowClickProps`) name what `useDataTable`'s getters and `rowClickProps` return. Pinning: `PinSide` / `PinnedSide` / `PinOffset` / `PinLeads` describe the layout, `nextPinSide` cycles a column's pin, `pinActionLabel` labels the action, and `pinnedDataCellStyle` / `pinnedEdgeCellStyle` / `pinnedColumnWidth` / `PinnedCellStyle` compute direction-aware sticky styles. Pager math: `paginationSlots` / `paginationItems` build the windowed pager model (`PaginationSlot`, `PaginationItem`, `PaginationInfo`). Column chrome: `ColumnMenuChromeProps`, `ColumnMenuSlotProps`, `ColumnMenuRow`, `ColumnMenuLabels`, `ColumnDragState`, `ColumnDragRowAttrs`, `ColumnDropProps`, `ColumnRowDragProps`, `ColumnReorderKeyProps`, `ColumnResizeHandleProps` and `COLUMN_DND_MIME` power the column menu's reorder/resize/pin rows. Toolbar glue: `SearchInputState` (debounced search binding), `FilterTriggerToggle` (popover/drawer trigger handlers). Editing/grouping glue: `focusEditorOnMount`, `rowEditingSignature`, `HeaderGroupCell`, `HtmlGroupedHeaderCell`, `headerGroupRow` / `headerGroupRows` / `htmlGroupedHeaderPlan` / `groupedHeaderChildRule` / `groupedHeaderCellStyle` / `groupedHeaderLabelStyle` / `groupedHeaderAlign` / `columnGroupStubStyle` / `COLUMN_GROUP_STUB_WIDTH`, `columnGroupHeaderCaption`. Each adapter mounts `ColumnGroupToggle` / `ColumnGroupToggleProps` over `ColumnGroupToggleChrome` / `ColumnGroupToggleChromeProps` / `ColumnGroupToggleSlots` / `ColumnGroupToggleButtonProps`. Shared utilities: `logicalAlign` (logical → physical alignment), `mergedCellStyle` (spreadsheet merge paint for a spanned cell), `cellSpanMark` (`"2x1"` on the origin), `cellFlashAttr` / `rowFlashSignature` (`data-flash` on a patched cell), `resolveMobileLabel` (a card field's caption), `isSelectedCell` (whether a cell's props put it inside the selected range, for a kit applying its own fill), `shallowEqualByKeys`, `resolveVirtualRows`, `SHARED_DESKTOP_ROW_KEYS`, `DEFAULT_CARD_SIZE_PX`, `useKeyedVirtualization` / `KeyedVirtualization` (virtualize an opaque keyed list, e.g. grouped entries), `useMountStagger` (the `animate` stagger), `useOverlayTransition` / `OverlayTransition` (turns an `open` boolean into `{ rendered, state }` — `rendered` outlives `open` by one exit so an overlay has something to animate on the way out, and reduced motion skips both edges), `OVERLAY_MOTION` (the durations and curves the unstyled, shadcn, Base UI and Radix drawers share), `useEscapeClose` / `EscapeCloseOptions` (close an overlay on Escape wherever focus is, since kits disagree about when they do it themselves — pass `ignoreWithin` a selector for controls inside that answer the key first, so one Escape closes one layer), `restoreFocusSoon` (hand focus back to the control an overlay was opened from, reclaiming it once if the kit's own focus handling drops it, and leaving focus a reader moved elsewhere alone), and the inline icon set (`FiltersIcon`, `SearchIcon`, `EyeIcon`, `GripIcon`, `PinIcon`, `ExpandChevron`, `sortArrow`). Row reorder chrome (on each adapter): `RowReorderHandle`, `RowReorderHandleProps`, `RowReorderButtons`, `RowReorderButtonsProps`. Layout: `RowReorderHandleChrome`, `RowReorderButtonsChrome`. Also `RowReorderAnnouncer`, `rowReorderSignature`, `REORDER_COLUMN_WIDTH`, `ROW_DND_MIME`. Row pin chrome: `rowPinSignature`, `rowSourceIndex`, `pinnedRowStickyStyle`, `pinnedRowCellStyle`, `pinnedRowPart`, `pinnedRowSticky`, `orderedCardEntries`, `bindMobileCardList`, `mobileCardListStyle`, `useOffsetHeight`, `PINNED_TOP_PART`, `PINNED_BOTTOM_PART`. **Bulk actions.** `useBulkBarState` / `BulkBarState` / `BulkBarChromeProps` derive everything a bulk-action toolbar renders (selected ids, in-flight action, the "select all matching" banner); `BulkActionOutcome` is a run's result and `bulkActionErrorMessage` its failure text. **Misc helpers.** `deriveSortByOptions` builds mobile "Sort by" options from sortable columns; `resolveColumns` fills declarative column defaults (humanized headers, locale-resolved accessors); `resolveDisabledReason` normalizes a row action's `disabled`; `useSummaryCells` maps a `summaryRow` builder over the visible columns; `ResolvedPaginationMode` is `paginationMode` after `"auto"` resolves; `TableLayout` names which layout is rendering (desktop table or mobile cards); `TableStateMutators` is the setter half shared by `TableSource` and `useTableUrlState` — the same mutations exist whether state lives in the URL, in memory, or behind a server query; `localizedColumnPath`, `normalizeLocaleTag` and `resolveLocaleTag` are the shared locale-resolution algorithm (see [i18n & RTL](./i18n-rtl.md)). ## The AI packages `@adapttable/ai` is React-free and model-neutral: it turns a live table into a capability catalog an agent can read and call, and every protocol adapter below is a view over that one catalog. `@adapttable/ai-react` is the binding that mounts it on a React table. See [AI & agents](./ai.md), the [HTTP contract](./ai-http.md) and [protocol integrations](./ai-integrations.md). ### `@adapttable/ai` — the session and its catalog `createAgentSession(options)` builds the session from a `CreateAgentSessionOptions` description of the table: `AgentApply` is the write seam the host fills, `AgentObservation` is what the table reports each time it is read, `AgentColumn` / `AgentFilter` / `AgentFilterOption` describe its shape, `AgentLimits` and `AgentPolicy` its bounds, and `AgentRowAddressing` / `RowAddressScope` say how a row may be named. The result is an `AgentSession`: `buildManifest` renders its `AgentManifest` (and per-column `AgentManifestAggregation` ids), `enabledKeys` lists what the table actually offers, and `CAPABILITY_KEYS` / `CapabilityKey` are the built-in names. A capability is a `CatalogEntry` backed by an `AgentCapabilityDefinition`. `AgentCapabilityKind` is its effect class (`read`, `view`, `write`, `destructive`), which is what decides how much ceremony a call needs; `CapabilityFamily` and `familyOf` group related keys for one-round discovery, capped at `MAX_FAMILY_GUIDES`. `CapabilityGuide` is the per-capability instruction, read with `guideOf` and `summaryOf`; `AgentCapabilityContext` is what a custom capability's handler receives, and `CapabilityPartial` / `CapabilityPlan` / `CapabilityStaging` describe work a capability can stage rather than apply at once. `JsonSchema` and `validateSchema` are the argument contract. Calls return an `ExecuteResult` (`WriteExecuteResult` for writes, with `WriteRowResult` per row) or an `ExecuteError`. `AgentColumnAuthoring` is the column-level authoring a table author supplies. **Discovery.** `discover(request)` answers a `DiscoveryRequest` with a `DiscoveryResult` drawn from a `DiscoverySource`, so a model asks once instead of being handed everything. `createDiscoveryCache` memoizes it as a `DiscoveryCache`, holding `DEFAULT_CACHE_GUIDES` guides across `DEFAULT_CACHE_VERSIONS` contract versions. **Context.** `buildAgentContext(session, options, inputs)` renders an `AgentContext` from `AgentContextOptions` and `AgentContextInputs`; `AgentContextProfile` picks how much to send — `full` by default, bounded only by `MAX_CONTEXT_BYTES`, with `DEFAULT_COMPACT_TOKENS` the budget `compact` opts into, a guide left out carries a `DeferralReason` saying which of the two cut it, and `ContextIncludeError` names an `include` entry the table does not publish. `ContextCapability` and `ContextColumn` are the rendered pieces, `AgentContextContract`, `AgentContextView` and `AgentContextSelection` the halves a request carries. `renderAgentContext(context)` turns it into the prompt text and `agentInstructions(input)` writes the general rules beside it from an `AgentInstructionsInput`; `agentSystemPrompt` composes the two. `sampleColumnValues` and `sampleColumns` give a model real values to match on, bounded by `SAMPLE_CAP`, with `matchesType` deciding what is worth sampling. Row values reach a model only inside `rowProvenance`'s `RowProvenanceEnvelope`, which marks them untrusted; `RowWindow`, `RowWindowRow`, `RowReadQuery`, `RowRef`, `RowKeyRef`, `RowPositionRef` and `ResolvedRow` are the read surface behind it. **Approval.** `sharedApproval` and `resolveApproval` turn a `SharedApproval` into a `ResolvedApproval`. A turn opens with `openTransaction`, collects `PendingApproval`s, records answers with `recordDecision`, settles them with `settleDecisions` and ends at `closeTransaction`; `ApprovalTransaction`, `ApprovalSubject`, `ApprovalOutcome` and `ApprovalResult` are its types. "Always allow" is deliberate rather than sticky: `createApprovalMemory` holds an `ApprovalMemory` scoped to a contract version, `mayAlwaysAllow` says whether a capability is eligible, and `assertAlwaysAllow` validates an `AlwaysAllowInput` against the live catalog at build time, raising `ApprovalAlwaysAllowError` on a key no table offers — a typo opts nothing in silently. **Assistant.** `createTableAssistant(inputs)` is the headless conversation store: `TableAssistantInputs` configures it, `TableAssistantStore` is the handle and `TableAssistantSnapshot` the value a view renders, holding `AssistantMessage`s and an `AssistantStatus`. A turn is an `AssistantTurn` in an `AssistantConversation` of `AssistantExchange`s, produced by an `AssistantPlanner` or carried by an `AssistantTransport` (`AssistantRequest` in, `AssistantTransportReply` out). `AssistantAction`, `AssistantProposal` and `WriteProposal` are what it asks to do, `AssistantOutcome` / `AssistantOutcomeStatus` how it ended, and `AssistantUnresolved` why it could not. `AssistantQuestion`, `AssistantQuestionOption` and `AssistantAnswer` are the question channel. `AssistantSuggestion`s are filtered by `eligibleSuggestions` and checked by `assertUniqueSuggestions`; `CapabilityPresentation` says how a capability is shown. Every turn is given an `AssistantTurnInput` — an `AssistantSendInput` when the reader asked and an `AssistantResumeInput` when it is rejoining — and a transport that names work outliving its connection through `onResumable` hands back an `AssistantResumeHandle` that `resume` takes. `AssistantInterruption` says which of the three ended a turn no reply ended. A capability says how far it has got through `CapabilityProgress` on its execution context; the session names the call and passes it on as `AgentProgress`. Receipts come from `receiptFromResult` / `receiptsFromResults` as `AssistantReceipt`s with an `AssistantReceiptStatus` and `AssistantReceiptSubject` — `subjectFor` builds one for a built-in capability, as `AssistantReceiptTerm` pairs of column label and formatted value — and `turnStatus` reads the `AssistantTurnStatus`. **Undo.** `planUndo` turns a finished turn into an `AssistantUndo` of `UndoCall`s, or an `UndoBlock` saying why not (`isUndoBlock` narrows it, `undoBlocked` explains it); `runUndo` applies one, and `AssistantUndoOffer` is what the panel shows. **The table binding.** `TableAgentBridge` is how a host receives live updates — a session to attach, a manifest to publish, a pending approval, a reader for the live view, and an `AlwaysAllowedState` naming what the reader has agreed to stop being asked about, with the `revoke` that takes one back. The panel reads those back as `AssistantAllowance` entries — one per capability, each carrying the name the table knows it by. A binding states what its own runtime offers through `ObservationInputs` and `agentObservation` turns that into the observation a session reads the table through, so every binding answers "what can this table do" the same way. `contractFingerprint` and `contractVersion` identify the contract a backend was sent, `displayProposals` and `ProposalResolver` render proposed writes. `observationFromNeutral`, `agentColumnsFromNeutral`, `readRowsFromNeutral`, `resolveRowFromNeutral`, `monotonicRevision`, `revisionToken`, `LiveObservationOptions`, `NeutralQueryOverlay` and `TableAgentColumnPatch` build that observation from the neutral engine, and `agentFiltersFromDefs` with `FilterCatalogColumnPatch` build the filter half. Aggregations are described by `AgentAggregations`, `AgentAggregationsPatch`, `AgentAggregationColumn` and `AgentAggregateOperation`, and applied with `aggregationsFor` / `applyAggregations` over `AggregationInputs` and `AggregationState`. Cell writes are `AgentCellEdit`s. **Streaming.** `createStreamReply` emits `AgentStreamEvent`s of `AgentStreamEventKind`, capped at `MAX_STREAM_EVENTS`; `splitRecords` and `parseStreamRecord` read them back, and `AgentStreamError` is the failure. **What a table's pages are.** `agentPagination` turns what a binding can say about its own source — `PaginationInput`: the current page and size, the sizes the host offers, the counted total for the _current query_ where there is one, whether a page number may be named — into an `AgentPagination`. It divides the page count from the total and works out whether a further page exists; anything unknowable is left absent rather than guessed, so "there is no next page" stays distinct from "nobody counted". `pageRefusal` and `pageSizeRefusal` answer whether a requested page or size can be served, in a sentence naming both sides. The session checks both before the host is called, so a page past the end is refused rather than reported as a move that happened. **What a table can do.** `agentObservation` decides that once, for every binding: a capability is available when the host wired a callback for it or the runtime already offers it. A binding states its own half — `ObservationInputs`, carrying `RuntimeOperations` for what the runtime does by itself, the host's `AgentApply`, an `ObservedPolicy` and an `ObservedView` — and reads the answer back. The page and size it publishes come from the `AgentPagination` it carries, so the bound a session enforces and the pages a model is told about are the same number by construction. **When the context will not fit.** A `compact` build, or any build given a `tokenBudget`, budgets the whole payload. Column descriptions that do not fit are named in `selection.deferredColumns` and fetched with `columns.describe`; a deferral says which kind of thing it was through `DeferralKind`. A `tokenBudget` the caller chose that nothing can satisfy raises `ContextBudgetError`, which names the floor the table costs with everything deferrable already deferred. ### `@adapttable/ai/json` and `/openai` Two shapes of the same catalog: JSON Schema tool definitions, and OpenAI function tools whose names are mapped by `openAiToolNameMap` because the provider's naming rules are narrower than a capability key. ### `@adapttable/ai/http` — the wire contract `agentHttpJsonSchema` is the published request schema as a `JsonSchemaDocument`, and `AGENT_HTTP_LIMITS` / `AgentWireLimits` are its bounds. A reply carries `AgentHttpToolCall`s and answers them with `AgentHttpToolResult` / `AgentHttpToolFailure`, whose payload is an `AgentHttpToolValue` (`isToolValue` narrows it); `AgentHttpAnswer` is the text, `AgentHttpQuestion` with `AgentHttpQuestionOption` the question channel, `AgentHttpAudio` a spoken turn, and `AgentHttpUnresolved` a turn that stopped. `AgentTurnError` is the typed failure. `PhaseState` is the phase-bound execution record that makes a replayed call identical rather than repeated. Contract pinning is a `PinRecord` with a `PinStatus`, held for `DEFAULT_PIN_TTL_MS` across at most `DEFAULT_PIN_CONNECTIONS` connections, and acknowledged with an `AgentHttpPinAck`. ### `@adapttable/ai/webmcp` — in-page tools `registerWebMcpTools(session, options)` publishes the catalog on `document.modelContext` — `ModelContextLike` is the shape it needs, so a test or a polyfill can stand in. `WebMcpOptions` configures it, `WebMcpRegistration` is the handle, and each `WebMcpTool` carries `WebMcpAnnotations` derived from the capability's own `kind` rather than authored twice. Results are a `WebMcpResult` of `WebMcpContent`. ### `@adapttable/ai/mcp` and `/mcp-apps` — MCP servers and embedded views `toMcpToolList(session)` and `toMcpResourceList(session)` render an `McpToolList` and `McpResourceList` with `McpListMeta`, each tool carrying `McpToolAnnotations`; `mcpToolResult` returns an `McpToolResult` of `McpContent`. MCP Apps puts a real table inside the conversation. `mcpAppResource(options)` declares an `McpAppResource` at a `mcpAppUri` with the `MCP_APP_MIME` type and an `McpAppSecurity` policy `mcpAppCsp` renders; `McpAppResourceOptions` configures it and `withMcpAppMeta` / `mcpAppToolMeta` attach the metadata that binds a tool to its view. Inside the frame, `createMcpAppBridge(options)` speaks JSON-RPC over an `McpAppChannel` to the host: `McpAppBridge` is the handle, `McpAppBridgeOptions` configures it, `McpAppHostCapabilities` is what the host admits to, and `McpAppToolInput` / `McpAppToolOutcome` report the host's own tool traffic. `approveThroughHost` and `askThroughHost` route approval and questions to the host's UI as an `McpAppElicitRequest` of `McpAppElicitOption`s, answered with an `McpAppElicitResult`. ### `@adapttable/ai/ag-ui` — the AG-UI protocol `aguiTransport(options)` is an `AssistantTransport` over an `AgUiConnection`; `AgUiOptions` configures it. `aguiTools(session)` renders the catalog as `AgUiTool`s named by `aguiToolName`. A run takes an `AgUiRunInput` of `AgUiMessage`s and streams `AgUiEvent`s to an `AgUiRunOutcome`; an `AgUiInterrupt` pauses for approval or a question and is answered with an `AgUiResume` carrying an `AgUiResumeStatus`. `statePatch` emits RFC 6902 `JsonPatchOperation`s for shared state, and `AgUiProtocolError` is a malformed or failed run. ### `@adapttable/ai/ai-sdk` — the AI SDK UI message stream `aiSdkTransport(options)` is an `AssistantTransport` over an `AiSdkConnection` configured by `AiSdkOptions`; `aiSdkTools(session)` renders `AiSdkTool`s named by `aiSdkToolName`, and `aiSdkCapability` maps a tool name back to its capability. The route sends `AiSdkRequest`s and streams `AiSdkPart`s — `AI_SDK_STREAM_VERSION` is the version this adapter speaks and `assertAiSdkVersion` refuses a stream it does not understand. Tool results go back as `AiSdkToolOutput`s and approvals as `AiSdkApprovalResponse`s; `AiSdkProtocolError` is a malformed or failed stream. ### `@adapttable/ai/voice` — dictation and clips `createSpeechInput(options)` returns a `SpeechInput` whose `SpeechState` holds a `SpeechStatus`. `SpeechMode` picks between the browser's own recognizer and recording an audio `SpeechClip` for a backend to transcribe; `SpeechInputOptions` and `VoiceOptions` configure it, and `rememberLanguage` / `readRememberedLanguage` keep the reader's dictation language between visits. ### `@adapttable/ai-react` — the React binding `tableAgent` mounts a session on a live table and `useTableAssistant` drives the conversation, exposing the same `TableAssistantStore` and `TableAssistantSnapshot` as the headless store, including its `AssistantQuestion` and `AssistantAnswer` channel. `useSpeechInput(options)` is the hook form of the speech input, taking `UseSpeechInputOptions`. ### The pieces in core and react `@adapttable/core` exports `ColumnAiOptions` — the per-column `ai` block a table author writes to describe a column to a model — and `BUILTIN_AGGREGATE_LABELS`, the localized names of the built-in aggregate functions, so a surface naming an aggregate reads the same word the table shows. `@adapttable/react/adapter` exports what an adapter needs to render the assistant: `AGENT_VIEW_STATE` / `AgentViewState`, `AGENT_ALWAYS_ALLOW_STATE` / `AgentAlwaysAllowState` and `AGENT_PROGRESS_STATE` / `AgentProgress` — how far a running capability has got — are the shared state keys, `TableAssistantLanguageChipProps` is the dictation-language slot, and `SpeechInputHandle` / `SpeechInputState` / `SpeechInputStatus` are the structural view of a speech input — structural so that `@adapttable/react` describes dictation without depending on `@adapttable/ai`. The rendered assistant carries two blocks an adapter draws itself: `TableAssistantQuestionView` with its `TableAssistantQuestionOption`s is a question waiting on the reader, and `TableAssistantUndoView` is the offer to put back what the last turn changed. `TableAssistantResumableView` says there is work a released connection left running, which the panel offers to rejoin, `TableAssistantProgressView` is how far a running capability has got, and `TableAssistantAllowanceView` is one capability the reader stopped being asked about. `deriveRuntimeOperations` reads which view operations a live runtime offers by itself, for a binding projecting them into `@adapttable/ai`'s `agentObservation`. It reports what the runtime does; what that means for a capability is decided there, once, rather than per binding. ## Other packages - `@adapttable/i18n` — `getLabels(locale)`, `getDirection(locale)`, `isRtlLocale(locale)`, `hasLocale(locale)`, `primarySubtag(locale)`, `RTL_LANGUAGES`, `locales` (keyed by `LocaleKey`) and the bundled presets (`en`, `ar`, `de`, `es`, `fr`, `he`, `it`, `ja`, `pt`, `zh`, … including `zhTW`) — see [i18n & RTL](./i18n-rtl.md). - `@adapttable/cli` — binary `adapttable init [--force]`; programmatic `detectKit`, `choosePackageManager`, `installCommand`, `scaffoldFiles`, `runInit` plus the pieces they compose: `KITS` / `KitInfo` / `SHADCN` describe the detectable kits, `packagesFor` and `mergeDependencies` compute what to install, `starterComponent` / `ScaffoldFile` / `STARTER_PATH` describe the scaffold, `PackageManager` names the supported managers, `InitError` is the typed failure, and `InitOptions` / `InitResult` / `InitIO` parameterize `runInit` for testing. - **Adapter packages** — each exports its `DataTable` with `DataTableProps`, `DataTablePropsBase`, `DataTableSlots` and `SavedViewsMenuProps` (plus the shared core re-exports). `DataTableProps` is `DataTablePropsBase & DataModeProps`: the base carries every prop except the data mode, which is the half to name when you wrap or extend a table's props without committing to a tier. `@adapttable/unstyled` also exports `IconProps`, the props its exported icons take, so a caller supplying one has a name for the argument; Radix and Base UI export their accent unions (`RadixAccentColor`, `BaseUiAccentColor`); Mantine also exports its chrome as reusable components (`ActiveFilterChips`, `AutoFilterForm`, `EmptyState`, `ErrorState`, `FilterDrawer`, `PaginationFooter`, `TableSkeleton`, each with a `…Props` companion: `ActiveFilterChipsProps`, `AutoFilterFormProps`, `EmptyStateProps`, `ErrorStateProps`, `FilterDrawerProps`, `PaginationFooterProps`, `TableSkeletonProps`); unstyled and shadcn export their building blocks (`FilterPanel` / `FilterPanelProps`, `FilterPopover` / `FilterPopoverProps`, `AutoFilterForm`, the `cx` class joiner) and shadcn additionally ships `shadcnClassNames`, the preset map behind its default look. --- # AdaptTable FAQ — free MUI X / ag-Grid alternative, RTL, SSR Short, direct answers to the things people ask when choosing a React table. (Looking for a quick comparison table instead? See [comparison.md](./comparison.md). Looking for what the product does not do? See [limitations.md](./limitations.md).) **Jump to a feature:** [URL state](./url-state.md) · [Filtering](./filtering.md) · [Virtualization](./virtualization.md) · [i18n & RTL](./i18n-rtl.md) · [Accessibility](./accessibility.md) · [Realtime](./realtime.md) · [Column management](./column-management.md) · [Data tiers](./data-tiers.md) · [Live demo](https://orwa-mahmoud.github.io/adapttable/demo/) **Migrating from another table:** [MUI X DataGrid](./migrate-from-mui-x-datagrid.md) · [TanStack Table](./migrate-from-tanstack-table.md) · [mantine-datatable](./migrate-from-mantine-datatable.md) · [ag-Grid](./migrate-from-ag-grid.md) · [mui-datatables](./migrate-from-mui-datatables.md) · [material-table](./migrate-from-material-table.md) ## What is AdaptTable? AdaptTable is a **headless, UI-agnostic React data table**. A single headless engine (`@adapttable/core`) powers ready, batteries-included adapters for **Mantine, MUI, Chakra, Ant Design, Radix, Base UI, shadcn/ui, and unstyled Tailwind**. You get TanStack-Table-style headless freedom _and_ a styled table for the UI kit you already use — from the same core. ## What is the best headless React table that works with my design system? If you use **Mantine, MUI, Chakra, Ant Design, Radix, Base UI, or shadcn/ui**, AdaptTable gives you a fully-featured table (sorting, filtering, selection, pagination, infinite scroll, optional virtualization, URL state, i18n/RTL, dark mode) that matches your kit without building the UI yourself. If you're on a different kit or plain Tailwind, the unstyled adapter exposes semantic HTML with `data-*` and `className` hooks, and the headless `useDataTable` core works with any markup. ## Which UI libraries does AdaptTable support? Eight adapters from one API: **Mantine, MUI, Chakra UI, Ant Design, Radix Themes, Base UI, shadcn/ui, and unstyled** (for Tailwind or your own CSS) — each kit adapter rendered with that kit's real components, plus a headless core (`useDataTable`) that works with any markup. Install only the adapter you use. ## Is there a free alternative to MUI X DataGrid or ag-Grid? Yes — AdaptTable is **MIT-licensed and fully free**, including server-side data, infinite scroll, filtering and selection. MUI X DataGrid and ag-Grid are **open-core**: their advanced server-side data and infinite-loading capabilities sit behind paid Pro/Premium or Enterprise tiers. Six more sit in those same paid tiers and are MIT here: [pivoting](./pivot.md), [tree data](./tree-data.md), [cell-range selection, range clipboard copy/paste and the fill handle](./cell-navigation.md), and [Excel (.xlsx) export](./customization.md#export). What the paid tiers still have is integration — one spreadsheet surface with its tool panels assembled — where AdaptTable gives you the parts. [Comparison](./comparison.md) has the table, with each vendor's tier named. The MUI adapter gives a DataGrid-style experience at no cost. ## Is AdaptTable free? Yes — every package is **MIT-licensed and completely free**, including server-side data, infinite scroll, filtering, selection, and virtualization. There is no paid tier and no feature gated behind a license (unlike the open-core AG Grid Enterprise or MUI X DataGrid Pro / Premium). ## How does it handle responsive tables on mobile? It does not try to squeeze desktop columns into a tiny viewport. The adapters automatically switch to mobile cards, with labels per value and a tunable `mobileIdentityColumns` option so the most important columns remain visible. This avoids the horizontal-scroll table pattern that breaks many responsive apps. ## How do I use the same table for client-side and server-side data? Use one `TableSource` contract for both: ```tsx // in-memory const source = useFrontendData({ data: rows, columns }); // server-paginated (wraps your useInfiniteQuery hook) — the table is identical const source = useQuerySource({ usePaginatedQuery }); ``` `` doesn't change between them. ## Does AdaptTable support RTL and Arabic? Yes, RTL is first-class. Column alignment uses **logical CSS** (`start`/`end`), so it flips automatically under `dir="rtl"`. The optional `@adapttable/i18n` package ships **18 locales** — English, Arabic, German, Spanish, Persian, French, Hebrew, Hindi, Italian, Japanese, Korean, Polish, Portuguese, Russian, Turkish, Urdu, Simplified Chinese, and Traditional Chinese — plus `getDirection` / `isRtlLocale` helpers. Arabic, Hebrew, Persian, and Urdu are right-to-left. ## Does it have dark mode? Dark mode is **seamless** — it's inherited from your UI kit's theme, with logical color choices and no hardcoded surfaces that fight the host theme. ## Can I animate rows? Do I need GSAP? Animation is **opt-in and dependency-free** — the built-in entrance stagger uses the Web Animations API and honours `prefers-reduced-motion`. Prefer GSAP or Framer Motion? Every row/card is tagged with `data-stagger`, so you can drive the animation yourself (see [customization.md](./customization.md#animations)). Or run with no animation at all — your call. ## Does it support virtualization? Yes. Long infinite lists can opt into row/card virtualization with Compose `virtualize()` and tune `estimateRowSize`, `estimateCardSize`, and `virtualOverscan`. Ant Design uses its native virtual table mode through the same feature. ## How do I add URL-synced (shareable, deep-linkable) table state? It's built in. Search, sort, filters, and page sync to the URL through an injectable adapter (browser History by default; pass a router adapter for Next.js / react-router). Reloads, shared links, and back/forward restore the exact view. See [url-state.md](./url-state.md). ## Which React table has a filter drawer with URL-synced state? AdaptTable ships both out of the box: declarative filters render in an anchored popover or a slide-in drawer (`filtersMode="drawer"`) with removable chips, and every filter, search, sort, and page value syncs to the URL — so a refresh or a shared link restores the exact view. It works the same for client-side data and server-side fetching, rendered natively by Mantine, MUI, Chakra, Ant Design, Radix, Base UI, or shadcn/ui. See [filtering.md](./filtering.md) and [url-state.md](./url-state.md). ## Which React versions and bundlers are supported? React **18+**. Every package ships dual ESM/CJS builds with `.d.ts` types (verified with `publint --strict` and `are-the-types-wrong`), so it works with Vite, Next.js, Remix, webpack, and friends. It's written in strict TypeScript. ## Does AdaptTable work with Next.js and server components? Yes. It works with **Next.js (App or Pages Router), Remix, and Vite**. The table is interactive, so render it inside a client component (`"use client"` in the App Router) — you can still fetch in a server component and pass the data in. URL-synced state takes a router adapter for Next.js or react-router, and falls back to the browser History API. ## Does it support realtime or websocket updates? Yes — you own the socket. When a row changes, patch the array you already pass as `data` with `applyRowPatches`. Sort, filters and selection survive. See [realtime React data table](./realtime.md). A websocket that hits a row someone is editing is a [conflict](./cell-editing.md#live-update-conflicts), not that page. ## Is it accessible? Yes — semantic table markup, `aria-sort` on sortable headers, labelled selection checkboxes and icon buttons, and a keyboard-friendly UX. Every adapter is audited with `axe` in CI, on both desktop and mobile layouts. See [accessible React data table](./accessibility.md). ## How big is it / is it tree-shakeable? Every package sets `sideEffects: false` and ships ESM, so unused code is tree-shaken. You only install the one adapter you use; the headless core has zero UI-kit dependencies. Measured 2026-09-15 from packed fixtures (`pnpm budget`: rolldown, min+gzip, React and the UI kit external because your app already ships those): | What you import | min+gzip | | ------------------------------------------ | --------- | | `useFrontendData` + `useDataTable` (react) | ~24 kB | | every core export | ~56 kB | | `DataTable` from an adapter | ~69–79 kB | The first row is the one to read: a headless table costs about a fifth of the full core, because the parts you never import never arrive. All eight adapters land within ~12 kB of each other, so switching kits does not change what you pay. The third row is what composition buys. Every optional feature lives on its own entry point, so a plain `DataTable` carries none of them: no drag machine, no virtualizer, no export writer, no filter engine. Each one arrives with the import that names it — `features={[grouping("team")]}` — and costs what it weighs. [Feature composition](./features.md) has the detail. These are not estimates. `pnpm budget` bundles each of those imports for real and fails the build if one crosses its ceiling, and it checks this table against what it just measured — so a figure here cannot drift from the build. ## How do I get started quickly? ```bash npx @adapttable/cli init # detects your UI kit and scaffolds a table ``` Or install an adapter directly, e.g. `pnpm add @adapttable/mantine`. See [the Getting started guide](./getting-started.md). ## Is AdaptTable production-ready? Yes — AdaptTable is **stable at 3.0** and follows semantic versioning, so breaking changes ship only in a major release. It is strict-TypeScript, dual ESM/CJS with `.d.ts` types, axe-audited for accessibility in CI, and holds near-100% test coverage across every adapter. ## What does AdaptTable not do? Present-tense ceilings — data ownership, formula grammar, reorder policy, virtualization, export caps, adapter size, SSR, kit coverage — live on [limitations and boundaries](./limitations.md). Each claim there traces to a test, a measurement, or a documented decision. ## When might another library fit better? - You want a mature, deeply integrated spreadsheet-analytics product and are happy to licence it → **AG Grid Enterprise** or **MUI X Premium**. Their pivot UI, tool panels and range tooling arrive as one assembled surface you switch on. AdaptTable ships the same capabilities under MIT — [pivoting](./pivot.md), [cell-range selection, range clipboard and the fill handle](./cell-navigation.md), [tree data](./tree-data.md), [inline editing](./cell-editing.md) and [Excel (.xlsx) export](./customization.md#export) — but as parts you compose, with their prerequisites stated, rather than one spreadsheet product. - You're not on React → **TanStack Table** (multi-framework). AdaptTable is React-only. - You want the table to draw its own look rather than your design system's. Every AdaptTable adapter renders your UI kit's real components, which is the whole point of it — and the wrong trade if you would rather not own the theming at all. --- # Limitations and boundaries What AdaptTable does not do, and the ceilings that are true of the shipped tree. Every statement below is present-tense fact with a source. This page is not a roadmap. ## The table never owns the data AdaptTable does not mutate your array. Edits, adds, deletes and reorders call host callbacks (`editing()`, `batchEditing()`, `rowEditing()`, add/delete handlers, reorder/move handlers). Undo, dirty state and persistence are whatever those callbacks already do. [Cell editing](./cell-editing.md) · [row reordering](./row-reordering.md). ## Formula grammar The optional `@adapttable/core/formula` grammar is the comparison / concat / sum / product / unary / primary tree in [formulas](./formulas.md). `^` and scientific notation (`1e5`) are outside it: `parseFormula` returns `ok: false` and the column reads `#ERROR!`. Write `x * x` or `POWER`. The function set is the table on that page — no `eval`, no user-defined functions, no expansion of the grammar through `POWER` / `SQRT`. ## Row reorder and move `rowReorder` is an import. Without it the drag machine is not in the graph. The default move policy is `"never"`: same-group / same-parent reorder works; crossing a group or parent boundary is rejected with an announcement. `"confirm"` and `"auto"` are opt-in. A row cannot move under itself or a descendant — the cycle guard rejects pointer, keyboard and menu paths before any host callback. While a sort is active, same-scope order-only writes are rejected (“Clear sorting before changing row order”). Cross-group moves and re-parenting still run because they change membership. Row order is the host array; it is not a URL or Saved Views parameter. [Row reordering](./row-reordering.md). ## Source capabilities A `TableSource` declares what it can retrieve. The table does not invent the rest: | Capability | When it is false | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `fullDataset` | The browser holds the current page. `rows.read` with `scope: "full"` is denied. Export-all needs a source-owned `allFilteredRows` route or `onExportAll`. | | `grouping` | `groupBy` is ignored and the status bar says why. | | `exportScope` | The Export control is disabled with the reason on it. | Declared in [data tiers](./data-tiers.md). Copied, never re-inferred, into the agent manifest ([adaptive capabilities](./agent-capabilities.md)). `rows.read` redacts `readable: false` cells and is bounded by `limits.readMax` (default 50). Installing `@adapttable/ai` does not add grouping, editing or a dataset the table cannot see. ## Client-side size and export Without `virtualize`, the body renders every row in the current view. At 10,000 rows that is 10,000 ``s; the virtualized measurement on the same set is ~24 DOM rows and stays ~24 from 1k to 100k ([virtualization](./virtualization.md)). That is a measurement, not a supported-row SLA. A server page plus virtualize is the path for large sets. Browser `fetchAll` export walks the current query up to `EXPORT_FETCH_ALL_MAX_ROWS` (50,000). Hitting the cap calls `onCapped` and does not write a file that pretends to be complete. Beyond that, `onExportAll` is a host job. [Exporting](./exporting.md). ## Bundle entrypoints Sizes are minified + gzipped AdaptTable bytes. React and the UI kit are external — an application already ships them. The method is packed consumer fixtures in `scripts/bundle-budget.mjs`. - A published adapter's base `DataTable` is held to **≤ 80 KB** (`PLAIN_ADAPTER_CEILING_KB`) and at least 35% under that kit's item-1 baseline. Omitted feature markers must be absent from the base graph. - Optional features arrive with their import. `standardFeatures()` includes only factories that run with no required argument. - `@adapttable/ai` is a separate package. Eleven base graphs contain none of `createAgentSession`, `tableAgent`, `adapttable.agent.v1` or `@adapttable/ai` (`scripts/ai-isolation.mjs`). Published FAQ figures are the measurements the budget script checks, not a second estimate. [FAQ](./faq.md#how-big-is-it--is-it-tree-shakeable) · [feature composition](./features.md). Interaction timings on the Tailwind showcase (first render, sort, page, search, column-menu open, CLS) are locked in `scripts/v3-perf-baseline.json`. They are a regression gate, not a promise to an application. ## Browser, SSR, and the e2e gate Supported runtimes: React 18 or 19, Node `>=22.12.0`, and the kit versions in [getting started](./getting-started.md). The table UI is React. Vue and Angular bindings are not in this tree. The per-PR Playwright project is **Chromium** against the built showcase (`playwright.config.ts`). Firefox, WebKit and a Pixel 5 mobile project run on the nightly/pre-release workflow (`.github/workflows/e2e-nightly.yml`), not on every PR. Chromium visual baselines live in `e2e/visual/` and are compared on that same nightly job. An axe audit walks every kit landing plus key feature pages (`e2e/axe-audit.spec.ts`) and fails on serious or critical findings. The table is a client component. During SSR there is no `window` or `matchMedia`: pass `forceMobile` so server and first paint agree, and pass `createMemoryAdapter(search)` so URL state does not touch History. `@adapttable/i18n` is the one package without `"use client"`. [SSR & RSC](./ssr-rsc.md). `@adapttable/bootstrap` is private and unpublished. “All eight adapters” means Mantine, MUI, Chakra, Ant Design, Radix Themes, Base UI, shadcn/ui and unstyled. ## Accessibility Keyboard, names, RTL and forced-colors are on by default across the eight published kits ([accessibility](./accessibility.md)). antd keeps a sticky header. `role="grid"` sits on the wrapper around both of antd's tables so a cell and its `columnheader` share one grid. The e2e `every body gridcell shares its columnheader with the same grid` walks every kit with antd sticky **on**, forbids `headers=` pointing at another table (W3C ACT a25f45), and requires `columnheader` in the same grid snapshot (`e2e/aria-parity.spec.ts`). That is the outcome of the sticky-header association work — it is not an open caveat. A remaining kit-specific accessibility defect is listed here only after a fix was attempted and failed. None is listed. ## What this product is not - A spreadsheet host. Formula, pivot, fill and range paste are parts you compose; they are not one locked workbook surface. - A hosted agent service. `@adapttable/ai` ships no model SDK, API key or chat UI. Core, every adapter root and `@adapttable/server` import none of those. - A data store. There is no built-in backend, auth or sync. - A config-object API. Features are imports and callbacks, not a second options bag with synonyms. ## Sources | Claim | Source | | ---------------------------------------- | -------------------------------------------------------------------------------------------------------- | | Host callbacks own writes | [cell-editing](./cell-editing.md), [row-reordering](./row-reordering.md) | | Formula grammar and refused tokens | [formulas](./formulas.md), `parseFormula` | | Move policy, cycle guard, sort block | [row-reordering](./row-reordering.md) | | `fullDataset` / export-all / `rows.read` | [data-tiers](./data-tiers.md), [agent-capabilities](./agent-capabilities.md), `session.governed.test.ts` | | `readMax` default 50 | `packages/ai/src/session.ts` `readMaxOf` | | Virtualize DOM count | [virtualization](./virtualization.md) | | Export 50,000 cap | [exporting](./exporting.md) `EXPORT_FETCH_ALL_MAX_ROWS` | | Adapter ≤ 80 KB, omitted-feature markers | `scripts/bundle-budget.mjs`, `scripts/consumer-fixtures.mjs` | | AI absent from base graphs | `scripts/ai-isolation.mjs` | | Perf baseline | `scripts/v3-perf-baseline.json` | | Chromium e2e | `playwright.config.ts` | | Nightly Firefox / WebKit / Pixel 5 | `.github/workflows/e2e-nightly.yml`, `name: "firefox"` in `playwright.config.ts` | | Showcase axe audit | `e2e/axe-audit.spec.ts` | | Visual baselines | `e2e/visual/v3-ui.spec.ts`, `e2e/visual/README.md` | | SSR seams | [ssr-rsc](./ssr-rsc.md) | | antd sticky header association | `e2e/aria-parity.spec.ts`, [accessibility](./accessibility.md) | | React / Node / kit floors | [getting-started](./getting-started.md) | --- # React data table comparison 2026 — AdaptTable vs AG Grid, TanStack Table & MUI X How AdaptTable compares to popular React table libraries — scoped to what each ships **built-in**. Every one of these projects is excellent at what it targets; the table below is about scope fit, not quality. A "✗" means "not a built-in feature" — most gaps can be closed with custom code or third-party libraries. | Feature | AG Grid | TanStack Table | mantine-datatable | MUI X DataGrid | **AdaptTable** | | ---------------------------------------------- | :-------: | :------------------: | :---------------: | :------------: | :-----------------------: | | Headless core | ✗ | ✓ | ✗ | ✗ | **✓** | | Works across UI kits | ✗ | ✓ (you build the UI) | Mantine only | MUI only | **✓ via ready adapters** | | Responsive mobile card layout | partial | build it yourself | partial | partial | **✓ automatic + tunable** | | Client **and** server data, same API | partial | wire it yourself | ✗ | partial | **✓ (`TableSource`)** | | URL-synced state (shareable links) | ✗ | ✗ | ✗ | ✗ | **✓** | | Filter drawer + removable chips | ✗ | ✗ | ✗ | partial | **✓ built-in** | | Infinite scroll **and** paged (auto by device) | ✓ | ✓ (manual) | partial | ✓ (paid) | **✓ auto by device** | | Optional row/card virtualization | ✓ | ✓ (manual) | ✗ | ✓ (paid) | **✓ built-in opt-in** | | i18n + **RTL / Arabic** first-class | partial | ✗ | ✗ | partial | **✓** | | Dark mode | ✓ | n/a | ✓ | ✓ | **✓ seamless** | | MIT / free | open-core | ✓ | ✓ | open-core | **✓** | | Pivoting | ✓ (paid) | ✗ | ✗ | ✓ (paid) | **✓ engine + panel, MIT** | | Cell-range selection | ✓ (paid) | ✗ | ✗ | ✓ (paid) | **✓ MIT (cell nav on)** | | Fill handle (drag to fill) | ✓ (paid) | ✗ | ✗ | ✓ (paid) | **✓ MIT (cell nav+edit)** | | Clipboard copy / paste of a range | ✓ (paid) | ✗ | ✗ | ✓ (paid) | **✓ MIT (paste writes)** | | Excel (.xlsx) export | ✓ (paid) | ✗ | ✗ | ✓ (paid) | **✓ MIT** | | Tree data (self-referencing rows) | ✓ (paid) | build it yourself | ✗ | ✓ (paid) | **✓ MIT** | Comparison as of August 2026, based on each project's public documentation; capabilities evolve, so verify against the latest docs. "Open-core" means a free, MIT/community edition plus paid Enterprise/Pro tiers (AG Grid Enterprise; MUI X DataGrid Pro/Premium); the advanced server-side data and infinite-loading features sit in those paid tiers. Verified against their own docs in August 2026: AG Grid puts pivoting, cell-range selection, the fill handle, tree data, clipboard operations and Excel export in Enterprise; MUI X puts pivoting, cell selection, the fill handle, clipboard paste and Excel export in Premium. AdaptTable ships all of them under MIT, with the prerequisites stated: cell-range selection, the fill handle and range copy all need `cellNavigation`, and anything that writes — paste, cut, fill — goes through editable columns and `onCellEdit`, the channel inline editing already uses. Pivoting is a separate engine plus its own panel rather than a one-line toggle. AG Grid and MUI X remain the more integrated spreadsheet-style products. Spotted something outdated or wrong? Please open an issue — we will correct it promptly. A plain AdaptTable adapter `DataTable` is 69–79 kB min+gzip (measured 2026-09-15 from packed fixtures; React and the kit stay external). Competitor bundle sizes are not listed here — they depend on which paid modules you licence and are not produced by this repo's fixtures. Method and the rest of the grid: [FAQ](./faq.md#how-big-is-it--is-it-tree-shakeable). ## Head-to-head ### AdaptTable vs TanStack Table TanStack Table is a headless engine — framework-agnostic and the closest in philosophy. The difference is what you ship: with TanStack you build every cell, header, filter, and pagination control yourself. AdaptTable gives you native, batteries-included UI for Mantine, MUI, Chakra, Ant Design, Radix, Base UI, and shadcn/ui out of the box — and still exposes a headless core with prop-getters when you want to drop down. Pick TanStack for non-React or total-control builds; pick AdaptTable when you want the UI done for your kit without losing the escape hatch. → [Migrate from TanStack Table](./migrate-from-tanstack-table.md). ### AdaptTable vs AG Grid AG Grid is the enterprise heavyweight, and its spreadsheet stack is the more integrated one. It puts pivoting, range selection, the fill handle, tree data, clipboard operations and Excel export in the paid Enterprise tier, and it renders its own look rather than your design system's. AdaptTable ships those same capabilities under MIT — assembled from parts rather than one spreadsheet surface — plus an interactive grouping strip with header drag-and-drop, keyboard/mobile controls, aggregation choices, URL state, and Saved Views. AdaptTable is free end to end, server data and infinite scroll included, rendering as your UI kit's real components. Reach for AG Grid when you want its more mature integrated spreadsheet product and are happy to licence it; reach for AdaptTable for application data tables that match your app and stay free. → [Migrate from ag-Grid](./migrate-from-ag-grid.md) (CRUD tables only — the guide starts with when to stay). ### AdaptTable vs MUI X DataGrid MUI X DataGrid is a strong choice if you're all-in on MUI — but it's MUI-only, and its server-side data, tree data, and infinite loading sit behind the paid Pro / Premium tiers (open-core). AdaptTable's MUI adapter gives a DataGrid-style experience for free, and the same API also renders in Mantine, Chakra, Ant Design, Radix, Base UI, and shadcn/ui, with server data and shareable URL state built in at no cost. → [Migrate from MUI X DataGrid](./migrate-from-mui-x-datagrid.md). Coming from the older MUI table generation instead? → [mui-datatables](./migrate-from-mui-datatables.md) · [material-table](./migrate-from-material-table.md). ### AdaptTable vs mantine-datatable mantine-datatable is a polished, popular table — but it's Mantine-only. AdaptTable renders natively in Mantine and six other kits from one API, and adds client/server data behind a single contract, shareable URL state, saved views, and first-class RTL. If you're on Mantine and staying there, either works; if you want the same table across kits (or those extra batteries), AdaptTable covers more ground. → [Migrate from mantine-datatable](./migrate-from-mantine-datatable.md). ## Every adapter, every feature The point of AdaptTable is that the feature set never changes when you switch kits — only the look does. Every adapter ships the same batteries: | Feature | Mantine | MUI | Chakra | Ant Design | Radix | Base UI | shadcn/ui | Unstyled | | ------------------------------------- | :-----: | :-: | :----: | :--------: | :---: | :-----: | :-------: | :------: | | Filter popover | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Filter drawer | ✅ | ✅ | ✅ | ✅ | ✅¹ | ✅ | ✅ | ✅ | | Active-filter chips | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Column menu (show/hide, reorder, pin) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Column resize | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Saved views | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Bulk action bar | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Summary / footer row | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Row expansion | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Inline cell editing (opt-in) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Row grouping + per-group aggregates | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Interactive grouping panel | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Pinned summary rows | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Row virtualization | ✅ | ✅ | ✅ | ✅² | ✅ | ✅ | ✅ | ✅ | | Card virtualization (mobile) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | RTL / Arabic | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Skeleton / empty / error states | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | Mount entrance animation | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ¹ Radix Themes ships no Drawer primitive, so `filtersMode="drawer"` renders as a Radix Dialog restyled into a side panel — same dimming backdrop and focus trap. ² Ant Design maps `virtualize` to its own native virtual table for desktop rows; mobile cards window through the shared engine like every other adapter. shadcn/ui is the unstyled adapter pre-wired with the shadcn class preset, so it matches unstyled feature-for-feature. ## When to choose AdaptTable - You use **Mantine, MUI, Chakra, Ant Design, Radix, Base UI, or shadcn/ui** and want a table that matches your kit without building it yourself. - You need **the same table for both in-memory and server-paginated data**. - You want **shareable, deep-linkable table state** for free. - You want tables that stay usable on phones without horizontal-scroll hacks. - You need **RTL / Arabic** done properly. - You want a **headless escape hatch** when the defaults aren't enough — the same core powers both the batteries-included components and your own custom markup. ## When another library may fit better - You want a mature, deeply integrated spreadsheet-analytics product and are happy to licence it → **AG Grid Enterprise** / **MUI X Premium**. AdaptTable has an interactive grouping panel, pivoting, range selection, the fill handle, clipboard and Excel export under MIT, but they are assembled from composable features rather than one spreadsheet surface. - You're on a framework other than React → **TanStack Table** (multi- framework). AdaptTable is React-only. - You need a spreadsheet-like editing surface today, not a responsive data table for application lists. ## Migration guides - [MUI X DataGrid alternative](./migrate-from-mui-x-datagrid.md) - [TanStack Table alternative](./migrate-from-tanstack-table.md) - [mantine-datatable alternative](./migrate-from-mantine-datatable.md) - [ag-Grid alternative for CRUD](./migrate-from-ag-grid.md) - [mui-datatables alternative](./migrate-from-mui-datatables.md) - [material-table alternative](./migrate-from-material-table.md) Also: [Mobile cards](./mobile.md) · [URL state](./url-state.md) · [Virtualization](./virtualization.md) · [i18n & RTL](./i18n-rtl.md) · [Accessibility](./accessibility.md) · [Realtime](./realtime.md) · [Limitations and boundaries](./limitations.md) · [Live demo](https://orwa-mahmoud.github.io/adapttable/demo/) --- # Migrate from mantine-datatable to AdaptTable — more features, same Mantine look ▶ **See it before you install:** [the live demo running on real Mantine](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mantine) — same components you already use, nothing to set up. [mantine-datatable](https://icflorescu.github.io/mantine-datatable/) is a polished, Mantine-only table. `@adapttable/mantine` renders the **same Mantine primitives**, so the look barely changes — what changes is how much you wire by hand. Sorting, pagination, filtering, and data fetching move from imperative state you manage into declarative props the table owns. Both are Mantine-native and both do RTL first-class, so those stay the same. This page maps the API across and shows a before/after. ## What you gain Things mantine-datatable leaves to you that AdaptTable does for you: - **One data contract for client and server.** Pass `data` for in-memory rows, or `data` + `total` + `loading` + `onQueryChange` for a paginated API — no manual `useEffect`/`fetching`/`totalRecords` juggling. See [data tiers](./data-tiers.md). - **A real filter UI.** Declare `filter` on a column and get a kit-native widget, a removable chip, and URL parsing for free. mantine-datatable's `column.filter` is an empty slot you render yourself. See [filtering](./filtering.md). - **Search, sort, and paging applied for you.** On the frontend tier AdaptTable filters, sorts, and pages the array itself — you no longer sort and slice `records` in response to `sortStatus`/`page`. - **URL-synced, shareable, reload-safe state** — mantine-datatable keeps state in React only. See [URL state](./url-state.md). - **A ready Columns menu** (show/hide, reorder, pin) via `enableColumnMenu`, and drag/keyboard resize via `resizableColumns`. mantine-datatable gives you the column mechanics but no menu UI. See [column management](./column-management.md). - **Saved views, an automatic mobile card layout, and opt-in virtualization** — none of which mantine-datatable ships. See [saved views](./saved-views.md) and [virtualization](./virtualization.md). ## Install ```bash pnpm add @adapttable/core @adapttable/mantine @mantine/core @mantine/hooks ``` Keep your existing `` at the app root — the adapter renders through it exactly as mantine-datatable did. ## Prop mapping `` props: | mantine-datatable | `@adapttable/mantine` | Notes | | --------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `records` | `data` | Frontend tier. Server tier: `data` (current page) + `total` + `onQueryChange`. | | `columns` | `columns` | Field-by-field mapping below. | | `idAccessor` | `rowKey` | **Required** — `(row) => string`. There is no `"id"` default. | | `sortStatus` / `onSortStatusChange` | `sortable` per column (+ `multiSort`) | AdaptTable owns sort state and applies it; you don't sort `records` yourself. | | `page` / `onPageChange` / `totalRecords` | automatic (frontend) or `total` + `onQueryChange` (server) | Pagination is `"auto"`: paged on desktop, infinite on mobile. | | `recordsPerPage` / `recordsPerPageOptions` | built-in page-size control | Defaults to `PAGE_SIZE_OPTIONS`; `DEFAULT_LIMIT` is 25. | | `selectedRecords` / `onSelectedRecordsChange` | `bulkActions`, or `selectedIds` / `onSelectionChange` | Providing `bulkActions` turns selection on and mounts the bulk bar. | | `isRecordSelectable` | — (gate in your bulk actions) | No per-row checkbox disable; use a bulk action's `disabledReason` to refuse ineligible ids (`selectionGetId` only customises the id). | | `rowExpansion={{ content }}` | `renderRowDetail` | `(row) => ReactNode`; multiple rows may be open at once. | | `fetching` | `loading` | Server tier: skeleton when empty, subtle refresh indicator otherwise. | | `noRecordsText` / `emptyState` | `slots.empty` | Replace the empty state; `slots.skeleton` replaces the loader. | | `column.filter` (your JSX popover) | column `filter` shorthand | Declarative — see below. JSX is still allowed via the table-level `filters` prop. | Column fields (`DataTableColumn` → `ColumnDef`): | mantine-datatable | `@adapttable/mantine` | Notes | | --------------------------- | --------------------------------------------------- | ------------------------------------------------------------------------- | | `accessor` | `key` | Both support dot paths (`"department.name"`). | | `title` | `header` | Auto-derived from `key` when omitted (`hiredAt` → "Hired At"). | | `render: (rec, i) => …` | `Cell` (component) or `accessor: (row) => …` | `Cell` receives `{ row, rowIndex }`; define it at module level. | | `sortable` | `sortable` | Add `sortValue` when the cell renders formatted/JSX content. | | `textAlign: 'left'/'right'` | `align: 'start'/'end'` | Logical alignment — correct under RTL automatically. | | `width` | `width` | — | | `hidden` / `toggleable` | `hideOnMobile`/`hideOnDesktop` + `enableColumnMenu` | Per-layout hiding is a column flag; user toggling lives in the menu. | | `draggable` / `resizable` | `enableColumnMenu` / `resizableColumns` | Enabled once at the table level, not per column. | | `pinned: 'left'/'right'` | `columnLayout` / `defaultColumnLayout` | Pinning is part of the column-layout state (menu-driven or controlled). | | `footer` | `summaryRow` | One table-level function maps the page's rows to per-column footer cells. | ## Before / after **Before** — mantine-datatable, wiring sort + paging yourself: ```tsx import { DataTable } from "mantine-datatable"; import { useMemo, useState } from "react"; function PeopleTable({ people }: { people: Person[] }) { const [page, setPage] = useState(1); const PER_PAGE = 25; const [sortStatus, setSortStatus] = useState>({ columnAccessor: "name", direction: "asc", }); const sorted = useMemo(() => { const data = [...people].sort((a, b) => String(a[sortStatus.columnAccessor]).localeCompare( String(b[sortStatus.columnAccessor]) ) ); return sortStatus.direction === "desc" ? data.reverse() : data; }, [people, sortStatus]); const records = sorted.slice((page - 1) * PER_PAGE, page * PER_PAGE); return ( ); } ``` **After** — AdaptTable applies search, sort, filters, and paging itself: ```tsx import { DataTable } from "@adapttable/mantine"; function PeopleTable({ people }: { people: Person[] }) { return ( r.id} columns={[ { key: "name", sortable: true }, { key: "role" }, { key: "status", filter: { type: "select", options: "auto" } }, ]} /> ); } ``` The `status` filter alone — a kit-native select, a removable chip, a URL param, and the row predicate — is code you would have written by hand before. ## Gotchas - **`rowKey` is required.** mantine-datatable defaults `idAccessor` to `"id"`; AdaptTable has no default, so always pass `rowKey={(r) => r.id}`. - **Stop sorting and slicing `records`.** On the frontend tier, hand the full array to `data` and delete your `sortStatus`/`page` sort-and-slice logic — the table does it. For a server API, use the [server tier](./data-tiers.md) instead of manual `fetching`. - **`render(record, index)` → `Cell`.** Use a module-level component receiving `{ row, rowIndex }`, or the lighter `accessor: (row) => …`. A cell that returns formatted/JSX content needs `sortValue` to stay sortable. - **Filters are declarative.** Replace a hand-built `column.filter` popover with a `filter` shorthand (`"text"`, `"select"`, `"dateRange"`, …). A genuinely bespoke control can still be passed as JSX to the table-level `filters` prop. - **`textAlign` values change.** `'left'`/`'right'` become the logical `'start'`/`'end'`, which flip correctly in RTL. ## Where next - [Getting started](./getting-started.md) — install, providers, first table. - [Filtering](./filtering.md) · [Data tiers](./data-tiers.md) · [Column management](./column-management.md) · [Saved views](./saved-views.md). - [Comparison](./comparison.md) — where each library fits. --- # Migrate from MUI X DataGrid to AdaptTable — v8 breaking changes mapped, Pro features free (MIT) ▶ **See it before you install:** [the live demo running on real Material UI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mui) — same components you already use, nothing to set up. [MUI X DataGrid](https://mui.com/x/react-data-grid/) is an excellent grid if you are all-in on Material UI. The catch is its licensing: many everyday table features live in the paid **Pro** and **Premium** tiers. `@adapttable/mui` renders real MUI components — so it looks like Material UI — while giving you a lot of that paid surface **free under MIT**, plus shareable URL state and an automatic mobile layout the DataGrid doesn't ship at any tier. This page maps `` to `` and shows a before/after. ## What you gain (free, MIT) Features that are **paid** in MUI X but built into `@adapttable/mui`: - **Column pinning** (Pro) → `columnMenu()` + column layout / Columns menu. - **Row virtualization** (Pro in v8) → `virtualize()`. Community `` also caps pages at 100 rows; AdaptTable has no such cap. - **Multi-column sort** (Pro) → `multiSort()`. - **Multiple simultaneous filters** (Pro) → declarative `filters`, always multi-condition. - **Column resizing** (Pro) → `resizableColumns()`. - **Master-detail / detail panel** (Pro) → `rowDetail(fn)`. - **Multiple row selection** (Pro) → `bulkActions([…])` (+ `selectedIds` / `onSelectionChange` when controlled). - **Footer summary rows** → `summaryRow` (MUI's statistical aggregation is Premium; AdaptTable gives you the footer row, you supply the values). Plus things DataGrid has no built-in answer for at any tier: - **URL-synced, shareable state** — see [URL state](./url-state.md). - **An automatic responsive card layout** on mobile (DataGrid's List view is Pro and manual). - **One API for seven UI kits** — the same code renders in Mantine, Chakra, Ant Design, Radix, Base UI, and shadcn/ui too. > Tiers above reflect MUI X **v8** (2025), verified against MUI's licensing > docs. MUI may move features between tiers — check their > [licensing page](https://mui.com/x/introduction/licensing/) before relying on > a specific boundary. ## Install ```bash pnpm add @adapttable/core @adapttable/mui @mui/material ``` Works with the default theme; wrap in `` to customize, exactly as DataGrid does. ## Prop mapping `` → ``: | MUI X DataGrid | `@adapttable/mui` | Notes | | ---------------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------- | | `rows` | `data` | Frontend tier. Server tier: `data` (page) + `total` + `onQueryChange`. | | `columns` | `columns` | `GridColDef` → `ColumnDef`, mapped below. | | `getRowId` | `rowKey` | **Required** — `(row) => string`. | | `sortModel` / `onSortModelChange` | `sortable` per column (+ `multiSort()`) | AdaptTable owns and applies sort state. | | `sortingMode: "server"` | `onQueryChange` (or `source`) | The consolidated query carries `sortBy`/`sortDir`. | | `filterModel` / `onFilterModelChange` | column `filter` shorthand + table `filters` | Widgets, chips, and URL params are derived for you. | | `paginationModel` / `onPaginationModelChange` | automatic (frontend) or `total` + `onQueryChange` | No 100-row community cap. | | `checkboxSelection` + `rowSelectionModel` | `bulkActions([…])`, or `selectedIds` / `onSelectionChange` | Multi-row selection is free; no `{ type, ids }` model to manage. | | `getDetailPanelContent` (Pro) | `rowDetail(fn)` | `(row) => ReactNode`. | | `loading` | `loading` | — | | `slots.noRowsOverlay` / `loadingOverlay` | `slots.empty` / `slots.skeleton` | — | | `initialState` / `apiRef.exportState()` (state only) | `urlSync` + `urlKey` (+ `savedViews({ storageKey })`) | State lives in the URL, shareable and reload-safe. | | `pinnedColumns` (Pro) / `columnVisibilityModel` | `columnMenu()` / `columnLayout` | Show/hide, reorder, pin in one menu. | `GridColDef` → `ColumnDef`: | MUI X DataGrid | `@adapttable/mui` | Notes | | -------------------------------------- | ------------------------------------- | ------------------------------------------------------------- | | `field` | `key` | Also the value path (dot paths supported). | | `headerName` | `header` | Auto-derived from `key` when omitted. | | `width` / `flex` | `width` | — | | `valueGetter: (value, row) => …` | `accessor: (row) => …` | AdaptTable reads the row directly — no v7/v8 signature churn. | | `valueFormatter` / `renderCell` | `accessor` / `Cell` | `Cell` is a component receiving `{ row, rowIndex }`. | | `type: "singleSelect"`, `valueOptions` | `filter: { type: "select", options }` | Turns the column into a native select filter. | | `sortable` | `sortable` | Add `sortValue` for formatted/JSX cells. | | `align` / `headerAlign` | `align` | Use logical `"start"`/`"center"`/`"end"`. | ## Before / after **Before** — MUI X, reaching for `` to pin and resize columns: ```tsx import { DataGridPro } from "@mui/x-data-grid-pro"; // paid tier function PeopleGrid({ rows }: { rows: Person[] }) { return ( r.id} checkboxSelection columns={[ { field: "name", headerName: "Name", sortable: true }, { field: "role", headerName: "Role" }, { field: "status", headerName: "Status", type: "singleSelect", valueOptions: ["active", "retired"], }, ]} initialState={{ pinnedColumns: { left: ["name"] } }} /> ); } ``` **After** — `@adapttable/mui`, all MIT, with a status filter + chip and URL state for free: ```tsx import { DataTable } from "@adapttable/mui"; import { bulkActions } from "@adapttable/mui/bulk-actions"; import { columnMenu } from "@adapttable/mui/column-menu"; import { resizableColumns } from "@adapttable/mui/resizable-columns"; function PeopleTable({ people }: { people: Person[] }) { return ( r.id} features={[ columnMenu(), // show/hide, reorder, pin — no Pro licence resizableColumns(), bulkActions([]), // turns on multi-row selection; add your actions ]} columns={[ { key: "name", sortable: true }, { key: "role" }, { key: "status", filter: { type: "select", options: "auto" } }, ]} /> ); } ``` ## Escaping the version churn (v6 → v7 → v8 breaking changes) A big reason teams migrate is that each DataGrid major rewrites your code. If one of these breaking changes brought you here, the right column is what the same thing looks like in AdaptTable — where it hasn't changed since 1.0: | DataGrid breaking change | In AdaptTable | | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | **`valueGetter` signature** rewritten in v7: `(params) => …` became `(value, row, column, apiRef) => …` (same for `valueFormatter`) | `accessor: (row) => …` — one argument, the row, stable API | | **`disableSelectionOnClick` renamed** to `disableRowSelectionOnClick` (v6) | selection never binds to row clicks; `onRowClick` is a separate, explicit prop | | **`rowSelectionModel` reshaped** in v8: a plain id array became `{ type: "include" \| "exclude", ids: Set }` | `onSelectionChange` hands you a plain `string[]` — no model object | | **Row virtualization moved to Pro** in v8 (Community pages cap at 100 rows) | `virtualize()` — free, MIT, no page cap | | **Multi-column sorting / multi-filters** gated behind Pro | `multiSort()` and multi-condition `filters` — free | | **Column resizing** gated behind Pro | `resizableColumns()` — free | One declarative API, semver-stable, and the features that keep moving behind the paywall are simply included. ## Gotchas - **`rowKey` is required** — the equivalent of `getRowId`, but with no `id` default. - **No `sortModel`/`filterModel`/`paginationModel` objects.** Sorting is a per-column `sortable` flag; filtering is declarative `filter` shorthands; pagination is automatic. For a server API, emit one query via [`onQueryChange`](./data-tiers.md) instead of three `*Mode="server"` props. - **`valueGetter` becomes `accessor`.** AdaptTable passes the row, so the v7/v8 `(value, row, column, apiRef)` signature change doesn't apply — and a JSX cell uses `Cell` (a `{ row, rowIndex }` component). - **Selection is a plain id list.** No v8 `{ type: "include" | "exclude", ids: Set }` model — `onSelectionChange` hands you `string[]`. - **Row expansion replaces master-detail.** `rowDetail(fn)` is the free equivalent of the Pro `getDetailPanelContent`. - **`summaryRow` is a footer, not aggregation.** You compute the totals from the page's rows; there is no Premium aggregation engine (and none needed for a sum/count footer). - **Features compose in `features`.** Import each factory from its kit subpath (`@adapttable/mui/column-menu`, …); enabling props no longer arm chrome. See [feature composition](./features.md). ## Where next - [Getting started](./getting-started.md) · [Data tiers](./data-tiers.md) · [Filtering](./filtering.md) · [Column management](./column-management.md). - [Virtualization](./virtualization.md) — window large lists, free. - [Comparison](./comparison.md) — where each library fits. --- # Migrate from TanStack Table to AdaptTable — built-in UI, same headless control ▶ **See it before you install:** [the live demo](https://orwa-mahmoud.github.io/adapttable/demo/?kit=tailwind) — the unstyled adapter with your own classes, plus seven kit-native ones in the switcher. [TanStack Table](https://tanstack.com/table/latest) is a superb headless engine — you keep total control because it renders nothing: no markup, no toolbar, no filter inputs, no pagination controls, no URL sync. AdaptTable shares that philosophy. `@adapttable/core` is also headless (prop-getters, no forced markup), but it comes with the parts you rebuild on every TanStack project already wired: a filter UI, a toolbar, pagination, URL-synced state, and saved views. And when you _don't_ need bespoke markup, an adapter renders native kit components so you can delete the table UI entirely. TanStack is multi-framework (React, Vue, Svelte, Solid, …); AdaptTable is React-only. If you're not on React, stay on TanStack. This page is for React teams tired of rebuilding the same chrome. ## What you gain Everything TanStack leaves to you (verified from its docs), done for you: - **A filter UI.** TanStack gives you `columnFilters` state and `getFacetedUniqueValues`, but you render every input. AdaptTable derives kit-native widgets, removable chips, and URL params from a `filter` declaration. See [filtering](./filtering.md). - **A toolbar, pagination, and search** — none of which exist in TanStack; you wire the buttons around `nextPage()`/`setPageIndex()` yourself. - **URL-synced state.** TanStack has no URL API; you serialize `sorting`/`columnFilters`/`pagination` by hand. AdaptTable does it. See [URL state](./url-state.md). - **Saved views** — no concept in TanStack. See [saved views](./saved-views.md). - **No opt-in row models.** You don't import `getSortedRowModel` / `getFilteredRowModel` / `getPaginationRowModel` — search, filter, sort, and paging run by default on the frontend tier. - **Native kit UI, optional.** Keep headless control with `useDataTable` prop-getters, or adopt an adapter (`@adapttable/mantine`, `mui`, …) and drop your hand-written markup. ## Install Headless core only, or core plus an adapter for ready UI: ```bash # Headless (keep rendering your own markup, TanStack-style) pnpm add @adapttable/core # Batteries — native components for your kit (delete the markup) pnpm add @adapttable/core @adapttable/mantine @mantine/core @mantine/hooks ``` ## Concept mapping | TanStack Table | AdaptTable | Notes | | --------------------------------------------------------------------- | --------------------------------------------------------------- | ------------------------------------------------------------------------ | | `useReactTable({ data, columns, getCoreRowModel })` | `useDataTable({ source, columns })` or an adapter `` | `source` from `useFrontendData`; adapters accept `data` directly. | | `getSortedRowModel` / `getFilteredRowModel` / `getPaginationRowModel` | (none needed) | Applied automatically on the frontend tier. | | `flexRender(cell.column.columnDef.cell, …)` | adapter renders, or prop-getters (`getCellProps`, …) | Headless route mirrors TanStack's model. | | `columnHelper.accessor("name", …)` | `{ key: "name" }` | Dot paths work in both. | | `columnHelper.accessor(row => …)` (`accessorFn`) | `{ key, accessor: (row) => … }` | — | | `columnHelper.display({ cell })` | `{ key, Cell }` | `Cell` receives `{ row, rowIndex }`. | | `header` / `cell` / `footer` | `header` / `Cell` (or `accessor`) / `summaryRow` | Footer is one table-level function. | | `enableSorting` / `state.sorting` / `onSortingChange` | `sortable` per column (+ `multiSort`) | AdaptTable owns sort state. | | `columnFilters` + `getFilteredRowModel` + your inputs | column `filter` shorthand + `filters` array | Widgets + chips built for you. | | `state.pagination` + `getPaginationRowModel` + your controls | automatic pagination | Paged on desktop, infinite on mobile. | | `enableRowSelection` + `state.rowSelection` + your checkboxes | `bulkActions` / `selectedIds` / `onSelectionChange` | — | | `getExpandedRowModel` + `row.getToggleExpandedHandler()` | `renderRowDetail` | Toggle UI is provided, not hand-added. | | `columnVisibility` / `columnOrder` / `columnPinning` state | `enableColumnMenu` / `columnLayout` | One built-in menu instead of hand-wired state + DnD. | | `manualSorting` / `manualFiltering` / `manualPagination` | `onQueryChange` (or `source` via `useQuerySource`) | One consolidated server query. See [data tiers](./data-tiers.md). | | (serialize state to the URL yourself) | `urlSync` / `urlKey` / `savedViews` | URL sync is on by default; `savedViews` is opt-in (pass a `storageKey`). | ## Before / after **Before** — TanStack: opt-in row models, `flexRender`, and hand-built pagination: ```tsx import { createColumnHelper, flexRender, getCoreRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from "@tanstack/react-table"; const col = createColumnHelper(); const columns = [ col.accessor("name", { header: "Name" }), col.accessor("role", { header: "Role" }), ]; function PeopleTable({ people }: { people: Person[] }) { const table = useReactTable({ data: people, columns, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), getPaginationRowModel: getPaginationRowModel(), }); return ( <>
{table.getHeaderGroups().map((hg) => ( {hg.headers.map((h) => ( ))} ))} {table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( ))} ))}
{flexRender(h.column.columnDef.header, h.getContext())}
{flexRender(cell.column.columnDef.cell, cell.getContext())}
{/* …and you still hand-write the pagination buttons, search box, filter inputs, and URL syncing. */} ); } ``` **After** — an adapter renders native UI; search, sort, filters, pagination, and URL state come built in: ```tsx import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, … function PeopleTable({ people }: { people: Person[] }) { return ( r.id} columns={[ { key: "name", sortable: true }, { key: "role", filter: { type: "select", options: "auto" } }, ]} /> ); } ``` Prefer to keep your own markup? Stay headless with `@adapttable/core`: `useFrontendData` for the source and `useDataTable` for the prop-getters (`getTableProps`, `getHeaderCellProps`, `getSortButtonProps`, `getRowProps`, `getCellProps`, `getSearchInputProps`) — the same headless shape as TanStack, but with filters, pagination, and URL state already handled. ## Gotchas - **React only.** On Vue/Svelte/Solid/Angular, keep TanStack — AdaptTable has no adapter for those. - **`rowKey` is required** (AdaptTable's `getRowId`). - **Delete the opt-in row models.** No `getSortedRowModel` / `getFilteredRowModel` / `getPaginationRowModel` imports — those run for you. - **Filters are declarative, not hand-rendered.** Swap your custom filter inputs for a `filter` shorthand; a bespoke control can still be JSX in `filters`. - **You keep the headless escape hatch.** `useDataTable` returns prop-getters, so moving to AdaptTable doesn't mean giving up control — it means not rebuilding the chrome first. ## Still on react-table v7? If your project uses the legacy `react-table` package (v7 — `useTable` + `useSortBy` / `useFilters` / `usePagination` / `useRowSelect` plugin hooks), you're on a frozen library: v7 stopped receiving releases when the project became TanStack Table v8. You have two upgrade paths, and both are rewrites of your table UI — the v7 plugin-hook API doesn't carry over: - **react-table v7 → TanStack v8**: new package, new column defs, new row models — and you still hand-build every piece of UI afterwards. - **react-table v7 → AdaptTable**: the same rewrite cost, but you come out the other side with the toolbar, filters, pagination, URL sync, and native kit rendering already done. The v7 concepts map cleanly: `useTable({ columns, data })` → ``; a v7 column's `Header`/`accessor`/`Cell` → AdaptTable's `header`/`key` (or `accessor`)/`Cell`; `useSortBy` → per-column `sortable`; `usePagination` → automatic; `useRowSelect` → `bulkActions` / `onSelectionChange`; `useExpanded` → `renderRowDetail`. Since you must rewrite anyway, migrating "up" to batteries beats migrating sideways to another build-it-yourself engine. ## Where next - [Concepts](./concepts.md) — the headless engine and `TableSource`. - [Getting started](./getting-started.md) · [Filtering](./filtering.md) · [Data tiers](./data-tiers.md) · [API reference](./api.md). - [Comparison](./comparison.md) — where each library fits. --- # Migrate from mui-datatables to AdaptTable — maintained, MUI v6+, React 19 ready ▶ **See it before you install:** [the live demo running on real Material UI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mui) — nothing to set up. [mui-datatables](https://github.com/gregnb/mui-datatables) served a generation of Material UI apps well — but it has had **no releases or commits since January 2023** (last version 4.3.0). Its peer range stops at `@mui/material ^5`, so **MUI v6 and v7 apps can't install it cleanly**, React 19 is unsupported, and ~624 open issues sit unaddressed. If your app is stuck on MUI v5 because of your table, this page is the way out. `@adapttable/mui` renders real Material UI components — supported across **MUI v5 through v9** — so migrating unfreezes your MUI upgrade path while keeping the Material look. This page maps the API across and shows a before/after. ## Why move All verifiable as of mid-2026: - **Unmaintained**: no releases or commits since January 2023; hundreds of open issues and PRs without a response. - **Version lock**: peer dependencies require `@mui/material ^5.11` — npm refuses clean installs on MUI v6/v7, and React 19 is outside the peer range. Your table shouldn't decide your framework versions. - **Weight**: 18 runtime dependencies, and drag-and-drop, print and CSV machinery ship whether you use them or not — there is no opting out of a feature you never turned on. - **No bundled types**: TypeScript definitions live in community `@types/mui-datatables` and can drift. AdaptTable is TypeScript-first. ## What you gain Beyond the unfreeze: URL-synced shareable state (filters, search, sort, page in the query string — reload-safe), one data contract for client **and** server data instead of the `onTableChange` action switch, chips for active filters, an automatic mobile card layout (no `responsive` mode quirks), saved views, and opt-in virtualization. See the [feature overview](./getting-started.md). ## Install ```bash pnpm add @adapttable/core @adapttable/mui @mui/material ``` Works with your existing MUI theme — and lets you upgrade `@mui/material` whenever you want. ## Prop mapping `` → ``: | mui-datatables | `@adapttable/mui` | Notes | | ----------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | | `data` (objects) | `data` | Array-of-objects only; no array-of-arrays mode. | | `data` (arrays) | convert to objects | One `map()` at the boundary — see Gotchas. | | `columns` (`name`, `label`, `options`) | `columns` (`key`, `header`, …) | Field-by-field below. | | `options.serverSide` + `count` + `onTableChange` | `data` + `total` + `onQueryChange` | One consolidated query object replaces the action-string switch. | | `options.page` / `rowsPerPage` / `rowsPerPageOptions` | automatic | Built-in pager; page size control included. | | `options.sortOrder` / `onColumnSortChange` | per-column `sortable` (+ `multiSort()`) | AdaptTable owns and applies sort state. | | `options.filterType` / `onFilterChange` | column `filter` shorthand + table `filters` | Kit-native widgets + removable chips, derived for you. | | `options.search` / `searchText` / `onSearchChange` | built-in search (`searchPlaceholder`, `searchable`) | Debounced and URL-synced. | | `options.selectableRows` + `onRowSelectionChange` | `bulkActions([…])`, or `selectedIds` / `onSelectionChange` | Compose `bulkActions()` to enable selection + the bulk bar. | | `options.expandableRows` + `renderExpandableRow` | `rowDetail(fn)` | `(row) => ReactNode` — no separate flag, no colSpan markup. | | `options.viewColumns` / `draggableColumns` | `columnMenu()` | Show/hide, reorder, and pin in one menu. | | `options.resizableColumns` | `resizableColumns()` | Drag + keyboard resize. | | `options.responsive` (`'vertical'`, …) | automatic mobile cards | Cards render below the breakpoint — no mode to configure. | | `options.download` / `print` | `exportCsv()` / `rowsToCsv` + `downloadCsv` | Compose `exportCsv()` for a toolbar button, or headless helpers on a custom `toolbar` button; print via CSS. | | `options.textLabels` | `labels` | Same idea; English defaults fill missing keys. | | `options.storageKey` | `savedViews({ storageKey })` / URL state | Views are named, shareable snapshots instead of one implicit save. | Column options → `ColumnDef`: | mui-datatables | `@adapttable/mui` | Notes | | ------------------------------------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------- | | `name` | `key` | Dot paths reach nested values without `enableNestedDataAccess`. | | `label` | `header` | Auto-derived from `key` when omitted. | | `options.customBodyRender(value, tableMeta)` | `Cell` (component) or `accessor: (row)` | You get the **row object**, not index bookkeeping. | | `options.customBodyRenderLite(dataIndex)` | `Cell` | Row memoization is built in — no "lite" variant needed. | | `options.display` / `viewColumns` | `columnMenu()` (+ `columnLayout`) | User-facing visibility lives in the Columns menu. | | `options.filter` / `filterType` / `filterOptions` | `filter` shorthand | `"text"`, `"select"`, `"multiSelect"`, `"numberRange"`, `"dateRange"`. | | `options.sort` / `sortCompare` | `sortable` / `sortValue` | `sortValue` extracts the comparable primitive. | | `options.setCellProps` / `setCellHeaderProps` | `align`, `width`, `classNames` | Common cases are first-class column props; per-part classes come from the adapter's `classNames` map. | ## Server-side data: retire the action switch mui-datatables server mode means `serverSide: true`, a `count`, and an `onTableChange(action, tableState)` callback where you `switch` over action strings — `'changePage'`, `'sort'`, `'search'`, `'filterChange'`, `'changeRowsPerPage'` — re-fetching in each case (the library throws if `onTableChange` is missing). AdaptTable replaces the whole dispatch with one callback receiving one consolidated query: ```tsx { // query: { page, limit, search, sortBy, sortDir, sortLevels, filters } — one shape, // fired once per real change (mount included), previous fetch aborted. const res = await fetch(`/api/people?${toParams(query)}`, { signal }); const body = await res.json(); setRows(body.items); setTotal(body.total); }} columns={columns} rowKey={(r) => r.id} /> ``` ## Before / after **Before** — mui-datatables: ```tsx import MUIDataTable from "mui-datatables"; function PeopleTable({ people }) { return ( , }, }, ]} options={{ filterType: "dropdown", selectableRows: "multiple", responsive: "vertical", }} /> ); } ``` **After** — `@adapttable/mui` (typed, URL-synced, MUI v5–v9): ```tsx import { type CellProps, DataTable } from "@adapttable/mui"; import { bulkActions } from "@adapttable/mui/bulk-actions"; function StatusCell({ row }: CellProps) { return ; } function PeopleTable({ people }: { people: Person[] }) { return ( r.id} features={[bulkActions([])]} columns={[ { key: "name", sortable: true }, { key: "role" }, { key: "status", Cell: StatusCell, filter: { type: "select", options: "auto" }, }, ]} /> ); } ``` ## Gotchas - **Array-of-arrays data isn't supported.** If your `data` is `[["Ada", "Engineer"], …]`, map it to objects once at the boundary: `rows.map(([name, role], i) => ({ id: String(i), name, role }))` — and use a real id when you have one. - **`rowKey` is required.** mui-datatables keys rows by index internally; AdaptTable wants a stable id. - **`customBodyRender` receives a value; `Cell` receives the row.** Rewrites usually get simpler — no `tableMeta.rowData[…]` index lookups. - **Filters become declarative.** A `filterType: "dropdown"` column becomes `filter: { type: "select", options: "auto" }`; ranges become `"numberRange"` / `"dateRange"` with operator widgets and chips included. - **CSV export composes with `exportCsv()`.** Pass actions to the factory for a toolbar button, or wire `rowsToCsv` / `downloadCsv` yourself via the `toolbar` slot; print is your app's concern. - **`textLabels` → `labels`**, and locale presets (incl. RTL languages) come from [`@adapttable/i18n`](./i18n-rtl.md). - **Features compose in `features`.** Import each factory from its kit subpath; enabling props no longer arm chrome. See [feature composition](./features.md). ## Where next - [Getting started](./getting-started.md) · [Data tiers](./data-tiers.md) · [Filtering](./filtering.md) · [Column management](./column-management.md). - Also migrating the grid elsewhere in your app? See [Migrate from MUI X DataGrid](./migrate-from-mui-x-datagrid.md). - [Comparison](./comparison.md) — where each library fits. --- # Migrate from material-table to AdaptTable — maintained drop-in for MUI ▶ **See it before you install:** [the live demo running on real Material UI](https://orwa-mahmoud.github.io/adapttable/demo/?kit=mui) — same components material-table wraps, nothing to set up. [material-table](https://github.com/mbrn/material-table) was once the default Material UI table. Today it's effectively dormant: the last release with release notes shipped in **August 2020**, maintenance since has been sporadic single-maintainer dependency bumps, and the issue tracker is cleared by a stale-bot rather than by fixes. Roughly **78% of its ~47k weekly downloads still run the 1.x line — which supports only Material-UI v4 and React 16/17** — and the official docs still show the v4 install. (Material React Table's own docs describe the project as having "become abandoned.") If your table is one of those stranded 1.x installs, `@adapttable/mui` renders real Material UI components across **MUI v5 through v9** — migrating unblocks your MUI and React upgrades at the same time. ## First, the honest part: what does NOT map material-table is a table-plus-CRUD-suite. AdaptTable deliberately isn't. If you depend on these, AdaptTable is the wrong target (Material React Table is the closer fit): - **Full row-edit CRUD suites** (`editable` as a material-table-style object with `onRowAdd` / `onRowUpdate`) — AdaptTable ships **opt-in inline cell editing** via `editing(save)` + `ColumnDef.editable` (see [Inline cell editing](./cell-editing.md)), not material-table's row dialog engine. Multi-field forms still go through `rowActions([…])` + your own UI, with the built-in `confirm` seam for destructive actions. - **Drag-to-group aggregation** (`options.grouping`) — AdaptTable ships [row grouping](./row-grouping.md) at any depth (`grouping("team")` or an ordered list, plus optional `groupAggregates`); there is no drag-a-column-to-group UI, so wire your own control to `groupBy` if you need that gesture. - **Tree data** (`parentChildData`) — [`tree({ getParentId })`](./tree-data.md) is the same idea: a flat list with a parent column, rendered as a hierarchy. - **PDF export button** — compose `exportCsv({ writer: pdfWriter })` with `pdfWriter` from `@adapttable/core/pdf`. See [PDF export](./export-pdf.md). What's left — sorted, filtered, searched, paginated, selectable CRUD list tables with remote data — is the majority use case, and it maps cleanly. ## Why move (all verifiable) - **The version trap**: 1.x peers on `@material-ui/core ^4` (a package discontinued years ago); the 2.x escape hatch exact-pins specific MUI versions and rides the deprecated `@mui/styles`. Weekly downloads have roughly halved since 2020–21. - **It mutates your rows**: material-table injects a `tableData` property into your data objects — breaking frozen/Redux state (issues #666, #1979). AdaptTable never writes to your rows. - **No virtualization**: nothing in its public API windows large datasets, and slow-large-table issues are long-standing. AdaptTable has opt-in row/card virtualization via `virtualize()`. - **Fragile foundations**: header-drag grouping depends on the archived `react-beautiful-dnd`. - Plus the AdaptTable batteries the old stack never had: URL-synced shareable state, filter chips, an automatic mobile card layout, saved views, i18n/RTL. ## Install ```bash pnpm add @adapttable/core @adapttable/mui @mui/material ``` Same Material look, on a current MUI — and your rows stay untouched. ## Prop mapping `` → ``: | material-table | `@adapttable/mui` | Notes | | ---------------------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------- | | `data` (array) | `data` | No `tableData` injection — your objects are never mutated. | | `data` (query function) | `onQueryChange` (or `source`) | Mapping below. | | `columns` | `columns` | Field-by-field below. | | `title` | `tableLabel` / your own heading | The toolbar is yours to compose (`toolbar` slot). | | `actions` (row actions) | `rowActions([…])` | Icon buttons on desktop, card buttons on mobile; `confirm` built in. | | `actions` (`isFreeAction: true`) | `toolbar` slot | Free actions are just toolbar content. | | `options.selection` + `onSelectionChange` | `bulkActions([…])`, or `selectedIds` / `onSelectionChange` | Compose `bulkActions()` to turn on selection. | | `detailPanel` | `rowDetail(fn)` | `(row) => ReactNode`. | | `options.paging` / `pageSize` / `pageSizeOptions` | automatic | Paged on desktop, infinite on mobile (`"auto"`). | | `options.search` / `searchText` / `debounceInterval` | built-in search | Debounced + URL-synced by default. | | `options.filtering` | column `filter` shorthands + `filters` | Real widgets + removable chips, not per-column text rows. | | `options.columnsButton` | `columnMenu()` | Show/hide, reorder, pin. | | `options.columnResizable` | `resizableColumns()` | — | | `options.fixedColumns: { left, right }` | column pinning via `columnMenu()` + `columnLayout` | Logical sides — RTL-correct. | | `options.padding: "dense"` | `density="compact"` | — | | `options.exportButton` | `exportCsv()` / `rowsToCsv` + `downloadCsv` | Compose `exportCsv()` for a built-in button; the writer picks CSV, Excel or PDF. | | `options.maxBodyHeight` | `maxHeight` | Enables the scroll box + sticky pinning. | | `localization` | `labels` (+ [`@adapttable/i18n`](./i18n-rtl.md)) | Flat label object; presets for every bundled locale, RTL included. | | `isLoading` | `loading` | — | Column def → `ColumnDef`: | material-table | `@adapttable/mui` | Notes | | ---------------------------------- | --------------------------------------------- | ------------------------------------------------------------------------------- | | `field` | `key` | Dot paths supported. | | `title` | `header` | Auto-derived from `key` when omitted. | | `render(rowData)` | `Cell` / `accessor: (row) => …` | Same idea, typed row. | | `type: "numeric"` | `align: "end"` + `filter: "numberRange"` | Type is behavior you declare, not an enum. | | `type: "date"` / `"datetime"` | `accessor` formatting + `filter: "dateRange"` | Operator-first range widgets included. | | `lookup: { 1: "Active", … }` | `filter: { type: "select", options }` | Options are explicit `{ value, label }[]` (or `"auto"`). | | `sorting` / `defaultSort` | `sortable` + `defaults` | Default sort is `defaults={{ sortBy, sortDir }}` (URL state wins when present). | | `customSort(a, b)` | `sortValue: (row) => primitive` | Extract a comparable value instead of writing a comparator. | | `customFilterAndSearch` | `filter` + `getValue` | Predicate derives from the declaration. | | `hidden` / `hiddenByColumnsButton` | `columnMenu()` + `columnLayout` | User-facing visibility lives in the menu. | | `cellStyle` / `headerStyle` | `align`, `width`, `className`, `Cell` | Style through your own components/classes. | ## Remote data: the query function maps almost 1:1 material-table's remote mode — `data={query => fetch(...).then(r => ({ data, page, totalCount }))}` — is the same idea as AdaptTable's server tier, minus two quirks: ```tsx { // material-table: query.page, query.pageSize, query.search, // query.orderBy (a whole Column OBJECT), query.orderDirection // AdaptTable: query.page, query.limit, query.search, // query.sortBy (the column KEY, a string), query.sortDir, // query.filters const res = await fetch(`/api/people?${toParams(query)}`, { signal }); const body = await res.json(); setRows(body.items); // no { data, page, totalCount } envelope required setTotal(body.total); }} columns={columns} rowKey={(r) => r.id} /> ``` The classic material-table stumbling block — `query.orderBy` being a full column object you have to unwrap — goes away: `sortBy` is the column key, which you chose to be API-stable. ## Before / after **Before** — material-table (MUI v4 era): ```tsx import MaterialTable from "material-table"; function PeopleTable({ people }) { return ( openEdit(row) }, ]} /> ); } ``` **After** — `@adapttable/mui` (typed, URL-synced, current MUI): ```tsx import { DataTable } from "@adapttable/mui"; import { bulkActions } from "@adapttable/mui/bulk-actions"; import { columnMenu } from "@adapttable/mui/column-menu"; import { rowActions } from "@adapttable/mui/row-actions"; function PeopleTable({ people }: { people: Person[] }) { return ( r.id} features={[ columnMenu(), bulkActions([]), rowActions([ { key: "edit", label: "Edit", onClick: (row) => openEdit(row) }, ]), ]} columns={[ { key: "name", sortable: true }, { key: "role" }, { key: "status", filter: { type: "select", options: STATUS_OPTIONS } }, { key: "salary", align: "end", accessor: (r) => formatCurrency(r.salary), sortValue: (r) => r.salary, filter: "numberRange", }, ]} /> ); } ``` ## Gotchas - **Strip `tableData` if you persist rows.** Rows that passed through material-table carry its injected `tableData` property — harmless to AdaptTable, but don't let it leak into your API payloads. - **`rowKey` is required.** material-table keyed rows via its injected ids; AdaptTable wants your stable id. - **Editing is a workflow now, not a table mode.** Recreate `onRowUpdate` / `onRowDelete` as `rowActions([…])` with your own form/dialog; destructive actions get the built-in `confirm` dialog seam. - **`lookup` becomes explicit options.** `{ 1: "Active" }` → `[{ value: "1", label: "Active" }]` (values round-trip through the URL as strings). - **`type` enums become declarations.** `"numeric"` → `align: "end"` (+ `numberRange` filter); dates → an `accessor` that formats + `dateRange` filter; `"currency"` → your formatter, with `sortValue` keeping sort numeric. - **Features compose in `features`.** Import each factory from its kit subpath; enabling props no longer arm chrome. See [feature composition](./features.md). ## Where next - [Getting started](./getting-started.md) · [Data tiers](./data-tiers.md) · [Filtering](./filtering.md) · [Selection & bulk actions](./selection.md). - Same app also uses DataGrid or mui-datatables? See [Migrate from MUI X DataGrid](./migrate-from-mui-x-datagrid.md) and [Migrate from mui-datatables](./migrate-from-mui-datatables.md). - [Comparison](./comparison.md) — where each library fits. --- # Migrate from AG Grid to AdaptTable — CRUD tables in your UI kit, MIT ▶ **See it before you install:** [the live demo](https://orwa-mahmoud.github.io/adapttable/demo/) — flip between Mantine, MUI, Chakra, Ant Design, Radix, Base UI, shadcn and Tailwind on the same data. [AG Grid](https://www.ag-grid.com/) is the best spreadsheet-grade grid in the React ecosystem — and this page starts by telling you when **not** to migrate. The honest line is not a feature list. Pivoting, tree data, cell-range selection, the fill handle, clipboard range operations and Excel export all ship in AdaptTable, under MIT, where AG Grid puts them in its paid Enterprise tier — $999/developer when we checked in August 2026. What AG Grid has that AdaptTable does not is **integration**: one spreadsheet surface, tool panels already assembled, and a decade of behaviour at the edges of it. In AdaptTable those are parts you compose — a pivot engine and its panel, a range model, a side-panel frame you fill, and a grouping panel you opt into. So stay on AG Grid when the grid IS the product: an analytics surface where users pivot, drill and drag fields around all day, or a workflow that leans on Excel-style editing at scale. That week is not worth spending, and the licence buys something real. This page is for the other — much larger — group: teams running **ordinary CRUD tables** on AG Grid Community, paying for spreadsheet power they don't use in two currencies: **churn** (a new AG Grid major about every 6 months — v32.2 rewrote the selection API, v33 made module registration and the Theming API mandatory, v36 overhauled the DOM and CSS class names), and **look** (AG Grid renders its own theme; AdaptTable renders _your_ UI kit's real components). ## When to stay on AG Grid - The grid is the product — an analytics surface users pivot and drill all day - Excel-style cell editing at scale - Assembled tool panels: AdaptTable's `sidePanel({ panels, open, onOpenChange })` is a frame with tabs that you fill (the pivot, saved-views and filter-tree panels ship; arranging them is yours) If you rely on those and the Enterprise licence is worth it to you, that is the right tool. Migrate the CRUD tables, keep the analytics grid — they can run side by side. ## What you gain (for CRUD tables) - **Native look per kit** — MUI tables look like MUI, Mantine like Mantine. No Quartz theme to restyle, no CSS class renames on major upgrades. - **Declarative instead of imperative** — no `gridRef.current.api.*` calls; selection, filters, sort, and page are props and URL state. - **URL-synced shareable state** built in — AG Grid exposes grid state through its API and leaves persistence to you. - **Interactive grouping in every kit** — `groupingPanel()` adds a dedicated strip above the table, desktop header drag-and-drop, chip reorder/removal, mobile selects, aggregation choices, RTL keyboard movement, and polite announcements. `groupBy` and `groupAgg` are URL state and Saved Views capture both. - **Free master/detail** — `rowDetail(fn)` does what AG Grid gates behind Enterprise master/detail (for detail panels, not nested grids). - **A real filter UI for free** — AG Grid's set filter, multi filter, and filter tool panel are Enterprise; AdaptTable's select/multi-select/range filters with chips are MIT. - **Everything is MIT** — no watermark risk when a developer touches the wrong feature flag. ## Install ```bash # pick the adapter for your UI kit pnpm add @adapttable/core @adapttable/mui @mui/material ``` No module registration, no theme objects, no CSS imports — the adapter renders through the kit provider your app already has. ## Prop mapping Grid-level (`` → ``): | AG Grid | AdaptTable | Notes | | -------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------- | | `rowData` | `data` | — | | `columnDefs` | `columns` | `ColDef` mapping below. | | `defaultColDef` | (not needed) | Column defaults are explicit per column. | | `pagination` + `paginationPageSize` | automatic | Paged on desktop, infinite on mobile; page-size control built in. | | `rowSelection={{ mode: "multiRow" }}` | `bulkActions([…])`, or `selectedIds` / `onSelectionChange` | Compose `bulkActions()` to turn on checkboxes. | | `api.getSelectedRows()` | `onSelectionChange` state | Selection is React state, not an API call. | | `rowModelType: "infinite"` | `onQueryChange` (+ infinite mode) | One consolidated query; superseded fetches abort via `signal`. | | `api.exportDataAsCsv()` | `exportCsv()` / `rowsToCsv` + `downloadCsv` | Compose `exportCsv()` for a toolbar button or headless helpers. | | `theme={themeQuartz}` | your kit's provider/theme | The table inherits the design system, incl. dark mode. | | `` | (nothing) | No module registry. | | `onGridReady` / `gridRef.current.api` | (nothing) | Declarative props replace the imperative API. | | column tool panel (Enterprise side bar) | `columnMenu()` | Show/hide, reorder, pin — free. | | `rowGroupPanelShow` / tool-panel grouping | `groupingPanel()` | Dedicated strip; drag headers on desktop, use selects on mobile. | | `getDetailPanelContent`-style master/detail (Ent.) | `rowDetail(fn)` | `(row) => ReactNode`, free. | `ColDef` → `ColumnDef`: | AG Grid | AdaptTable | Notes | | ----------------------------------- | --------------------------------------------------------------- | -------------------------------------------------- | | `field` | `key` | Dot paths supported. | | `headerName` | `header` | Auto-derived from `key` when omitted. | | `valueGetter` / `valueFormatter` | `accessor: (row) => …` | One function, receives the row. | | `cellRenderer` | `Cell` | Component receiving `{ row, rowIndex }`. | | `sortable` (default true) | `sortable` (default false) | Opt-in instead of opt-out. | | `comparator` | `sortValue` | Extract a comparable primitive. | | `filter: "agTextColumnFilter"` etc. | `filter: "text"` / `"select"` / `"numberRange"` / `"dateRange"` | Widgets + chips derived from the declaration. | | `flex` / `width` / `minWidth` | `width` | — | | `pinned: "left" \| "right"` | pinning via `columnMenu()` + `columnLayout` | Logical sides — RTL-correct. | | `hide` | `columnMenu()` + `columnLayout` | User-facing visibility lives in the menu. | | `resizable` (default true) | `resizableColumns()` (table-level) | — | | `editable` | `editable` + `editor` + `editing(save)` | Opt-in; no editor until `editing()` is composed. | | `rowGroup: true` | Initial key in `groupingPanel(["team"])` | Users can add, reorder, or remove keys afterwards. | | `aggFunc` | `groupAggregates` default + panel aggregation override | Session choices are sum/avg/min/max/count/none. | ## Before / after **Before** — AG Grid Community (v36 setup): ```tsx import { AgGridReact } from "ag-grid-react"; import { AgGridProvider, AllCommunityModule, themeQuartz, } from "ag-grid-community"; import { useRef } from "react"; function PeopleGrid({ rows }: { rows: Person[] }) { const gridRef = useRef(null); return ( fmt(p.value) }, ]} /> ); } ``` **After** — AdaptTable (here with the MUI adapter; swap the import for your kit): ```tsx import { DataTable } from "@adapttable/mui"; import { bulkActions } from "@adapttable/mui/bulk-actions"; import { columnMenu } from "@adapttable/mui/column-menu"; import { groupingPanel } from "@adapttable/mui/grouping-panel"; function PeopleTable({ people }: { people: Person[] }) { return ( r.id} features={[bulkActions([]), columnMenu(), groupingPanel()]} columns={[ { key: "name", sortable: true }, { key: "role" }, { key: "status", filter: { type: "select", options: "auto" } }, { key: "salary", align: "end", accessor: (r) => fmt(r.salary), sortValue: (r) => r.salary, filter: "numberRange", }, ]} /> ); } ``` No provider wrapper, no modules, no theme object, no ref — and the rendered table is your design system's, not a themed grid. ## Gotchas - **The analytics features are parts, not a mode.** [Pivoting](./pivot.md) is an engine plus its own panel; [nested grouping](./row-grouping.md) is `groupingPanel(["team", "status"], extras)` with header drag-and-drop, selects, and optional aggregate defaults; [tree data](./tree-data.md) is `tree({ getChildren })` or `tree({ getParentId })`; range selection, the fill handle and clipboard operations all need `cellNavigation()` composed (see [Cell navigation](./cell-navigation.md)). Each works — none is the single spreadsheet surface AG Grid hands you, so budget assembly time rather than a prop rename. - **Cell editing is opt-in.** Map AG Grid `editable` columns to `ColumnDef.editable` + `editing(save)` (see [Inline cell editing](./cell-editing.md)). For a multi-field edit dialog, `rowActions([…])` + your own form is still the answer — that part AdaptTable deliberately leaves to you. - **The imperative API disappears.** Anywhere you called `api.getSelectedRows()`, `api.setFilterModel()`, or `api.sizeColumnsToFit()`, you now read props/state (`onSelectionChange`, URL-synced filters, column `width`s). This is usually the bulk of the rewrite — and the part that stops breaking on majors. - **Sorting is opt-in.** AG Grid columns sort by default; AdaptTable columns need `sortable: true`. - **Export is a writer, not three features.** Compose `exportCsv()` for the toolbar button; the format is whatever writer you hand it — `csvWriter`, `xlsxWriter` from `@adapttable/core/xlsx` for a real spreadsheet, or `pdfWriter` from `@adapttable/core/pdf`. Excel export is AG Grid Enterprise; here it is one import. (Its context menu is Enterprise anyway.) - **Row virtualization is opt-in** (`virtualize()`) rather than always-on — enable it for genuinely large lists; a benchmark lives in the [virtualization docs](./virtualization.md). - **Features compose in `features`.** Import each factory from its kit subpath; enabling props no longer arm chrome. See [feature composition](./features.md). ## Where next - [Getting started](./getting-started.md) · [Data tiers](./data-tiers.md) · [Filtering](./filtering.md) · [Column management](./column-management.md). - [Comparison](./comparison.md) — the honest feature table, including where AG Grid wins. --- # Upgrading from v2 v3 removes the enabling props. A feature is an import and an entry in `features`, and that is the only way in — which is what lets a table download what it named and nothing else. Measured adapter sizes live in the [FAQ](./faq.md#how-big-is-it--is-it-tree-shakeable); the acceptance ceiling is 80 KB min+gzip. Nothing else about the table changed: the same columns, the same data tiers, the same URL state, the same labels. ## The shortest upgrade If your table used most of the chrome, one import replaces the lot: ```tsx import { DataTable } from "@adapttable/mantine"; import { standardFeatures } from "@adapttable/mantine/preset"; r.id} features={standardFeatures({ grouping: "team", filters: filterDefs })} />; ``` `standardFeatures()` brings the Columns menu, the density chooser, CSV export, find-in-table, fit-columns, the fullscreen toggle, header filters, multi-sort, resizable columns and the status bar. Grouping, bulk actions, filters and saved views join when you pass their options. See [feature composition](./features.md) for what the preset costs against individual imports. ## The smallest upgrade Import only what you used. Each row below is a prop you were passing and the factory that replaces it: ```tsx // v2 ; // v3 import { columnMenu } from "@adapttable/mantine/column-menu"; import { editing } from "@adapttable/mantine/editing"; import { exportCsv } from "@adapttable/mantine/export"; import { grouping } from "@adapttable/mantine/grouping"; ; ``` No type arguments are needed. A factory whose configuration says nothing about rows composes into any table; one that takes a row-typed callback infers the row from that callback. ## Every removed prop, and what replaces it | Removed prop | Replacement | Import | | ----------------------------------------------------------------------------- | ---------------------------- | ------------------------------------- | | `batchEditing`, `onBatchEdit` | `batchEditing(…)` | `@adapttable//editing` | | `bulkActions` | `bulkActions(…)` | `@adapttable//bulk-actions` | | `cellNavigation` | `cellNavigation(…)` | `@adapttable//cell-navigation` | | `getCellSpan`, `cellSpanAppearance` | `cellSpan(…)` | `@adapttable//cell-span` | | `collapsibleColumnGroups` | `collapsibleColumnGroups(…)` | `@adapttable//column-groups` | | `enableColumnMenu` | `columnMenu(…)` | `@adapttable//column-menu` | | `columnSelectionCheckbox` | `columnSelectionCheckbox(…)` | `@adapttable//column-selection` | | `commandPalette` | `commandPalette(…)` | `@adapttable//command-palette` | | `contextMenu` | `contextMenu(…)` | `@adapttable//context-menu` | | `densityChooser` | `densityChooser(…)` | `@adapttable//density` | | `dirtyIndicators` | `dirtyIndicators(…)` | `@adapttable//editing` | | `editHistory` | `editHistory(…)` | `@adapttable//editing` | | `onCellEdit` | `editing(…)` | `@adapttable//editing` | | `exportCsv` | `exportCsv(…)` | `@adapttable//export` | | `extraRows` | `extraRows(…)` | `@adapttable//extra-rows` | | `filters` | `filters(…)` | `@adapttable//filters` | | `filterTypes` | `filterTypes(…)` | `@adapttable//filters` | | `findInTable` | `findInTable(…)` | `@adapttable//find-in-table` | | `fitColumns` | `fitColumns(…)` | `@adapttable//fit-columns` | | `fullscreen` | `fullscreen(…)` | `@adapttable//fullscreen` | | `groupBy` | `grouping(…)` | `@adapttable//grouping` | | `headerFilters` | `headerFilters(…)` | `@adapttable//header-filters` | | `multiSort` | `multiSort(…)` | `@adapttable//multi-sort` | | `nestedTable` | `nestedTable(…)` | `@adapttable//nested-table` | | `onPrint`, `printButton` | `print(…)` | `@adapttable//print` | | `resizableColumns` | `resizableColumns(…)` | `@adapttable//resizable-columns` | | `onAddRow`, `onDuplicateRow`, `onDeleteRow`, `confirmDeleteRow`, `rowActions` | `rowActions(…)` | `@adapttable//row-actions` | | `rowStyle`, `rowHeight`, `rowClassName` | `rowAppearance(…)` | `@adapttable//row-appearance` | | `renderRowDetail`, `defaultExpandedRowIds` | `rowDetail(…)` | `@adapttable//row-detail` | | `rowEditing`, `onRowEdit` | `rowEditing(…)` | `@adapttable//editing` | | `pinnedRowIds`, `onPinnedRowIdsChange` | `rowPinning(…)` | `@adapttable//row-pinning` | | `savedViews` | `savedViews(…)` | `@adapttable//saved-views` | | `selectionStats` | `selectionStats(…)` | `@adapttable//selection-stats` | | `sidePanel` | `sidePanel(…)` | `@adapttable//side-panel` | | `statusBar` | `statusBar(…)` | `@adapttable//status-bar` | | `getChildren`, `getParentId`, `treeColumn`, `onLoadChildren` | `tree(…)` | `@adapttable//tree` | | `undoRedoButtons` | `undoRedoButtons(…)` | `@adapttable//editing` | | `virtualize`, `virtualizeColumns` | `virtualize(…)` | `@adapttable//virtualize` | Companion props travel with their feature: `cellSpanAppearance` is the second argument to `cellSpan`, `defaultExpandedRowIds` the second to `rowDetail` and `nestedTable`, `printButton` the second to `print`. Grouping's extras (`groupAggregates`, `groupSort`, `groupFooters`, paging, collapse state) are one options object: ```tsx grouping("team", { groupAggregates: (rows: readonly Person[]) => ({ spend: total(rows) }), groupFooters: true, }); ``` ## The other four removals **Main-entry adapter machinery.** 72 names that were re-exported from `@adapttable/core` now live only on `@adapttable/react/adapter`. The builder tier moved with the React binding, because everything in it renders: ```ts // v2 import { headerGroupRows } from "@adapttable/core"; // v3 import { headerGroupRows } from "@adapttable/react/adapter"; ``` Every moved name keeps the same spelling: | Removed from `@adapttable/core` | v3 import | | ------------------------------- | --------------------------- | | `COLUMN_GROUP_ID_SEP` | `@adapttable/react/adapter` | | `COLUMN_GROUP_RENDER_PREFIX` | `@adapttable/react/adapter` | | `COLUMN_GROUP_STUB_PREFIX` | `@adapttable/react/adapter` | | `COLUMN_GROUP_STUB_WIDTH` | `@adapttable/react/adapter` | | `columnGroupHeaderCaption` | `@adapttable/react/adapter` | | `columnGroupId` | `@adapttable/react/adapter` | | `columnGroupPath` | `@adapttable/react/adapter` | | `columnGroupStubStyle` | `@adapttable/react/adapter` | | `groupedHeaderAlign` | `@adapttable/react/adapter` | | `groupedHeaderCellStyle` | `@adapttable/react/adapter` | | `groupedHeaderChildRule` | `@adapttable/react/adapter` | | `groupedHeaderLabelStyle` | `@adapttable/react/adapter` | | `HeaderGroupCell` | `@adapttable/react/adapter` | | `headerGroupRow` | `@adapttable/react/adapter` | | `headerGroupRows` | `@adapttable/react/adapter` | | `HtmlGroupedHeaderCell` | `@adapttable/react/adapter` | | `htmlGroupedHeaderPlan` | `@adapttable/react/adapter` | | `isColumnGroupRenderKey` | `@adapttable/react/adapter` | | `isColumnGroupStubKey` | `@adapttable/react/adapter` | | `isColumnGroupSummaryKey` | `@adapttable/react/adapter` | | `toggleCollapsedColumnGroup` | `@adapttable/react/adapter` | | `EXTRA_OVER_SPAN_ROW_STYLE` | `@adapttable/react/adapter` | | `EXTRA_OVER_SPAN_STYLE` | `@adapttable/react/adapter` | | `EXTRA_ROW_PARTS` | `@adapttable/react/adapter` | | `extraCountBeforeRowIds` | `@adapttable/react/adapter` | | `extraCoveredTableSlots` | `@adapttable/react/adapter` | | `ExtraEntry` | `@adapttable/react/adapter` | | `extraHostFillStyle` | `@adapttable/react/adapter` | | `extraRowsForSection` | `@adapttable/react/adapter` | | `extraUncoveredColSpans` | `@adapttable/react/adapter` | | `inflateBodyCellRowSpans` | `@adapttable/react/adapter` | | `insertExtraRows` | `@adapttable/react/adapter` | | `insertExtrasBeforeRows` | `@adapttable/react/adapter` | | `isExtraEntry` | `@adapttable/react/adapter` | | `orderedCardEntries` | `@adapttable/react/adapter` | | `PINNED_BOTTOM_PART` | `@adapttable/react/adapter` | | `PINNED_TOP_PART` | `@adapttable/react/adapter` | | `pinnedRowCellStyle` | `@adapttable/react/adapter` | | `pinnedRowPart` | `@adapttable/react/adapter` | | `pinnedRowSticky` | `@adapttable/react/adapter` | | `pinnedRowStickyStyle` | `@adapttable/react/adapter` | | `useOffsetHeight` | `@adapttable/react/adapter` | | `columnMenuActions` | `@adapttable/react/adapter` | | `filterColumnMenuRows` | `@adapttable/react/adapter` | | `hideAllColumns` | `@adapttable/react/adapter` | | `resetColumnLayout` | `@adapttable/react/adapter` | | `showAllColumns` | `@adapttable/react/adapter` | | `unpinAllColumns` | `@adapttable/react/adapter` | | `BodyCell` | `@adapttable/react/adapter` | | `bodyCellsHaveRowSpan` | `@adapttable/react/adapter` | | `cellsForRow` | `@adapttable/react/adapter` | | `cellSpanMark` | `@adapttable/react/adapter` | | `rowSpanSignature` | `@adapttable/react/adapter` | | `REORDER_COLUMN_WIDTH` | `@adapttable/react/adapter` | | `ROW_DND_MIME` | `@adapttable/react/adapter` | | `rowReorderDropStyle` | `@adapttable/react/adapter` | | `rowReorderSignature` | `@adapttable/react/adapter` | | `RowReorderState` | `@adapttable/react/adapter` | | `resolveRowHeight` | `@adapttable/react/adapter` | | `resolveRowStyle` | `@adapttable/react/adapter` | | `rowStyleSignature` | `@adapttable/react/adapter` | | `EditableCellActivateProps` | `@adapttable/react/adapter` | | `EditableCellButtonProps` | `@adapttable/react/adapter` | | `EditableCellSlots` | `@adapttable/react/adapter` | | `FilterHeaderClassNames` | `@adapttable/react/adapter` | | `FilterHeaderRowProps` | `@adapttable/react/adapter` | | `applyCollapsedColumnGroups` | `@adapttable/react/adapter` | | `flattenColumnTree` | `@adapttable/react/adapter` | | `FullscreenState` | `@adapttable/react/adapter` | | `useFullscreen` | `@adapttable/react/adapter` | | `rowPinSignature` | `@adapttable/react/adapter` | | `rowSourceIndex` | `@adapttable/react/adapter` | **`useChromeBodyData`.** Choose the implementation the host actually renders: `usePlainChromeBodyData` for a normal table, or `useVirtualChromeBodyData` for the virtualized feature path. Both remain available from `@adapttable/core`. **`FilterTypeRegistry.register` / `FilterTypeRegistry.extend`.** A custom filter type registers through a feature, on the same object every other extension uses: ```tsx // v2 const registry = FilterTypeRegistry.register(mySpec); // v3 features={[{ id: "my-filter", setup: (host) => host.registerFilterType(mySpec) }]} ``` `filterTypes([mySpec])` from `@adapttable//filters` does the same thing without writing the object by hand. **MUI's `size` prop.** Use `density`: `density="compact"` is what `size="small"` meant, and `density="comfortable"` is `size="medium"`. The React binding took the rest of the main entry with it — the hooks, the React column and the prop-getters a host imports directly: | Removed from `@adapttable/core` | v3 import | | ---------------------------------- | ------------------- | | `aggregate` | `@adapttable/react` | | `AggregateOptions` | `@adapttable/react` | | `Aggregator` | `@adapttable/react` | | `BaseDataTableProps` | `@adapttable/react` | | `BulkAction` | `@adapttable/react` | | `CellElementProps` | `@adapttable/react` | | `ChromeBodyData` | `@adapttable/react` | | `ColumnFooterContext` | `@adapttable/react` | | `ColumnGroupDef` | `@adapttable/react` | | `ColumnGroupRecord` | `@adapttable/react` | | `ColumnHeaderController` | `@adapttable/react` | | `columnHeaderLabel` | `@adapttable/react` | | `ColumnReorderKeyProps` | `@adapttable/react` | | `ColumnResizeHandleProps` | `@adapttable/react` | | `ComputedColumnSpec` | `@adapttable/react` | | `CustomCellEditorRender` | `@adapttable/react` | | `EditableCellActivateControlProps` | `@adapttable/react` | | `EditableCellControls` | `@adapttable/react` | | `EditableCellGate` | `@adapttable/react` | | `EditableCellGateProps` | `@adapttable/react` | | `ExportContext` | `@adapttable/react` | | `exportViewFromChrome` | `@adapttable/react` | | `ExtraRow` | `@adapttable/react` | | `FeatureProps` | `@adapttable/react` | | `FeatureProviderContribution` | `@adapttable/react` | | `FeatureProviderProps` | `@adapttable/react` | | `FeatureRender` | `@adapttable/react` | | `FilterChromeMode` | `@adapttable/react` | | `FilterTypeSpec` | `@adapttable/react` | | `groupAggregateEntries` | `@adapttable/react` | | `GroupAggregatesFn` | `@adapttable/react` | | `GroupedFlatEntry` | `@adapttable/react` | | `GroupingChipKeyboardProps` | `@adapttable/react` | | `GroupingDragProps` | `@adapttable/react` | | `GroupingDropProps` | `@adapttable/react` | | `GroupRowCell` | `@adapttable/react` | | `groupRowLayout` | `@adapttable/react` | | `HeaderFilterOpenProvider` | `@adapttable/react` | | `headerFilterStickTop` | `@adapttable/react` | | `IncrementalView` | `@adapttable/react` | | `IncrementalViewConfig` | `@adapttable/react` | | `isDeclarativeFilters` | `@adapttable/react` | | `MobileCardField` | `@adapttable/react` | | `MobileCardRenderer` | `@adapttable/react` | | `MultiSelectEditorCheckboxProps` | `@adapttable/react` | | `MultiSelectEditorChrome` | `@adapttable/react` | | `MultiSelectEditorChromeProps` | `@adapttable/react` | | `MultiSelectEditorSlots` | `@adapttable/react` | | `NestedTable` | `@adapttable/react` | | `renderRegisteredFilter` | `@adapttable/react` | | `resolveColumnFooter` | `@adapttable/react` | | `resolveColumnHeader` | `@adapttable/react` | | `RowAction` | `@adapttable/react` | | `RowActionsRenderer` | `@adapttable/react` | | `RowStyle` | `@adapttable/react` | | `SidePanelEntry` | `@adapttable/react` | | `Slot` | `@adapttable/react` | | `TableChrome` | `@adapttable/react` | | `TableExtraEntry` | `@adapttable/react` | | `TableRowReorderState` | `@adapttable/react` | | `ToolbarSlots` | `@adapttable/react` | | `useActiveFilterChips` | `@adapttable/react` | | `useBatchEditing` | `@adapttable/react` | | `useBooleanFilterWidget` | `@adapttable/react` | | `useBulkActionRunner` | `@adapttable/react` | | `useCellEditing` | `@adapttable/react` | | `useCellSaveState` | `@adapttable/react` | | `useChecklistFilter` | `@adapttable/react` | | `useChromeScrollReset` | `@adapttable/react` | | `useColorScheme` | `@adapttable/react` | | `useColumnDragState` | `@adapttable/react` | | `useColumnLayout` | `@adapttable/react` | | `useColumnLayoutStorageState` | `@adapttable/react` | | `useColumnLayoutUrlState` | `@adapttable/react` | | `UseDataTableResult` | `@adapttable/react` | | `useDebounce` | `@adapttable/react` | | `useDensityUrlState` | `@adapttable/react` | | `useDirtyCells` | `@adapttable/react` | | `useEditConflict` | `@adapttable/react` | | `useEditHistory` | `@adapttable/react` | | `useExtraChips` | `@adapttable/react` | | `useFilterOptions` | `@adapttable/react` | | `useFilterTreeChips` | `@adapttable/react` | | `useFilterTriggerToggle` | `@adapttable/react` | | `useFindFocus` | `@adapttable/react` | | `useFindInTable` | `@adapttable/react` | | `useFrontendData` | `@adapttable/react` | | `useGridFocus` | `@adapttable/react` | | `useGroupCollapse` | `@adapttable/react` | | `useGroupCollapseUrlState` | `@adapttable/react` | | `useGroupPaging` | `@adapttable/react` | | `useHeaderFilterOverlay` | `@adapttable/react` | | `useHighlight` | `@adapttable/react` | | `useHorizontalOverflow` | `@adapttable/react` | | `useInfiniteScroll` | `@adapttable/react` | | `useIsMobile` | `@adapttable/react` | | `useLazyChildren` | `@adapttable/react` | | `useMediaQuery` | `@adapttable/react` | | `usePointerDismiss` | `@adapttable/react` | | `usePrefersReducedMotion` | `@adapttable/react` | | `useRangeFilterWidget` | `@adapttable/react` | | `useRowEditing` | `@adapttable/react` | | `useRowExpansion` | `@adapttable/react` | | `useRowMutations` | `@adapttable/react` | | `useRowPinning` | `@adapttable/react` | | `useRowPinningUrlState` | `@adapttable/react` | | `useRowReorder` | `@adapttable/react` | | `useSavedViews` | `@adapttable/react` | | `useScrollToTableTop` | `@adapttable/react` | | `UseScrollToTableTopOptions` | `@adapttable/react` | | `useSearchInput` | `@adapttable/react` | | `useSelection` | `@adapttable/react` | | `useServerData` | `@adapttable/react` | | `useShortcuts` | `@adapttable/react` | | `useTableData` | `@adapttable/react` | | `UseTableDataOptions` | `@adapttable/react` | | `useTableEditHistory` | `@adapttable/react` | | `useTableUrlState` | `@adapttable/react` | | `useTableVirtualization` | `@adapttable/react` | | `useTextFilterWidget` | `@adapttable/react` | | `useTreeExpansion` | `@adapttable/react` | ## Checking the upgrade The compiler finds every call site: a removed prop is not in `DataTableProps` any more, so `tsc` names each one. There is no deprecation warning to grep for, because there is nothing left to deprecate. This repository rehearses that upgrade against packed tarballs for every published kit — preset-equivalent first, then the four-import minimal path — with `pnpm migrate:rehearse`. Headless consumers keep `useDataTable`. The release receipt is `scripts/v3-receipt.md`. Run the v3 codemod over the source directories: ```bash npx @adapttable/cli migrate-v3 src npx @adapttable/cli migrate-v3 src --check ``` The codemod performs one provably mechanical rewrite: named adapter-contract imports move from `@adapttable/core` to `@adapttable/react/adapter`, splitting a mixed import when necessary. It is idempotent; the second run reports zero updates. Enabling props, `FilterTypeRegistry.register` / `extend`, `useChromeBodyData`, and MUI `size` need behavior choices, so the command reports each location and exits non-zero without rewriting it. Use those locations with the inventory above; no feature order or option mapping is guessed. Item 16 extends the same command for the package split below: React hooks, Chrome, and renderer types move from `@adapttable/core` to `@adapttable/react`. Until that ships, the map is the contract — not a runtime. ## v3 package split v3's remaining foundation change: `@adapttable/core` becomes framework-neutral and React moves to `@adapttable/react`. Kit `DataTable` imports do not change. Capabilities do not disappear. Relocation is not deletion. The complete symbol map — every current public export, its kind, class, and destination — is [`scripts/v3-package-split-map.json`](../scripts/v3-package-split-map.json). `node scripts/check-package-split-map.mjs` fails if a published typed entry is missing from the map or a mapped symbol has no current home. Read [ARCHITECTURE.md](../ARCHITECTURE.md) for the package graph and the engine / column / AI contracts. ### How to read a move | Today's import | After the split | What moved | | -------------------------------------------------- | ------------------------------------------------- | --------------------------------------------------- | | `@adapttable/core` → `useDataTable` | `@adapttable/react` | Headless React hook | | `@adapttable/core` → `ColumnDef` | `@adapttable/react` | React column (extends neutral `ColumnModel`) | | `@adapttable/core` → `TableSource` | `@adapttable/core` | Unchanged | | `@adapttable/core` → `useQuerySource` | `@adapttable/react` | React source hook; query types stay on core | | `@adapttable/core` → `useTableChrome` | `@adapttable/react` | Chrome binding | | `@adapttable/react/adapter` → `HeaderGroupCell` | `@adapttable/react/adapter` | React adapter chrome | | `@adapttable/react/adapter` → `sourceCapabilities` | `@adapttable/core` or `@adapttable/react/adapter` | Neutral helper stays in core | | `@adapttable/react/features` → `grouping` | `@adapttable/react/features` | Feature factory that returns a React `TableFeature` | | `@adapttable/core/pivot` → `pivot` | `@adapttable/core/pivot` | Pure engine | | `@adapttable/core/pivot` → `usePivotUrlState` | `@adapttable/react/pivot` | React hook | | `@adapttable/mui` → `DataTable` | `@adapttable/mui` | Unchanged | | `@adapttable/ai` → `createAgentSession` | `@adapttable/ai` | Unchanged | | `@adapttable/ai/react` → `tableAgent` | `@adapttable/ai-react` | React binding moved off `@adapttable/ai` | | `@adapttable/ai/http` → `createAgentHttpClient` | `@adapttable/ai/http` | Unchanged | No public symbol is retired by this split. A row in the map whose `proposedImport` equals `currentImport` is a keep. Every other row is a specifier change only, unless `behavior` is non-empty. ### Representative consumers Plain engine (no React installed) — item 10 must make this typecheck and run: ```ts import { sourceCapabilities, type TableSourceCapabilities, } from "@adapttable/core"; import { pivot } from "@adapttable/core/pivot"; const capabilities: TableSourceCapabilities = sourceCapabilities({ allFilteredRows: rows, total: rows.length, }); const result = pivot(rows, { rows: ["team"], columns: [], measures: [] }); ``` Headless React — today's `useDataTable` with the new specifier: ```tsx import { useDataTable, type ColumnDef } from "@adapttable/react"; const columns: ColumnDef[] = [{ key: "name", header: "Name" }]; const table = useDataTable({ data: people, columns, rowKey: (r) => r.id }); ``` Kit — no import change: ```tsx import { DataTable } from "@adapttable/mui"; import { grouping } from "@adapttable/mui/grouping"; r.id} features={[grouping("team")]} />; ``` AI — root stays React-free; the React feature binds to the live engine: ```ts import { createAgentSession } from "@adapttable/ai"; import { tableAgent } from "@adapttable/ai-react"; const session = createAgentSession({ observe, apply }); const feature = tableAgent({ tableId: "orders" }); ``` These four snippets must keep agreeing with the frozen contracts in ARCHITECTURE.md as items 10–15 land. They are examples, not published stubs. --- # Migrating from v1 to v2 v2 is a truth-and-consistency release: every documented behavior now works as written, the same word always means the same thing across all eight adapters, and a batch of silent traps became loud. Nothing deprecated ships — v1 names were removed, not aliased, so the compiler walks you through the rename table below. ## The rename table | v1 | v2 | Where | | ------------------------------------------------------------- | ------------------------------------------------- | ------------------------------------------------------------- | | `enabled` | `urlSync` | source hooks + `useTableUrlState` | | `adapter` | `urlAdapter` | source hooks + `UseSavedViewsOptions` | | `useBackendData` / `UseBackendDataOptions` | `useQuerySource` / `UseQuerySourceOptions` | the query-library source builder | | `defaultLayout` | `defaultColumnLayout` | column-layout hooks | | `selected` / `onChange` | `selectedIds` / `onSelectionChange` | `useSelection` options | | `collapsedIds` / `onCollapsedIdsChange` | `collapsedGroupIds` / `onCollapsedGroupIdsChange` | row grouping | | `customToolbar` | `toolbar` | `ToolbarChromeProps` | | `PaginatedResponse.items` / `.hasNext` | `.rows` / `.hasNextPage` | response envelope | | `SortState` | `SortLevel` (one merged type) | sorting types | | `colorScheme` (Chakra accent) | `accentColor` | Chakra adapter (frees the light/dark type name) | | `hideSearch` | `searchable` (positive polarity, default `true`) | all adapters | | `isMobile` prop | `forceMobile` | all adapters | | `labels.applyFilters` | `labels.filtersDone` | labels (the button closes; filters apply live) | | `SavedViewsMenuLabels` (and per-kit variants) | `SavedViewsLabels` | every adapter, one exported name | | `PageSelector` returning `{ items }` | `{ rows }` | `useQuerySource` custom `selectPage` | | `GroupCollapseState.collapsedIds` (and the model option) | `collapsedGroupIds` | headless grouping | | `useDataTable` option `isMobile` | `forceMobile` (result field stays `isMobile`) | headless tier | | `emptyState` / `loadingState` props | `slots.empty` / `slots.skeleton` | unstyled / shadcn (one override surface) | | `classNames.rowsPerPageSelect` | `classNames.rowsPerPage` | unstyled / shadcn | | `classNames.pageButton` | `pagePrev` / `pageNext` / `pageNumber` | unstyled / shadcn (one key per rendered part) | | `data-adapttable-part="group-row/group-cell"` (header groups) | `header-group-row` / `header-group-cell` | header groups; `group-row`/`group-cell` now mean ROW grouping | | `data-adapttable-part="retry"` | `retry-button` | error state | | antd `virtualHeight` / `virtualWidth` | removed — bound the scroller with `maxHeight` | antd adapter | ## Behavior changes (and why) - **The adapter-builder tier moved to `@adapttable/core/adapter`.** The ~65 exports only adapter implementations consume — `useDataTableShell`, the render prelude, chrome prop bundles, pinning and pager math, the inline icons — import from the new entry; `@adapttable/core` keeps the app-facing API. _Why: autocomplete and docs at the main entry now show only what app code is meant to use._ - **React 18 works again.** v1.2 accidentally required React 19.2 (`useEffectEvent`); v2 runs the declared `^18 || ^19` range and CI proves it on 18.3 / 19.0 / 19.2. - **Explicit `mode` prop.** `mode="server"` requires `onQueryChange` at compile time; `mode="frontend"` turns `onQueryChange` into a pure notification (the table keeps processing your `data`). Omitting `mode` keeps v1 inference exactly. _Why: the frontend tier had no way to observe query changes without surrendering the data path._ - **`onGroupByChange` and `onClearFilters` are observers.** The table always performs the change itself, then notifies. _Why: providing a logging handler used to silently replace the built-in mutation._ Take full control via `source.setGroupBy` / `source.clearExtras`. - **One source-flag contract.** `isLoading` = first load only; `isFetching` = any in-flight; `hasNextPage`/`fetchNextPage` = infinite-append only (false / no-op when paged); `refetch` really re-runs. The `onQueryChange` tier now APPENDS on `fetchNextPage` (accumulating across round-trips) instead of replacing the page, and it resolves `paginationMode: "auto"` like the other tiers — mobile becomes infinite cards; pass `paginationMode="paged"` for the v1 behavior. - **`baseParams` never beat live state, and filters are namespaced.** Query hooks receive filter values under `params.filters` instead of spread at the top level. _Why: a static param could silently override an active filter, and a filter named `sortBy` clobbered the sort._ - **The headless tier renders real tables.** `useDataTable` resolves bare-key columns (headers + accessors) like `` does, and `getRowProps` no longer smuggles `key` inside its spreadable result — read `getRowKey(row)`; `getCellContent(column, row, index)` renders Cell-or-accessor without casts. - **`defaults`, `searchDebounceMs` and `paginationMode` are real component props** (they were documented but didn't exist), and `virtualize` on a paged table dev-warns instead of silently doing nothing. - **An explicit `hideOnMobile: true` always wins** — the mobile identity anchor no longer forces hidden columns onto cards. A column marked `editable` without a composed `editing()` feature dev-warns instead of doing nothing. - **The selection contract is settled**: uncontrolled =` onSelectionChange` observes (mount fire with the empty set, auto-reset on search/filter change included); controlled = change-request handler, no mount fire. - **`clearAll` clears the multi-sort chain too** (it used to leave rows visibly sorted). - **Grouped tables are a full-set view**: footer count, select-all scope and page-scope CSV all describe the rendered (full filtered) set. - **CSV export**: formula-prefixed cells are neutralised by default (`escapeFormulas: false` opts out), and the export always contains the full exportable column set regardless of viewport. - **`defaultConfirm` fails safe**: no dialog available (SSR, webviews) now DENIES a destructive action instead of auto-approving it. - **Persisted state hydrates after mount** (column layout, saved views) — no SSR hydration mismatch; blocked storage is tolerated. - **Styling surface is 1:1** — every `classNames` key maps to a rendered `data-adapttable-part` and vice versa (127 keys, enforced by tests); the shadcn preset styles every part; MUI and antd gained structural `classNames`; Chakra/Radix/Base UI export their `DataTableClassNames`. - **`density` has a visual effect in every adapter** and kit `size` overrides it; `stickyTop` means the sticky-header inset everywhere (`stickyToolbar` follows `stickyHeader` on page-scroll tables; pass `false` to let the toolbar scroll away); Mantine's `SavedViewsMenu` takes the same `options` shape as the other kits. - **Accessibility**: editable cells expose the VALUE as their accessible name (edit hint as title); Enter-commit restores focus; clickable rows use a roving tab stop and activate on Space; menus restore focus on Escape; antd multi-sort chains from the keyboard (Shift+Enter); bulk selection announces via a live region; the pager announces the current page. - **i18n**: one locale-resolution algorithm for labels AND per-column `i18n` paths (`ar_EG` ≡ `AR-eg`); count-aware plural forms; a new `labels.removeFilter` names chip removal; the RTL list is script-based (Hausa out, Assyrian Neo-Aramaic / Western Punjabi / South Azerbaijani in). - **Packaging**: truthful peer floors (Chakra `^3.13`, MUI `^6`, Mantine `^7.2`); `"use client"` in every hook-bearing build (Next App Router imports work without wrappers); LICENSE in every tarball; the CLI gained a CJS entry and stopped scaffolding on a bare invocation. ## Suggested path 1. Update the packages; let the compiler surface the renames (table above). 2. If you consume the `onQueryChange` tier: move filter reads to `query.filters` / `params.filters`, and re-test infinite flows (append semantics). 3. If you relied on replacing behavior through `onGroupByChange` / `onClearFilters`, switch to `source.setGroupBy` / `source.clearExtras`. 4. Re-run your visual checks on mobile if you used the server tier (auto → infinite cards) or relied on identity-anchored hidden columns. --- # AdaptTable versioning & stability policy AdaptTable follows [Semantic Versioning](https://semver.org/). This page states what that means in practice, what the committed-stable API surface is, and how deprecations are handled — so you can upgrade with confidence. ## Versioning policy Given `MAJOR.MINOR.PATCH`: - **PATCH** — bug fixes and internal improvements that don't change the public API. Always safe to adopt. - **MINOR** — new features and backwards-compatible changes. Code written against the current minor keeps working on the next. - **MAJOR** — breaking changes to the public API. We avoid these; when one is unavoidable, it ships in a major with a migration note in the CHANGELOG. The published packages (`@adapttable/core`, `@adapttable/react`, the adapters, `@adapttable/i18n`, `@adapttable/server`, `@adapttable/ai`, `@adapttable/ai-react`, and `@adapttable/cli`) each follow [changesets](https://github.com/changesets/changesets) **independently**: a package only bumps when a changeset names it. Adapters, `@adapttable/i18n` and `@adapttable/server` depend on a concrete `@adapttable/core` version at publish time (exact pin), so you do not need matching version numbers across kits — install the adapter you use and let npm pull the core it was published against. `@adapttable/cli` versions on its own cadence; its programmatic API is still part of the public surface below. ## Stability AdaptTable is **stable at `3.0`**. The full SemVer contract above applies: breaking changes to the public API surface (below) ship only in a major release, with a migration note in the relevant package's `CHANGELOG.md`. In practice such changes are rare — most releases are additive minors and safe patches. ## Supported UI-kit versions Each adapter declares a wide peer range for its kit, and a weekly, non-blocking peer-matrix workflow typechecks each adapter against the **oldest and newest** supported major — so a claimed-but-broken version is caught before you hit it: | Adapter | Kit peer range | | --------------------------------------------- | -------------------------------------- | | `@adapttable/mantine` | `@mantine/core` + `@mantine/hooks` 7–9 | | `@adapttable/mui` | `@mui/material` 6.1.2+ – 9 | | `@adapttable/chakra` | `@chakra-ui/react` 3 | | `@adapttable/antd` | `antd` 6 | | `@adapttable/radix` | `@radix-ui/themes` 3 | | `@adapttable/base-ui` | `@base-ui/react` ^1.6 | | `@adapttable/unstyled` / `@adapttable/shadcn` | no UI-kit dependency | `react` / `react-dom` 18 and 19 are supported across every package. ## Public API surface **What is exported and documented is supported.** Removal or narrowing of that surface happens in a major, with a migration note. A symbol tagged `@internal` is not the contract even when it appears in the published `.d.ts`. The complete name list lives on the [API reference](./api.md); this page names every **supported entrypoint** so a derived allowlist cannot omit one. ### `@adapttable/core` The framework-neutral engine: `createTableEngine` and the view it publishes; `TableSource`; the neutral column and filter models (`ColumnInput`, `ColumnFilter`, `ColumnLayoutState`, …); the data operations — filtering, sorting, paging, grouping, aggregation; URL-state codecs (`parseTableUrlState` / `applyTableUrlState` / `UrlStateAdapter` / `routerUrlAdapter`); the labels contract. No React in its import graph. The [API reference](./api.md) lists every export on this entry. ### `@adapttable/react` The React binding: the `useFrontendData` / `useQuerySource` / `useServerData` source builders; `useDataTable` and its prop-getters; `BaseDataTableProps`; `ColumnDef` and the other React column types, whose render callbacks return elements; the URL-state hooks (`useColumnLayoutUrlState`, `useDensityUrlState`, …); column-layout, selection, sorting, pagination and virtualization hooks. ### `@adapttable/react/features` Canonical home of the feature factories (`rowReorder`, `savedViews`, `grouping`, `editing`, `virtualize`, `columnMenu`, `cellNavigation`, `applyTableFeatures`, …). Kit subpaths re-export this entry; values stay off the core main barrel. ### `@adapttable/react/adapter` The supported **adapter-author** boundary. A ninth adapter is built from this entry — `useDataTableShell`, chrome components, slot contracts, pager and pin math, announcers — with the same SemVer promise as the main entry. App code rarely imports it; reaching for it is choosing that contract, not an undocumented escape. There is no private channel behind it. ### Focused core subpaths Each is a published, supported entry — not an implementation detail: | Entry | What it is | | -------------------------- | -------------------------------------------------------------------------- | | `@adapttable/core/formula` | Formula columns (`buildFormulaColumns`, `FormulaValue`, …) | | `@adapttable/core/pdf` | Print / PDF writers and page layout (`PrintPageSize`, `PrintPageBreak`, …) | | `@adapttable/core/pivot` | Pivot engine (`pivot`, `pivotTableModel`, aggregators) | | `@adapttable/core/query` | The query model without React — codecs a backend can load | | `@adapttable/core/stream` | Live row patches (`RowPatch`, `RowPatchEvent`, …) | | `@adapttable/core/xlsx` | Spreadsheet export writer | `@adapttable/react` publishes the React-facing counterparts of the ones that need elements — `/formula`, `/pivot`, `/stream` — plus `/sparkline`, the sparkline column helper. ### Adapter main entries Published kits: `@adapttable/mantine`, `@adapttable/mui`, `@adapttable/chakra`, `@adapttable/antd`, `@adapttable/radix`, `@adapttable/base-ui`, `@adapttable/unstyled`, `@adapttable/shadcn`. Each main entry exports `` with `DataTableProps` / `DataTablePropsBase` / `DataTableSlots` / `SavedViewsMenuProps`, plus the documented kit extras (Mantine chrome components, unstyled/shadcn building blocks, Radix and Base UI accent unions, unstyled `IconProps`, shadcn's `shadcnClassNames`). Styled kits do **not** expose every internal node — their `classNames` are the documented wrapper hooks; per-node classes and `data-adapttable-part` are the unstyled/shadcn contract. ### Kit feature subpaths Forty paths, the same on every published adapter, re-exporting `@adapttable/react/features` (and the pivot panel on `/pivot`): `/features`, `/preset`, `/assistant`, `/batch-editing`, `/bulk-actions`, `/cell-navigation`, `/cell-span`, `/column-groups`, `/column-menu`, `/column-selection`, `/command-palette`, `/context-menu`, `/density`, `/editing`, `/export`, `/extra-rows`, `/filters`, `/find-in-table`, `/fit-columns`, `/fullscreen`, `/grouping`, `/grouping-panel`, `/header-filters`, `/multi-sort`, `/nested-table`, `/pinned-summary-rows`, `/pivot`, `/print`, `/resizable-columns`, `/row-actions`, `/row-appearance`, `/row-detail`, `/row-pinning`, `/row-reorder`, `/saved-views`, `/selection-stats`, `/side-panel`, `/status-bar`, `/tree`, `/virtualize`. `/preset` carries `standardFeatures` — the composed starting point. Import from the kit you mount (`@adapttable/mantine/row-reorder`, …) so the factory and the table share one package. ### `@adapttable/i18n` Locale presets (`en`, `ar`, …, `zhTW`), `getLabels` / `hasLocale` / `locales` / `LocaleKey`, and direction helpers (`getDirection`, `isRtlLocale`, `primarySubtag`, `RTL_LANGUAGES`). ### `@adapttable/ai` `createAgentSession`, the `adapttable.agent.v1` manifest and the `CAPABILITY_KEYS` catalog. Optional — core and adapter roots do not re-export it. ### `@adapttable/ai-react` `tableAgent` and `useTableAssistant`. Depends on `@adapttable/ai` and `@adapttable/react`. The AI root stays React-free. ### `@adapttable/server` `parseTableQuery` against a `QuerySchema`, returning `ServerTableQuery` and `QueryRejection[]`. `QueryInput` is a `Request`, `URL`, query string or `URLSearchParams`. ### `@adapttable/cli` The `adapttable init` binary, and the programmatic surface: `detectKit`, `runInit`, `choosePackageManager`, `installCommand`, `scaffoldFiles`, plus `KITS` / `Kit` / `KitInfo` / `SHADCN`, `packagesFor` / `mergeDependencies`, `starterComponent` / `ScaffoldFile` / `STARTER_PATH`, `PackageManager`, `InitError`, `InitOptions` / `InitResult` / `InitIO`. `./package.json` and the binary path are not typed entrypoints. ## Customization ladder Four rungs, each more surface than the last. A styled adapter does not pretend to be the last two: 1. **Kit theming and adapter defaults** — the table looks like the rest of the app because it is built from that kit. Theme through the kit provider; no AdaptTable class map required. 2. **Structural slots, `classNames`, and render callbacks** — replace a region (`slots.empty`, `toolbar`, `confirm`) or restyle the hooks the kit documents. Styled adapters expose wrapper hooks (`root`, `toolbar`, `table`, `card`, `footer`), not every cell and icon. 3. **Unstyled / shadcn per-node classes** — `@adapttable/unstyled` (and `@adapttable/shadcn` on top of it) expose a `classNames` key and a stable `data-adapttable-part` on every rendered node. That part map is the contract; see [customization](./customization.md). 4. **Headless markup** — `useDataTable` and the prop-getters, or a custom adapter over `@adapttable/react/adapter`. You own every pixel. Reach for the lowest rung that does the job. Jumping to `/adapter` or headless getters to restyle a button is using the wrong contract. ## Deprecation policy When an API is retired, it is **not** removed immediately: 1. The deprecated API is marked `@deprecated` with a JSDoc note pointing to the replacement. 2. It keeps working for **at least one minor** release (longer when practical). 3. Removal happens in a **major** release. We never silently remove a documented public API. ## Releasing Releases are produced by changesets: open a changeset describing which packages changed, merge it, and the release workflow versions **only those packages** and publishes them to npm with a generated per-package `CHANGELOG.md`. See [CONTRIBUTING.md](../CONTRIBUTING.md) for the contributor flow.