agentleFS
orwa-mahmoudorwa-mahmoud/adapttable/llms-full.txt

adapttable

llms.txt72 starsChanged 6 months agoMITView on GitHub

AdaptTable v3 documentation: a framework-neutral data engine in @adapttable/core, headless React, Angular and Vue bindings, and native table adapters. React kits include Mantine, MUI, Chakra UI, Ant Design, Radix Themes, Base UI, shadcn/ui and unstyled Tailwind. Angular kits include native unstyled, NG-ZORRO, Material, ng-bootstrap, ngx-bootstrap, Spartan, Taiga UI, Aria and CDK. 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…

  • Installs packages
  • Sends data out

The file

Show its rules and text

We split this llms-full.txt into the rules it gives an agent

16 rules. About 0 are common, 4 are distinctive, and 12 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈4

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 12

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Prefer zero install first?

    line 36
  2. Very specific:

    Every kit depends on @adapttable/react, so it is always installed — but a package manager with a strict layout (pnpm by default) lets you …

    line 74
  3. Very specific:

    Keep it on the version your kit pins, which pnpm why @adapttable/react shows.

    line 84
  4. Distinctive:

    Prefer to try before installing?

    line 262
  5. Very specific:

    Every opt-in is an import and one entry in features.

    line 603
  6. Very specific:

    Every public adapter exports the same subpaths.

    line 640
  7. Very specific:

    Every seam is a method on TableFeatureHost.

    line 823
  8. Very specific:

    Every server tier receives one consolidated TableQuery:

    line 1086
  9. Distinctive:

    If the table wants something the source has not declared, the field is

    line 1118
  10. Very specific:

    Frontend: search, the declarative-filter predicate, sorting, and page slicing all run in memory.

    line 1216
  11. Very specific:

    Full control: every source builder returns the same TableSource contract, so the table can't tell in-memory from server data — switch tiers …

    line 1228
  12. Distinctive:

    Setting a value it already holds is not a change: No request fires.

    line 1340
  13. Very specific:

    A superseded request aborts: When a newer query replaces an in-flight one, the previous call's signal fires.

    line 1341
  14. Very specific:

    Every source builder returns a TableSource, and useDataTable cannot tell them apart.

    line 1552
  15. Very specific:

    Search, sort and paging run in memory.

    line 1564
  16. Very specific:

    When the goal is a complete table for a UI kit that AdaptTable does not ship, with the feature factories, slots and every built-in …

    line 2048

≈ “Common” is an estimate: at least 1 in 5 of the 3,592 llms.txt files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
llms-full.txt
# AdaptTable — full documentation

> AdaptTable v3 documentation: a framework-neutral data engine in @adapttable/core, headless React, Angular and Vue bindings, and native table adapters. React kits include Mantine, MUI, Chakra UI, Ant Design, Radix Themes, Base UI, shadcn/ui and unstyled Tailwind. Angular kits include native unstyled, NG-ZORRO, Material, ng-bootstrap, ngx-bootstrap, Spartan, Taiga UI, Aria and CDK. 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 your framework's getting-started and the shared concepts for package ownership, then features for opt-in composition. The Angular binding, all nine native kits and @adapttable/ai-angular are available on npm. The Angular 0.5 binding changes canonical import paths; use its migration guide and compatible kit releases when upgrading. Vue has a published public binding and eight kits — native Unstyled, Element Plus, Naive UI, Nuxt UI, Quasar, Reka UI, shadcn-vue and Vuetify — each covering the full feature set with its own controls, plus optional agents, assistant UI and speech through @adapttable/ai-vue. The Vue packages are published on npm. Use the v2-to-v3 migration guide when upgrading React; historical migration examples describe their named versions, not current import paths. This file is generated from the canonical guides; the linked index is at https://adapttable.orwamahmoud.com/llms.txt.


---

# Get started with AdaptTable — React table for your UI kit

▶ **Nothing to install yet — [open the live demo](https://adapttable.orwamahmoud.com/react/demo/) and use it.** Flip
between [Mantine](https://adapttable.orwamahmoud.com/react/demo/?kit=mantine) · [MUI](https://adapttable.orwamahmoud.com/react/demo/?kit=mui) ·
[Chakra](https://adapttable.orwamahmoud.com/react/demo/?kit=chakra) · [Ant Design](https://adapttable.orwamahmoud.com/react/demo/?kit=antd) ·
[Radix](https://adapttable.orwamahmoud.com/react/demo/?kit=radix) · [Base UI](https://adapttable.orwamahmoud.com/react/demo/?kit=base-ui) ·
[shadcn](https://adapttable.orwamahmoud.com/react/demo/?kit=shadcn) · [Tailwind](https://adapttable.orwamahmoud.com/react/demo/?kit=tailwind) on the same data,
and toggle grouping and inline editing while you are there.

<video src="https://adapttable.orwamahmoud.com/media/core/tour.mp4" poster="https://adapttable.orwamahmoud.com/media/core/poster.png?v=2" controls playsinline preload="none" width="1500" height="1065" style="width:100%;height:auto;aspect-ratio:1500/1065;border-radius:8px"></video>

AdaptTable is a headless, UI-agnostic React data table. Pick the adapter for
your design system and you get a styled, sortable, searchable, paginated
table with URL-synced state, RTL, and dark mode. Filters, selection with bulk
actions, and every other feature are opt-in imports composed in
`features={[...]}`.

## 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 71–78 kB min+gzip (measured 2026-11-09 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
```

Every kit depends on `@adapttable/react`, so it is always installed — but a
package manager with a strict layout (pnpm by default) lets you import only
what your own `package.json` lists. Importing a hook such as
`useDensityUrlState` or `useHighlight` from `@adapttable/react` then needs it
added too:

```bash
pnpm add @adapttable/react
```

Keep it on the version your kit pins, which `pnpm why @adapttable/react`
shows.

## 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.1.2 \|\| ^7 \|\| ^8 \|\| ^9`                                      |
| Chakra UI         | `^3.13.0`                                                             |
| Ant Design        | `^6`                                                                  |
| Radix Themes      | `^3`                                                                  |
| Base UI           | `^1.6.0`                                                              |
| 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";

<MantineProvider>
  <App />
</MantineProvider>;
```

**Material UI** — works with the default theme out of the box; wrap in
`ThemeProvider` to customize:

```tsx
import { createTheme, ThemeProvider } from "@mui/material";

<ThemeProvider theme={createTheme()}>
  <App />
</ThemeProvider>;
```

**Chakra UI** (v3) — the provider takes a system; use the built-in
`defaultSystem` or your own:

```tsx
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";

<ChakraProvider value={defaultSystem}>
  <App />
</ChakraProvider>;
```

**Ant Design** — works without a provider; add `ConfigProvider` for theme or
locale:

```tsx
import { ConfigProvider } from "antd";

<ConfigProvider>
  <App />
</ConfigProvider>;
```

**Radix Themes** — import the Themes stylesheet and wrap in `<Theme>` (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 (
    <DataTable
      data={PEOPLE}
      columns={[
        { key: "name", sortable: true },
        { key: "role" },
        { key: "status", filter: { type: "select", options: "auto" } },
        { key: "hiredAt", filter: "dateRange" },
      ]}
      rowKey={(r) => 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
<DataTable
  data={PEOPLE}
  columns={columns}
  rowKey={(r) => 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. Every
starter composes the same features from its own kit: column filters with chips,
a drag-to-group panel with subtotals, the column menu, resizing, multi-column
sort, cell navigation, inline editing with undo and redo, and CSV export. 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
  keyboard, dataset-relative indices.
- [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
- [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://adapttable.orwamahmoud.com/react/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://adapttable.orwamahmoud.com/react/demo/) — one dataset, one feature set, re-rendered by every real adapter.

The examples below use React. The same neutral engine powers the
[Angular signal binding](angular/getting-started.md) and
[Vue reactive binding](vue/getting-started.md), each with its own native UI kits.

## The `TableSource` contract

Everything in AdaptTable revolves around one idea: a **`TableSource<T>`** —
a uniform contract that a table consumes regardless of where its rows came
from. Every built-in source builder (`useFrontendData`, `useServerData`,
`useQuerySource`) fulfils it, and the table renders without knowing which
produced it.

```ts
interface TableSource<TRow> {
  // data
  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
  error: Error | null;
  paginationMode: "infinite" | "paged";
  // state
  page: number;
  limit: number;
  defaultLimit: number; // page size when the URL names none
  search: string;
  sortBy: string | undefined;
  sortDir: "asc" | "desc" | undefined;
  sortLevels: readonly SortLevel[]; // multi-sort chain, empty when unused
  extra: ExtraFilters; // Record<string, string | string[] | number | undefined>
  groupBy: string | undefined; // comma-separated grouping keys
  // setters
  setPage: (next: number) => void;
  setLimit: (next: number) => void;
  setSort: (key: string | undefined, dir?: "asc" | "desc") => void;
  toggleSortLevel: (key: string) => void;
  setSearch: (next: string) => void;
  setExtra: (key: string, value: FilterValue) => void;
  setExtras: (updates: ExtraFilters) => void;
  setGroupBy: (key: string | undefined) => void;
  clearExtras: () => void;
  clearAll: () => void;
}
```

Optional members a source adds when it can: `refetch`, `allFilteredRows`,
`allSearchedRows`, `facets`, `filterTree` / `setFilterTree`, `capabilities`,
`groups`, `tableEngine`, `initializeGroupBy`, `setGroupAggregateOverrides`,
and the aggregate fields (`groupAggregateOverrides`, `groupAggregations`,
`queryAggregates`, `aggregateOperations`, `honorsAggregates`). The
[API reference](./api.md) has each one's type.

Because the table is agnostic to the source's origin, you can switch between
in-memory and server data — or [build a custom source](./custom-table-source.md) — 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<TRow> {
  key: string; // unique; also the backend sortBy value
  header?: ReactNode; // pre-translated; derived from `key` when omitted
  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**, by the same rule as the card layout: the 768px media
query, or your `mobileBreakpoint`, and `forceMobile` over both.
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/react` — attach
the returned ref to a sentinel element after your last row to get the same
behaviour in custom markup (`rootMargin` sets how early it fires, default
`"200px"`):

```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…
<div ref={sentinelRef} />;
```

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 the `virtualize()`
feature. `@adapttable/react` exports the underlying `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
feature 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
import { DataTable } from "@adapttable/mantine";
import { virtualize } from "@adapttable/mantine/virtualize";

interface Person {
  id: string;
  name: string;
}

export function People({ people }: { people: Person[] }) {
  return (
    <DataTable
      data={people}
      columns={[{ key: "name", sortable: true }]}
      rowKey={(row) => row.id}
      paginationMode="infinite"
      features={[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
`mobileBreakpoint` (default 768px); `forceMobile` pins the card layout.
A card shows every column without `hideOnMobile`, so `hideOnMobile` on the
low-value columns is what shapes it; `hideOnDesktop` adds a field to cards
only. `renderCard` replaces a card's body while its shell — selection,
toggles, row actions — stays.

```tsx
const columns = [
  { key: "name" },
  { key: "team" },
  { key: "email", hideOnMobile: true },
];

<DataTable data={people} columns={columns} rowKey={(row) => row.id} />;
```

## The framework-neutral engine

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. `@adapttable/react` connects it to React hooks,
`@adapttable/angular` to Angular signals, and `@adapttable/vue` to Vue refs
and component lifecycles.

```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, `lastPage`, `selectedIds`, `capabilities`, 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.                                                                            |
| `stageCandidate` / `commitCandidate` / `discardCandidate` / `candidate` | Stage configuration and data privately, then publish or drop it. `candidate` reads the staged state; everything else reads the committed one.  |
| `tableId` / `dispose()`                                                 | The engine's identity, and releasing it.                                                                                                       |

`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.

## Features are imports

A `<DataTable>` with `columns`, `rowKey` and a data tier searches, sorts and
pages. Everything else — filters, grouping, editing, virtualization, bulk
actions, the column menu — is a factory imported from a kit subpath and
passed in `features`:

```tsx
import { DataTable } from "@adapttable/mantine";
import { filters } from "@adapttable/mantine/filters";
import { grouping } from "@adapttable/mantine/grouping";

interface Person {
  id: string;
  name: string;
  team: string;
}

export function People({ people }: { people: Person[] }) {
  return (
    <DataTable
      data={people}
      columns={[
        { key: "name", sortable: true },
        { key: "team", filter: { type: "select", options: "auto" } },
      ]}
      rowKey={(row) => row.id}
      features={[filters([]), grouping("team")]}
    />
  );
}
```

The import is the switch: a table downloads a feature only when it names
one. Host plugins are the same `TableFeature` type in the same array.
[Feature composition](./features.md) lists every factory, the standard
preset, and the plugin hooks.

## The two ways to use it

1. **Batteries-included** — `import { DataTable } from "@adapttable/<kit>"`.
2. **Headless** — `import { useDataTable } from "@adapttable/react"` and render
   your own markup with the returned prop-getters — see
   [headless rendering](./headless.md).

---

# React table features — optional imports, presets and plugins

▶ **See it working:** [the Feature Lab](https://adapttable.orwamahmoud.com/react/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 { applyRowReorder } from "@adapttable/react";
import { DataTable } from "@adapttable/mantine";
import { rowReorder } from "@adapttable/mantine/row-reorder";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => 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.

`<DataTable>` has no enabling props: `enableColumnMenu`, `bulkActions`,
`contextMenu`, `commandPalette`, `statusBar`, `sidePanel`, `findInTable` and
the rest are not `DataTable` props. The factory from the matching subpath turns
a feature on — see [migrating from v2](./migrate-from-v2.md).

## Kit subpaths

Every public adapter exports the same subpaths. **Kit** means the subpath binds
the factory to that kit's own components; **headless** means it re-exports the
`@adapttable/react/features` factory unchanged, because the feature draws no
control of its own.

| Import                                  | Exports                                                                                           | Draws    |
| --------------------------------------- | ------------------------------------------------------------------------------------------------- | -------- |
| `@adapttable/<kit>/row-reorder`         | `rowReorder`                                                                                      | kit      |
| `@adapttable/<kit>/row-pinning`         | `rowPinning`                                                                                      | headless |
| `@adapttable/<kit>/pinned-summary-rows` | `pinnedSummaryRows`                                                                               | headless |
| `@adapttable/<kit>/cell-span`           | `cellSpan`                                                                                        | headless |
| `@adapttable/<kit>/extra-rows`          | `extraRows`                                                                                       | headless |
| `@adapttable/<kit>/row-appearance`      | `rowAppearance`                                                                                   | headless |
| `@adapttable/<kit>/row-detail`          | `rowDetail`, `nestedTable`                                                                        | kit      |
| `@adapttable/<kit>/nested-table`        | `nestedTable`                                                                                     | kit      |
| `@adapttable/<kit>/row-actions`         | `rowActions`                                                                                      | headless |
| `@adapttable/<kit>/editing`             | `editing`, `rowEditing`, `batchEditing`, `editHistory`, `dirtyIndicators`, `undoRedoButtons`      | kit      |
| `@adapttable/<kit>/batch-editing`       | `batchEditing`                                                                                    | kit      |
| `@adapttable/<kit>/grouping`            | `grouping` — code-fixed grouping without the interactive panel                                    | kit      |
| `@adapttable/<kit>/grouping-panel`      | `groupingPanel`, `GroupingPanel` — grouping headers + interactive panel                           | kit      |
| `@adapttable/<kit>/tree`                | `tree`                                                                                            | kit      |
| `@adapttable/<kit>/virtualize`          | `virtualize`                                                                                      | headless |
| `@adapttable/<kit>/column-menu`         | `columnMenu`                                                                                      | kit      |
| `@adapttable/<kit>/resizable-columns`   | `resizableColumns`                                                                                | headless |
| `@adapttable/<kit>/fit-columns`         | `fitColumns`                                                                                      | headless |
| `@adapttable/<kit>/column-groups`       | `collapsibleColumnGroups`                                                                         | kit      |
| `@adapttable/<kit>/column-selection`    | `columnSelectionCheckbox`                                                                         | kit      |
| `@adapttable/<kit>/multi-sort`          | `multiSort`                                                                                       | headless |
| `@adapttable/<kit>/filters`             | `filters` (kit), `filterTypes` (headless)                                                         | kit      |
| `@adapttable/<kit>/header-filters`      | `headerFilters`                                                                                   | kit      |
| `@adapttable/<kit>/saved-views`         | `savedViews`                                                                                      | kit      |
| `@adapttable/<kit>/export`              | `exportCsv`                                                                                       | kit      |
| `@adapttable/<kit>/cell-navigation`     | `cellNavigation`                                                                                  | kit      |
| `@adapttable/<kit>/selection-stats`     | `selectionStats`                                                                                  | kit      |
| `@adapttable/<kit>/status-bar`          | `statusBar`, `selectionStats`                                                                     | kit      |
| `@adapttable/<kit>/find-in-table`       | `findInTable`                                                                                     | kit      |
| `@adapttable/<kit>/fullscreen`          | `fullscreen`                                                                                      | kit      |
| `@adapttable/<kit>/density`             | `densityChooser`                                                                                  | kit      |
| `@adapttable/<kit>/print`               | `print`                                                                                           | kit      |
| `@adapttable/<kit>/command-palette`     | `commandPalette`                                                                                  | kit      |
| `@adapttable/<kit>/context-menu`        | `contextMenu`                                                                                     | kit      |
| `@adapttable/<kit>/side-panel`          | `sidePanel`                                                                                       | kit      |
| `@adapttable/<kit>/bulk-actions`        | `bulkActions`                                                                                     | kit      |
| `@adapttable/<kit>/assistant`           | `tableAssistant`, `TableAssistant`                                                                | kit      |
| `@adapttable/<kit>/pivot`               | `PivotPanel` and the pivot engine (`pivot`, `pivotTableModel`, …)                                 | kit      |
| `@adapttable/<kit>/preset`              | `standardFeatures`, `StandardFeatureOptions`                                                      | kit      |
| `@adapttable/<kit>/features`            | the headless `@adapttable/react/features` surface: `applyTableFeatures`, `feature`, every factory | headless |

`<kit>` is `mantine`, `mui`, `chakra`, `antd`, `radix`, `base-ui`, `shadcn`,
or `unstyled`. `@adapttable/<kit>/features` forwards `@adapttable/react/features`
as it is, so a factory that draws kit UI — `columnMenu`, `filters`,
`headerFilters`, `collapsibleColumnGroups`, `rowReorder`, `groupingPanel`,
`exportCsv`, … — renders no controls when imported from there. Import each one
from its own `@adapttable/<kit>/<subpath>`.

The pivot engine stays a calculation — `import { pivot } from "@adapttable/core/pivot"`
— not a `<DataTable>` 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";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(r) => 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
(Ctrl/Cmd+F after a click in the table), fit-columns, the fullscreen toggle, [header filters](./header-filters.md), multi-sort, resizable
columns and the status bar. Because `headerFilters()` is a member, filters
from the preset's `filters` option open from the column headers (`filtersMode`
resolves to `"header"`); compose the members individually for popover or drawer
filters.

Find draws no toolbar control by default. `standardFeatures({ findButton: true })`
adds the Find control after Export:

```tsx
features={standardFeatures({ findButton: true })}
```

Configurable preset members join only when you give them input — `grouping`,
`bulkActions`, `filters` and `savedViews`. 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: { storageKey: "people-table-views" },
  }),
  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 71 kB gzipped and the same table with
`standardFeatures()` composed is 133 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";

<DataTable features={[groupingPanel("team"), virtualize()]} />;
```

A factory whose configuration says nothing about rows returns a
`StaticTableFeature` — the same feature whatever the table holds — and composes
into any `<DataTable>` with no type argument.

A factory that takes a row-typed callback returns `TableFeature<TRow>` 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: "numberRange",
      ops: ["eq", "gt", "lt"],
      defaultOp: "eq",
      stateKeys: (def) => [def.key],
      match: () => true,
      chips: () => ({}),
      conditionToExtra: () => ({}),
    });
    return () => {
      /* table unmounted, or `features` changed */
    };
  },
};

<DataTable features={[currencyFilter, rowReorder(onReorder)]} … />;
```

The full filter-type contract is on [custom filter types](./custom-filter-types.md).

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,
  useFeatureState,
} from "@adapttable/react/adapter";
import { useEffect, useState } from "react";

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 (
        <FeatureStateScope stateKey={AUDIT} value={{ count }}>
          {children}
        </FeatureStateScope>
      );
    },
  },
});
```

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 <span>{audit.count} changes</span>;
```

Both halves are typed: `featureStateKey<T>` 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 }) => <MyStatusBar total={total} />),
  ],
});
```

The table computes the props and asks; it never learns what was drawn:

```tsx
<FeatureSlot slot={STATUS_BAR} props={{ total }} />
```

`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` · `rowActions` · `feature` (ad-hoc patch) ·
`applyTableFeatures` (the merge used by every adapter) · `useTableFeatures`
(apply + `setup(host)`, the hook every adapter runs).

Host helpers are exported from `@adapttable/react/adapter`: `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 `<DataTable>`, 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 (
    <DataTable
      data={PEOPLE}
      columns={[{ key: "name", sortable: true }, { key: "role" }]}
      rowKey={(r) => 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<Person[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(false);

  return (
    <DataTable
      data={rows}
      total={total}
      loading={loading}
      onQueryChange={async (query, { signal }) => {
        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
import { useServerData } from "@adapttable/react";

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                                                                                                                                                                                       |
| `expandedIds` | `tree`       | Open tree node ids, so the response can carry the children of every open branch                                                                                                                                                |

`cursor` and `expandedIds` need a source built with `useServerData` or
`useQuerySource` and passed as `source`: they take the `nextCursor` and
`expandedIds` options, which `<DataTable onQueryChange>` has no prop for.
`supports`, `facetKeys` and `facets` are props on every kit's `<DataTable>`
as well as options of those two hooks.

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](./custom-table-source.md).

```tsx
import { keepPreviousData, useInfiniteQuery } from "@tanstack/react-query";
import type { PaginatedResponse, TableQueryParams } from "@adapttable/core";
// or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd",
// "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere.
import { DataTable, useQuerySource } from "@adapttable/mantine";

interface Person {
  id: string;
  name: string;
  role: string;
}

async function fetchPeople(
  params: Partial<TableQueryParams>
): Promise<PaginatedResponse<Person>> {
  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<Person>;
}

// Your query hook: fetch one page for the current params.
function usePeopleQuery(params: Partial<TableQueryParams>) {
  return useInfiniteQuery({
    queryKey: ["people", params],
    queryFn: ({ pageParam }) => fetchPeople({ ...params, page: pageParam }),
    initialPageParam: params.page ?? 1,
    getNextPageParam: (last) => (last.hasNextPage ? last.page + 1 : undefined),
    placeholderData: keepPreviousData,
  });
}

export function PeopleTable() {
  const source = useQuerySource<Person>({ usePaginatedQuery: usePeopleQuery });
  return (
    <DataTable
      source={source}
      columns={[{ key: "name", sortable: true }, { key: "role" }]}
      rowKey={(r) => 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 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<Person> = {
  ...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                            |
| `aggregateOperations` | `readonly string[]` (optional)  | The aggregate ids the backend computes                                                               | The five standard functions are assumed                       |

**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 { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { tableQueryKey, tableQueryBaseKey } from "@adapttable/core";

// `query` is the `TableQuery` the table emitted; `fetchPeople` returns a
// `PaginatedResponse` for it.
const queryClient = useQueryClient();
const infinite = useInfiniteQuery({
  queryKey: tableQueryKey(query, { scope: "people" }),
  queryFn: ({ signal }) => fetchPeople(query, signal),
  initialPageParam: 1,
  getNextPageParam: (last) => (last.hasNextPage ? last.page + 1 : undefined),
});

// 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:

- **One request per query.** Queries are compared by value, so setting the same
  search term three times in a tick or an identical re-render collapses into a
  single call. Under StrictMode's development double-mount the handler runs
  twice and the first call's `signal` is already aborted, so forwarding
  `signal` to `fetch` leaves one live request.
- **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<void>` | —        | 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`; sent only when the source declares `supports.aggregates` (a `useServerData` / `useQuerySource` option). Reader overrides overlay this; Restore defaults returns here, not to the last response. |
| `error`         | `Error \| null`                                                                            | `null`   | Forwarded error to display.                                                                                                                                                                                                                    |
| `source`        | `TableSource<TRow>`                                                                        | —        | Full control: a prebuilt source from `useFrontendData` / `useQuerySource` / your own.                                                                                                                                                          |
| `mode`          | `"frontend" \| "server"`                                                                   | inferred | Pins the tier; `"server"` requires `onQueryChange`, `"frontend"` turns it into a notification (see [Explicit `mode`](#explicit-mode--when-inference-isnt-what-you-meant)).                                                                     |

## 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://adapttable.orwamahmoud.com/react/demo/).

---

# React table headless rendering — useDataTable

`useDataTable` from `@adapttable/react` is the table without markup. Give it
a `TableSource` and your columns; it returns the rows to draw, the sort,
search, pagination and selection state, and prop-getters that put the right
roles, ARIA attributes and handlers on elements you render yourself. Search,
sorting, paging and URL state work exactly as they do under a kit's
`<DataTable>`.

```tsx
import {
  type ColumnDef,
  useDataTable,
  useFrontendData,
} from "@adapttable/react";

interface Person {
  id: string;
  name: string;
  team: string;
  salary: number;
}

const columns: ColumnDef<Person>[] = [
  { key: "name", header: "Name", sortable: true },
  { key: "team", header: "Team", sortable: true },
  {
    key: "salary",
    header: "Salary",
    align: "end",
    sortable: true,
    accessor: (row) => row.salary.toLocaleString(),
    sortValue: (row) => row.salary,
  },
];

export function PeopleTable({ people }: { people: Person[] }) {
  const source = useFrontendData({
    data: people,
    columns,
    paginationMode: "paged",
  });
  const table = useDataTable({ source, columns, rowKey: (row) => row.id });

  return (
    <div>
      <input {...table.getSearchInputProps()} />

      <table {...table.getTableProps()}>
        <thead>
          <tr {...table.getHeaderRowProps()}>
            {table.columns.map((column) => (
              <th key={column.key} {...table.getHeaderCellProps(column)}>
                {column.sortable ? (
                  <button {...table.getSortButtonProps(column)}>
                    {column.header}
                    {table.sortBy === column.key &&
                      (table.sortDir === "asc" ? " ▲" : " ▼")}
                  </button>
                ) : (
                  column.header
                )}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {table.rows.map((row, index) => (
            <tr key={table.getRowKey(row)} {...table.getRowProps(row, index)}>
              {table.columns.map((column) => (
                <td key={column.key} {...table.getCellProps(column)}>
                  {table.getCellContent(column, row, index)}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {table.isEmpty && <p>{table.labels.noData}</p>}

      <nav aria-label="Pagination">
        <button
          type="button"
          disabled={source.page <= 1}
          onClick={() => source.setPage(source.page - 1)}
        >
          Previous
        </button>
        <span>
          {table.pagination.fromIndex}–{table.pagination.toIndex} of{" "}
          {source.total}
        </span>
        <button
          type="button"
          disabled={source.page >= table.pagination.totalPages}
          onClick={() => source.setPage(source.page + 1)}
        >
          Next
        </button>
      </nav>
    </div>
  );
}
```

That is a complete table: typing in the box searches after a 300 ms
debounce, a header button cycles its column ascending → descending →
cleared, and the page, search and sort live in the URL.

## How it works

Two hooks, two jobs:

1. **A source** owns the data and the query state — rows, total, page,
   limit, search, sort, filters, grouping — and the setters that change
   them. It is a `TableSource`, the same contract every kit consumes; see
   [Concepts](./concepts.md#the-tablesource-contract).
2. **`useDataTable`** reads the source and the columns and derives what
   rendering needs: the visible columns for the current layout, the
   debounced search value, pagination figures, selection, filter chips and
   the prop-getters.

`useDataTable` never renders and never fetches. Everything it returns is a
function of the source and the options, so the same hook drives a `<table>`,
a card list, a virtualized grid, or a design system's own components.

Columns are the React `ColumnDef` a kit takes. A column without a `header`
gets one humanized from its `key`, and a column without an `accessor` or
`Cell` reads the row by its key, exactly as under `<DataTable>`.

## Building a source

Every source builder returns a `TableSource`, and `useDataTable` cannot tell
them apart. Pick by where the rows live — the tiers themselves are covered in
[Data tiers](./data-tiers.md).

### In memory — `useFrontendData`

```tsx
import { useFrontendData } from "@adapttable/react";

const source = useFrontendData({ data: people, columns });
```

Search, sort and paging run in memory. Pass `columns` so sorting can read
each column's `sortValue`; `getSearchText`, `getSortValue` and `filterFn`
override the defaults, and `getRowId` defaults to `String(row.id)`.
`paginationMode` defaults to `"auto"` — infinite on viewports at or below
768 px, paged above — so set `"paged"` or `"infinite"` when your markup
renders only one of them.

### Your own fetch — `useServerData`

```tsx
import { useServerData } from "@adapttable/react";
import { useState } from "react";

interface Person {
  id: string;
  name: string;
}

export function usePeopleSource() {
  const [rows, setRows] = useState<Person[]>([]);
  const [total, setTotal] = useState(0);
  const [loading, setLoading] = useState(false);

  return useServerData({
    rows,
    total,
    loading,
    paginationMode: "paged",
    onQueryChange: async (query, { signal }) => {
      setLoading(true);
      try {
        const params = new URLSearchParams({
          page: String(query.page),
          limit: String(query.limit),
          search: query.search,
        });
        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);
      }
    },
  });
}
```

`onQueryChange` fires with one consolidated `TableQuery` whenever the query
changes, including once on mount with the URL-restored values, and aborts
the previous call's `signal` when a newer query supersedes it.
`supports` opts the query into the optional fields listed in
[What the query carries](./data-tiers.md#what-the-query-carries-and-how-it-grows);
`facetKeys`, `aggregates`, `nextCursor` and `expandedIds` supply their values.

### A query library — `useQuerySource`

`useQuerySource({ usePaginatedQuery })` wraps your `useInfiniteQuery`-style
query hook into the same contract: it flattens pages in infinite mode, shows the
latest page in paged mode, and clamps out-of-range pages. The full TanStack
Query example is in [Data tiers — Full control](./data-tiers.md#3-full-control--source).

## `useDataTable` options

| Option                  | Type                                | Default        | Description                                                                             |
| ----------------------- | ----------------------------------- | -------------- | --------------------------------------------------------------------------------------- |
| `source`                | `TableSource<TRow>`                 | required       | The data and query state.                                                               |
| `columns`               | `ColumnDef<TRow>[]`                 | required       | Column definitions.                                                                     |
| `rowKey`                | `(row: TRow) => string`             | required       | Stable row identity; the React key and, by default, the selection id.                   |
| `tableLabel`            | `string`                            | `labels.table` | The table's accessible name.                                                            |
| `labels`                | `TableLabels`                       | English        | Label overrides, merged over the defaults.                                              |
| `locale`                | `string`                            | —              | Resolves per-column `i18n` data paths for bare-key columns.                             |
| `dir`                   | `"ltr" \| "rtl"`                    | `"ltr"`        | Written to the table element by `getTableProps`.                                        |
| `forceMobile`           | `boolean`                           | `false`        | Use the mobile column set; reported back as `isMobile`.                                 |
| `mobileIdentityColumns` | `number`                            | —              | Deprecated and ignored; removed in v4. `hideOnMobile` decides the mobile column set.    |
| `searchDebounceMs`      | `number`                            | `300`          | Delay between typing and `source.setSearch`.                                            |
| `multiSort`             | `boolean`                           | `false`        | Shift-press on a sort button adds or cycles a sort level instead of replacing the sort. |
| `bulkActions`           | `BulkAction[]`                      | —              | Any action turns selection on.                                                          |
| `selectionGetId`        | `(row: TRow) => string`             | `rowKey`       | Selection id when it differs from the row key.                                          |
| `selectedIds`           | `readonly string[]`                 | —              | Controlled selection.                                                                   |
| `onSelectedIdsChange`   | `(ids: string[]) => void`           | —              | Change handler for the controlled selection.                                            |
| `filterLabels`          | `Record<string, ChipLabelResolver>` | —              | Chip label per filter key; drives `filterChips`.                                        |
| `fitColumns`            | `boolean`                           | `false`        | Share the container width: `flex` columns get a percentage width.                       |
| `columnWidths`          | `Record<string, number>`            | —              | User widths (for example from a column layout); they win over `width` and `flex`.       |

## The result

| Field                                   | What it is                                                                                      |
| --------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `rows`                                  | The rows for the current slice — the page, or everything loaded in infinite mode.               |
| `columns`                               | The columns to render for the current layout, with headers and accessors resolved.              |
| `isEmpty`                               | `true` when there are no rows and the first load is not in progress.                            |
| `isMobile`                              | The `forceMobile` value.                                                                        |
| `labels`                                | Every label, defaults merged with your overrides.                                               |
| `dir`                                   | The resolved direction.                                                                         |
| `pagination`                            | `{ totalPages, safePage, fromIndex, toIndex }`; the indices are 1-based and `0` when empty.     |
| `sortBy` / `sortDir`                    | The primary sort.                                                                               |
| `toggleSort(key)`                       | Advance a column through ascending → descending → cleared.                                      |
| `searchValue` / `setSearchValue`        | The input's immediate value and its setter; the source receives it after the debounce.          |
| `sortByOptions`                         | `{ value, label }` for every sortable column with a text label — the options for a sort select. |
| `selection`                             | `SelectionState` when `bulkActions` is non-empty, else `null`.                                  |
| `filterChips`                           | Removable `{ key, label, onRemove }` chips for active filters that have a `filterLabels` entry. |
| `activeFilterCount`                     | `filterChips.length`.                                                                           |
| `source`                                | The source you passed, for `setPage`, `setLimit`, `fetchNextPage` and the rest.                 |
| `getRowKey(row)`                        | The row's React key — `rowKey`, kept out of `getRowProps`.                                      |
| `getCellContent(column, row, rowIndex)` | The column's `Cell` component when set, else its `accessor` value, else `null`.                 |

Selection is keyed by id, so it survives page, sort and page-size changes; it
resets when the search, the filters or the grouping change the result set.

## Prop-getters

Each getter returns a plain object to spread on an element. Pass your own
props as the last argument and they are merged: `className` values are
joined, `style` objects are merged, event handlers (`on` followed by a
capital letter) both run — the getter's first — and any other key you pass
replaces the getter's value.

```tsx
<tr
  key={table.getRowKey(row)}
  {...table.getRowProps(row, index, {
    className: "people-row",
    onClick: () => openPerson(row.id),
  })}
/>
```

| Getter                               | Returns                                                                                                                                                                                   |
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `getTableProps(props?)`              | `role: "table"`, `dir`, `aria-label` (`tableLabel`, else `labels.table`).                                                                                                                 |
| `getHeaderRowProps(props?)`          | `role: "row"`.                                                                                                                                                                            |
| `getHeaderCellProps(column, props?)` | `role: "columnheader"`, `scope: "col"`, `aria-sort` (`"ascending"`, `"descending"` or `"none"` on sortable columns, absent otherwise), `data-sort-index`, `data-column-key`, and `style`. |
| `getSortButtonProps(column, props?)` | `type: "button"`, `disabled` (`true` when the column is not sortable), `onClick`, `data-sort-index`, and `aria-label` (`"Sort by: <header>"`).                                            |
| `getRowProps(row, index, props?)`    | `role: "row"`, `data-adapttable-part: "row"`, `data-row-id`, `data-index` (the `index` you pass), and `aria-selected` while selection is on. Never a `key`.                               |
| `getCellProps(column, props?)`       | `role: "cell"`, `data-column-key`, and `style`.                                                                                                                                           |
| `getSearchInputProps(props?)`        | `type: "search"`, `role: "searchbox"`, `value`, `placeholder` (`labels.searchPlaceholder`), `aria-label` (`labels.search`), and `onChange`.                                               |

The `style` on header and body cells carries `textAlign` from the column's
`align` (`start`, `center` or `end`, so it follows the writing direction) and,
when the column states any, `width`, `minWidth` and `maxWidth` — the user's
width from `columnWidths` first, then the column's `width`, then its `flex`
share under `fitColumns`.

`data-sort-index` is the column's 1-based place in a multi-column sort and is
present only while it is sorted. The sort button's `onClick` accepts the
click event: with `multiSort: true`, a Shift-press toggles the column's level
in the sort chain instead of replacing the sort.

`data-index` is also the attribute a virtualizer measures by, which is why
`getRowProps` takes the index: pass the row's position in the source rows,
not its position on screen.

## Selection

Pass `bulkActions` and `selection` becomes a `SelectionState`:

```tsx
import {
  type ColumnDef,
  useDataTable,
  useFrontendData,
} from "@adapttable/react";

interface Person {
  id: string;
  name: string;
}

const columns: ColumnDef<Person>[] = [{ key: "name" }];

export function SelectablePeople({
  people,
  onArchive,
}: {
  people: Person[];
  onArchive: (ids: string[]) => void;
}) {
  const source = useFrontendData({ data: people, columns });
  const table = useDataTable({
    source,
    columns,
    rowKey: (row) => row.id,
    bulkActions: [{ key: "archive", label: "Archive", onClick: onArchive }],
  });
  const selection = table.selection;

  return (
    <table {...table.getTableProps()}>
      <thead>
        <tr {...table.getHeaderRowProps()}>
          <th scope="col">
            <input
              type="checkbox"
              aria-label={table.labels.selectAll}
              checked={selection?.headerState === "all"}
              onChange={() => selection?.toggleAll()}
            />
          </th>
          {table.columns.map((column) => (
            <th key={column.key} {...table.getHeaderCellProps(column)}>
              {column.header}
            </th>
          ))}
        </tr>
      </thead>
      <tbody>
        {table.rows.map((row, index) => (
          <tr key={table.getRowKey(row)} {...table.getRowProps(row, index)}>
            <td>
              <input
                type="checkbox"
                aria-label={table.labels.selectRow}
                checked={selection?.isSelected(row.id) ?? false}
                onChange={() => selection?.toggle(row.id)}
              />
            </td>
            {table.columns.map((column) => (
              <td key={column.key} {...table.getCellProps(column)}>
                {table.getCellContent(column, row, index)}
              </td>
            ))}
          </tr>
        ))}
      </tbody>
    </table>
  );
}
```

`selection` also carries `selectedIds`, `selectedCount`, `clear`,
`replace`, `selectAllMatching` and `allMatching` for a "select every match"
banner, and `headerState` (`"all"`, `"some"` or `"none"`) for an
indeterminate header box. `useBulkActionRunner` runs an action through a
confirm handler; see [Selection & bulk actions](./selection.md).

## Mobile cards

`useDataTable` does not watch the viewport. Read it with `useIsMobile()` —
a `(max-width: 768px)` media query by default, `useIsMobile(px)` for another
breakpoint — and pass the answer as `forceMobile`. `columns` then drops the
columns marked `hideOnMobile`, and mobile-only columns (`hideOnDesktop`)
appear.

```tsx
import {
  type ColumnDef,
  useDataTable,
  useFrontendData,
  useIsMobile,
} from "@adapttable/react";

interface Person {
  id: string;
  name: string;
  team: string;
  email: string;
}

const columns: ColumnDef<Person>[] = [
  { key: "name", header: "Name", sortable: true },
  { key: "team", header: "Team", sortable: true },
  { key: "email", header: "Email", hideOnMobile: true },
];

export function People({ people }: { people: Person[] }) {
  const isMobile = useIsMobile();
  const source = useFrontendData({
    data: people,
    columns,
    paginationMode: "paged",
  });
  const table = useDataTable({
    source,
    columns,
    rowKey: (row) => row.id,
    forceMobile: isMobile,
  });

  if (!table.isMobile) {
    // …the <table> from the first example
    return null;
  }

  return (
    <div>
      <label>
        {table.labels.sortBy}{" "}
        <select
          value={table.sortBy ?? ""}
          onChange={(event) =>
            source.setSort(event.currentTarget.value || undefined, "asc")
          }
        >
          <option value="">—</option>
          {table.sortByOptions.map((option) => (
            <option key={option.value} value={option.value}>
              {option.label}
            </option>
          ))}
        </select>
      </label>

      <ul aria-label={table.labels.table}>
        {table.rows.map((row, index) => (
          <li key={table.getRowKey(row)}>
            <dl>
              {table.columns.map((column) => (
                <div key={column.key}>
                  <dt>{column.mobileLabel ?? column.header}</dt>
                  <dd>{table.getCellContent(column, row, index)}</dd>
                </div>
              ))}
            </dl>
          </li>
        ))}
      </ul>
    </div>
  );
}
```

A card has no header to click, so sorting moves to a select built from
`sortByOptions`. `getRowProps` returns `role: "row"`, which belongs inside a
table; a list item takes its key from `getRowKey` instead. Kit adapters make
the same switch at the same 768 px default — see
[Mobile cards](./mobile.md).

## Virtualization

`useTableVirtualization` windows any list of rows: it returns the slice to
mount and the space above and below it. It is exported from
`@adapttable/react` and uses `@tanstack/react-virtual`, a dependency of that
package.

```tsx
import {
  type ColumnDef,
  useDataTable,
  useFrontendData,
  useTableVirtualization,
} from "@adapttable/react";
import { useRef } from "react";

interface Reading {
  id: string;
  sensor: string;
  value: number;
}

const columns: ColumnDef<Reading>[] = [
  { key: "sensor", header: "Sensor", sortable: true },
  {
    key: "value",
    header: "Value",
    align: "end",
    accessor: (row) => row.value,
  },
];

export function Readings({ readings }: { readings: Reading[] }) {
  const scrollRef = useRef<HTMLDivElement>(null);
  const source = useFrontendData({
    data: readings,
    columns,
    paginationMode: "infinite",
  });
  const table = useDataTable({ source, columns, rowKey: (row) => row.id });
  const virtual = useTableVirtualization({
    rows: table.rows,
    rowKey: table.getRowKey,
    enabled: true,
    estimateSize: 40,
    getScrollElement: () => scrollRef.current,
    onEndReached: source.fetchNextPage,
  });

  return (
    <div ref={scrollRef} style={{ maxHeight: 400, overflow: "auto" }}>
      <table {...table.getTableProps()}>
        <thead>
          <tr {...table.getHeaderRowProps()}>
            {table.columns.map((column) => (
              <th key={column.key} {...table.getHeaderCellProps(column)}>
                {column.header}
              </th>
            ))}
          </tr>
        </thead>
        <tbody>
          {virtual.paddingTop > 0 && (
            <tr aria-hidden="true" style={{ height: virtual.paddingTop }}>
              <td colSpan={table.columns.length} />
            </tr>
          )}
          {virtual.rows.map(({ row, index, key }) => (
            <tr
              key={key}
              ref={virtual.measureElement}
              {...table.getRowProps(row, index)}
            >
              {table.columns.map((column) => (
                <td key={column.key} {...table.getCellProps(column)}>
                  {table.getCellContent(column, row, index)}
                </td>
              ))}
            </tr>
          ))}
          {virtual.paddingBottom > 0 && (
            <tr aria-hidden="true" style={{ height: virtual.paddingBottom }}>
              <td colSpan={table.columns.length} />
            </tr>
          )}
        </tbody>
      </table>
    </div>
  );
}
```

With `getScrollElement` the window tracks that element; without it, it tracks
the page, and `scrollMargin` is the list's offset from the top of the
document. Each mounted row is measured through `measureElement`, which reads
the `data-index` that `getRowProps` writes. `onEndReached` fires once each
time the window reaches the last loaded row, which is where an infinite
source loads more.

| Option             | Type                                    | Default  | Description                                                                       |
| ------------------ | --------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| `rows`             | `readonly TRow[]`                       | required | The rows to window — usually `table.rows`.                                        |
| `rowKey`           | `(row: TRow) => string`                 | required | Stable row key.                                                                   |
| `enabled`          | `boolean`                               | `false`  | Off returns every row with no spacers, so one render path serves both cases.      |
| `estimateSize`     | `number \| ((index: number) => number)` | `56`     | Row height estimate in px, before measurement.                                    |
| `overscan`         | `number`                                | `8`      | Rows mounted beyond each edge of the visible window.                              |
| `getScrollElement` | `() => Element \| null`                 | —        | The scroll box to track; omit to track the page.                                  |
| `scrollMargin`     | `number`                                | `0`      | Page mode only: the list's offset from the top of the document.                   |
| `onEndReached`     | `() => void`                            | —        | Called when the window reaches the last row.                                      |
| `expandable`       | `boolean`                               | `false`  | Rows carry a detail row; each pair is measured together through `measureRowPair`. |

It returns `{ enabled, rows, paddingTop, paddingBottom, measureElement?,
measureRowPair? }`, where each entry of `rows` is `{ row, index, key,
virtualItem? }`. A windowed table mounts a fraction of its rows, so tell
assistive technology the real size: set `aria-rowcount` on the table and
`aria-rowindex` on each row, as the kit adapters do — see
[Virtualization](./virtualization.md) and
[Accessibility](./accessibility.md).

## URL state

The source builders keep their state in the query string through
`useTableUrlState`, so a headless table has shareable, reload-safe URLs with
no extra code. All three builders take the same URL options:

| Option            | Default     | Description                                                                         |
| ----------------- | ----------- | ----------------------------------------------------------------------------------- |
| `urlSync`         | `true`      | `false` keeps the state in component memory instead of the URL.                     |
| `urlKey`          | —           | Namespace for this table's params (`left.q`, `left.page`, …) when a URL holds two.  |
| `urlAdapter`      | History API | A `UrlStateAdapter` for your router.                                                |
| `defaults`        | —           | Initial `page`, `limit`, `search`, sort and `extra` values while the URL is silent. |
| `numberExtraKeys` | —           | Extra-filter keys parsed as numbers.                                                |
| `arrayExtraKeys`  | —           | Extra-filter keys parsed as comma-separated arrays.                                 |

State that is not a query field has its own hook, each returning a value and
a change handler to wire into your controls:

| Hook                       | Returns                                                             |
| -------------------------- | ------------------------------------------------------------------- |
| `useColumnLayoutUrlState`  | `{ layout, onLayoutChange }` — hidden, order, pinned, widths, names |
| `useDensityUrlState`       | `{ density, onDensityChange }`                                      |
| `useGroupCollapseUrlState` | `{ collapsedGroupIds, onCollapsedGroupIdsChange }`                  |
| `useRowPinningUrlState`    | `{ pinnedRowIds, onPinnedRowIdsChange }`                            |

Each takes `urlKey`, `urlSync` and `urlAdapter`. `createHistoryAdapter()`
and `createMemoryAdapter(initialSearch?)` build adapters; router adapters
for react-router, TanStack Router and Next.js, the param names, and the
format guarantees are in [URL state](./url-state.md).

## Headless or an adapter

`useDataTable` is the right level when the markup is yours: a design system
the kits do not cover, a card-first layout, or a table embedded in a larger
component. The prop-getters carry the semantics; the controls around them —
pagination, filter forms, column menus, toolbars — are yours to draw.

When the goal is a complete table for a UI kit that AdaptTable does not ship,
with the feature factories, slots and every built-in behaviour, write an
adapter on the `@adapttable/react/adapter` builder tier instead — see
[Building an adapter](./building-an-adapter.md). Using a kit AdaptTable
already supports, `<DataTable>` from `@adapttable/<kit>` is the shorter path;
its `classNames`, `slots` and `renderCard` cover most visual changes without
leaving it ([Customization](./customization.md)).

## Notes

- `useDataTable` does not apply filters on its own. `useFrontendData`
  filters through `filterFn` and `filterTreeFn`; server sources send the
  filter state in the query. Set filter values with `source.setExtra`.
- `getTableProps` always returns `role: "table"`. Keyboard grid navigation
  (`role="grid"`, roving focus, ranges) is `useGridFocus`; see
  [Cell navigation](./cell-navigation.md#headless).
- Infinite mode without virtualization pairs with `useInfiniteScroll`, a
  sentinel ref that calls `fetchNextPage` as the end of the list comes into
  view; see [Pagination modes](./concepts.md#pagination-modes).
- Export works headless too: `downloadTableCsv({ source, columns, writer })`
  from `@adapttable/core` builds and downloads the file the export button
  would, with any writer — see [Excel (XLSX) export](./export-xlsx.md).

---

# Custom React table data source — the TableSource contract

Every `<DataTable>` renders one `TableSource<TRow>`: the rows to show, the
view state that produced them, and the setters that change it. The built-in
builders — `useFrontendData`, `useServerData`, `useQuerySource` — each return
one. When none of them fits your data layer, build the object yourself and
pass it as `source`; the table cannot tell the difference.

**Related:** [Data tiers](./data-tiers.md) · [Concepts](./concepts.md) ·
[Realtime](./realtime.md) · [API](./api.md#source-capabilities)

## Pick a builder first

Most data fits a builder, and a builder already handles the edge cases this
page lists.

| Your data                                            | Use                                                                                          |
| ---------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| Rows already in memory                               | `data` alone, or `useFrontendData` headless — [data tiers](./data-tiers.md#1-frontend--data) |
| Rows pushed into memory by a socket or poll          | `data` plus `applyRowPatches` / `useRowPatchStream` — [realtime](./realtime.md)              |
| A paginated endpoint you call yourself               | `onQueryChange`, or `useServerData` headless                                                 |
| TanStack Query `useInfiniteQuery`, caching, prefetch | `useQuerySource` — [full control](./data-tiers.md#3-full-control--source)                    |

A hand-rolled source fits when:

- your data layer already speaks its own protocol — a socket that answers
  queries and pushes invalidations, an SDK with its own paging object — and
  mapping it onto the contract is shorter than routing it through
  `onQueryChange`;
- the view state (page, sort, filters) must live in a store you already own,
  because other parts of the app read and write it;
- a test or story needs a fixed source with exact flags.

## Example — a socket that answers queries

The server receives a query over a WebSocket, replies with one page, and sends
`changed` when the data moves. `useTableUrlState` supplies the whole state
half of the contract; the hook adds the data half.

```tsx
import { useEffect, useRef, useState } from "react";
// or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd",
// "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same exports everywhere.
import {
  DataTable,
  type TableSource,
  useTableUrlState,
} from "@adapttable/mantine";

interface Trade {
  id: string;
  symbol: string;
  price: number;
}

/** What the server sends back over the socket. */
type ServerMessage =
  | { type: "page"; requestId: number; rows: Trade[]; total: number }
  | { type: "error"; requestId: number; message: string }
  | { type: "changed" };

/** Paged only: there is never a next page to append. */
function noNextPage(): void {}

export function useTradeSource(url: string): TableSource<Trade> {
  // The state half: page, limit, search, sort, filters, grouping and every
  // setter, URL-synced, with each visible-row change resetting to page 1.
  const state = useTableUrlState({ urlKey: "trades" });

  const socketRef = useRef<WebSocket | null>(null);
  const latestRequest = useRef(0);
  const [open, setOpen] = useState(false);
  const [revision, setRevision] = useState(0);
  const [rows, setRows] = useState<readonly Trade[]>([]);
  const [total, setTotal] = useState(0);
  const [loaded, setLoaded] = useState(false);
  const [fetching, setFetching] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const socket = new WebSocket(url);
    socketRef.current = socket;
    socket.onopen = () => setOpen(true);
    socket.onclose = () => setOpen(false);
    socket.onerror = () => {
      setFetching(false);
      setError(new Error("The trades feed is unavailable."));
    };
    socket.onmessage = (event: MessageEvent<string>) => {
      const message = JSON.parse(event.data) as ServerMessage;
      if (message.type === "changed") {
        // The server says the data moved: ask for the same view again.
        setRevision((n) => n + 1);
        return;
      }
      // A reply to a superseded query is dropped.
      if (message.requestId !== latestRequest.current) return;
      setFetching(false);
      if (message.type === "error") {
        setError(new Error(message.message));
        return;
      }
      setRows(message.rows);
      setTotal(message.total);
      setError(null);
      setLoaded(true);
    };
    return () => {
      socket.close();
      socketRef.current = null;
    };
  }, [url]);

  const { page, limit, search, sortBy, sortDir, extra } = state;
  useEffect(() => {
    const socket = socketRef.current;
    if (!open || !socket) return;
    latestRequest.current += 1;
    setFetching(true);
    socket.send(
      JSON.stringify({
        type: "query",
        requestId: latestRequest.current,
        page,
        limit,
        search,
        sortBy,
        sortDir,
        filters: extra,
      })
    );
  }, [open, revision, page, limit, search, sortBy, sortDir, extra]);

  return {
    ...state,
    rows,
    total,
    isLoading: !loaded,
    isFetching: fetching,
    isFetchingNextPage: false,
    hasNextPage: false,
    fetchNextPage: noNextPage,
    error,
    refetch: () => setRevision((n) => n + 1),
    paginationMode: "paged",
    capabilities: {
      fullDataset: false,
      grouping: false,
      selectAcrossPages: true,
      exportScope: "page",
      totalCount: "exact",
    },
  };
}

export function TradesTable() {
  const source = useTradeSource("wss://example.com/trades");
  return (
    <DataTable
      source={source}
      columns={[
        { key: "symbol", sortable: true },
        { key: "price", sortable: true, align: "end" },
      ]}
      rowKey={(row) => row.id}
    />
  );
}
```

## How it works

- `source` wins tier resolution: the table uses the object as it is and does
  no filtering, sorting or paging of its own. Passing `data` or
  `onQueryChange` alongside it dev-warns.
- `useTableUrlState` returns every state field and every setter the contract
  needs (`UseTableUrlStateResult` extends `TableStateMutators`), so spreading
  it covers that half. Pass `urlSync: false` to keep the same state in memory
  instead of the URL.
- The source re-sends its query whenever a state value changes, and on
  `refetch()` or a server `changed` message. Each request carries an id, and a
  reply to an older id is dropped, so a slow answer never overwrites a newer
  one.
- `isLoading` is true until the first page arrives and never again;
  `isFetching` is true whenever a request is in flight. The table draws the
  skeleton while `isLoading` holds and no rows exist, and a non-blocking
  refresh indicator for every later request.
- `capabilities` says this source serves one page at a time and counts the
  full match set. Grouping is off, so a `groupBy` in the URL is ignored and the
  status bar says why.

## The contract

`TableSource<TRow>` is exported from `@adapttable/core` and re-exported by
every kit. The members below are required.

### Data

| Member               | Type                    | What the table does with it                                                                                                |
| -------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `rows`               | `readonly TRow[]`       | Renders them — the current page, or every loaded row in infinite mode.                                                     |
| `total`              | `number`                | Drives the pager and the "select all N matching" banner.                                                                   |
| `isLoading`          | `boolean`               | First load only. With no rows, the body renders the skeleton. A background refresh never raises it again.                  |
| `isFetching`         | `boolean`               | Any request in flight. Without `isLoading` or `isFetchingNextPage`, the table shows its refresh indicator and `aria-busy`. |
| `isFetchingNextPage` | `boolean`               | An append started by `fetchNextPage` is in flight. Always `false` in paged mode.                                           |
| `hasNextPage`        | `boolean`               | Infinite mode shows Load more and arms the scroll sentinel. Always `false` in paged mode.                                  |
| `fetchNextPage`      | `() => void`            | Appends the next page. A no-op in paged mode, while an append is in flight, or when the data is exhausted.                 |
| `error`              | `Error \| null`         | Non-null replaces the body with the error state and hides the pager.                                                       |
| `paginationMode`     | `"paged" \| "infinite"` | The resolved mode — never `"auto"`. Paged shows the footer pager; infinite shows Load more.                                |

### View state

| Member         | Type                           | Meaning                                                                            |
| -------------- | ------------------------------ | ---------------------------------------------------------------------------------- |
| `page`         | `number`                       | Current 1-based page.                                                              |
| `limit`        | `number`                       | Current page size.                                                                 |
| `defaultLimit` | `number`                       | The size the rows-per-page list keeps offering after the reader picks another one. |
| `search`       | `string`                       | Committed search term.                                                             |
| `sortBy`       | `string \| undefined`          | Active sort column key.                                                            |
| `sortDir`      | `"asc" \| "desc" \| undefined` | Active sort direction.                                                             |
| `sortLevels`   | `readonly SortLevel[]`         | The multi-sort chain; empty unless multi-sort is in use.                           |
| `extra`        | `ExtraFilters`                 | The filter bag: `Record<string, string \| string[] \| number \| undefined>`.       |
| `groupBy`      | `string \| undefined`          | Row-grouping keys, comma-separated.                                                |

### Setters — `TableStateMutators`

Every setter that changes which rows are visible also resets to page 1.

| Member            | Signature                                                 | Obligation                                                                             |
| ----------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| `setPage`         | `(next: number) => void`                                  | Set the 1-based page. The only setter that does not reset the page.                    |
| `setLimit`        | `(next: number) => void`                                  | Set the page size; reset to page 1.                                                    |
| `setSort`         | `(key: string \| undefined, dir?: SortDirection) => void` | Set or clear the single-column sort; reset the multi-sort chain and the page.          |
| `toggleSortLevel` | `(key: string) => void`                                   | Cycle the key in the chain: absent → asc → desc → removed. New keys append at the end. |
| `setSearch`       | `(next: string) => void`                                  | Set or clear the search term; reset to page 1.                                         |
| `setExtra`        | `(key: string, value: FilterValue) => void`               | Set one filter; reset to page 1.                                                       |
| `setExtras`       | `(updates: ExtraFilters) => void`                         | Set several filters in one commit; reset to page 1.                                    |
| `clearExtras`     | `() => void`                                              | Clear every filter; keep search and sort; reset to page 1.                             |
| `clearAll`        | `() => void`                                              | Clear search, sort, grouping, page and every filter in one commit.                     |
| `setGroupBy`      | `(key: string \| undefined) => void`                      | Set or clear the grouping keys; reset to page 1.                                       |

The table's Clear filters action calls `clearExtras()`, then
`setFilterTree?.(undefined)`. The empty state reads "no results" rather than
"no data" when a filter chip is active, `extra` has a key, or `search` is
non-empty.

## Optional members

A source adds these when it can answer them. Leaving one out turns off what it
would have unlocked; nothing else changes.

| Member                                                   | Add it when                                                                                                                                                              |
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `refetch`                                                | The source can re-run its fetch. The error state offers Retry only when this exists, and shows it as retrying while `isFetching` is true.                                |
| `allFilteredRows`                                        | Every row matching search, filters and sort is in hand, unpaginated. Client grouping, export of every matching row and select-across-pages read it.                      |
| `allSearchedRows`                                        | Rows after search, before filters. Facet counts start here so a checklist can exclude its own filter.                                                                    |
| `facets`                                                 | You computed distinct-value counts per filter key (`FacetMap`) — on a server, the counts for `query.facets`.                                                             |
| `filterTree` / `setFilterTree`                           | The source applies a nested AND/OR tree ([advanced filters](./filter-tree.md)).                                                                                          |
| `capabilities`                                           | The shape of the source would mislead the table — see below.                                                                                                             |
| `groups`                                                 | The server computed the groups (`QueryGroupRow<TRow>[]`) for the current `groupBy`. The table renders them instead of grouping the one page it holds.                    |
| `groupAggregations` / `queryAggregates`                  | With server groups: the operations the rows on screen were computed with, and the developer's original `query.aggregates`.                                               |
| `aggregateOperations`                                    | The backend names the aggregate ids it computes. Omitted, the five standard functions are assumed.                                                                       |
| `honorsAggregates`                                       | The source computes the `query.aggregates` it receives. On server groups the reader's aggregate controls are enabled only when this is `true`. Frontend sources omit it. |
| `groupAggregateOverrides` / `setGroupAggregateOverrides` | The source keeps the reader's per-session aggregate choices. `useTableUrlState` provides both.                                                                           |
| `initializeGroupBy`                                      | Set grouping only when no grouping state exists yet, so an explicit empty value still wins. `useTableUrlState` provides it.                                              |
| `tableEngine`                                            | The source is backed by a `TableEngine` — see [the engine and revisions](#the-engine-and-revisions).                                                                     |

## State that lives in your own store

When page, sort and filters must live in a store you own, implement the setters
yourself. This hook keeps the view in React state; the same functions map onto
any store's actions.

```ts
import { useState } from "react";
import type {
  ExtraFilters,
  SortLevel,
  TableSource,
  TableStateMutators,
} from "@adapttable/core";

/** The view a table reads and writes, held wherever your app keeps state. */
export type ViewState = TableStateMutators &
  Pick<
    TableSource<unknown>,
    | "page"
    | "limit"
    | "defaultLimit"
    | "search"
    | "sortBy"
    | "sortDir"
    | "extra"
    | "groupBy"
  >;

interface View {
  page: number;
  limit: number;
  search: string;
  sortLevels: readonly SortLevel[];
  extra: ExtraFilters;
  groupBy: string | undefined;
}

const DEFAULT_LIMIT = 25;
const INITIAL: View = {
  page: 1,
  limit: DEFAULT_LIMIT,
  search: "",
  sortLevels: [],
  extra: {},
  groupBy: undefined,
};

/** absent → asc → desc → removed; a new key joins the end of the chain. */
function cycleLevel(
  levels: readonly SortLevel[],
  key: string
): readonly SortLevel[] {
  const current = levels.find((level) => level.key === key);
  if (!current) return [...levels, { key, dir: "asc" }];
  if (current.dir === "asc") {
    return levels.map((level) =>
      level.key === key ? { key, dir: "desc" } : level
    );
  }
  return levels.filter((level) => level.key !== key);
}

export function useViewState(): ViewState {
  const [view, setView] = useState<View>(INITIAL);
  // Every change to which rows are visible starts again at page 1.
  const narrow = (next: (prev: View) => Partial<View>) =>
    setView((prev) => ({ ...prev, ...next(prev), page: 1 }));
  const head = view.sortLevels[0];

  return {
    ...view,
    defaultLimit: DEFAULT_LIMIT,
    sortBy: head?.key,
    sortDir: head?.dir,
    setPage: (page) => setView((prev) => ({ ...prev, page })),
    setLimit: (limit) => narrow(() => ({ limit })),
    setSort: (key, dir = "asc") =>
      narrow(() => ({ sortLevels: key ? [{ key, dir }] : [] })),
    toggleSortLevel: (key) =>
      narrow((prev) => ({ sortLevels: cycleLevel(prev.sortLevels, key) })),
    setSearch: (search) => narrow(() => ({ search })),
    setExtra: (key, value) =>
      narrow((prev) => ({ extra: { ...prev.extra, [key]: value } })),
    setExtras: (updates) =>
      narrow((prev) => ({ extra: { ...prev.extra, ...updates } })),
    clearExtras: () => narrow(() => ({ extra: {} })),
    clearAll: () => setView((prev) => ({ ...INITIAL, limit: prev.limit })),
    setGroupBy: (groupBy) => narrow(() => ({ groupBy })),
  };
}
```

Swap `useTableUrlState({ urlKey: "trades" })` in the socket example for
`useViewState()` and the rest of the source is unchanged. `sortBy` and
`sortDir` read the head of the chain, so single-column and multi-column
sorting stay one state.

## `capabilities` — what the source can do

Some controls need more than one page: exporting every row, grouping, "select
all 2,431 matching". `sourceCapabilities(source)` in `@adapttable/core` is the
one place the table decides what a source supports. A declared
`capabilities` object wins outright — it is used whole, not merged. Without
one, the answer is read off the source's shape:

| Capability          | Inferred as                                                                                      |
| ------------------- | ------------------------------------------------------------------------------------------------ |
| `fullDataset`       | `true` when `allFilteredRows` is present.                                                        |
| `grouping`          | `"server"` when `groups` is present; otherwise `"client"` when `fullDataset`; otherwise `false`. |
| `selectAcrossPages` | `true` when `fullDataset`, or when `total` is greater than zero.                                 |
| `exportScope`       | `"all"` when `fullDataset`; otherwise `"page"`.                                                  |
| `totalCount`        | `"exact"` when `fullDataset`, or when `total` is greater than zero; otherwise `"loaded"`.        |

Declare `capabilities` when the shape says the wrong thing: a paged source
whose backend can export or select everything, or an in-memory slice that must
not pose as the whole set. A declaration describes support; it does not move
data. `exportScope: "all"` without `allFilteredRows` still needs an export
route on the export feature — see
[what a source can do](./data-tiers.md#what-a-source-can-do--capabilities) and
[browser and server-built exports](./exporting.md).

## The engine and revisions

`tableEngine` is optional, and only `useFrontendData` sets it among the
builders. When a source carries one, the table wraps it with
`createNeutralTable(engine, engine.tableId, binding)` and publishes the
resulting `NeutralTable` — the shape `@adapttable/ai` reads — alongside the
rows it renders. A source without an engine renders exactly the same.

Revisions belong to the engine, not to the source. `TableRevisions` carries
four counters — `data`, `view`, `schema`, `policy` — that a subscriber wakes on
through `engine.subscribe(axes, listener)`, and `revisionToken(revisions)`
folds them into one comparable string. `TableSource` has no counter of its
own: the table reads a source on every render, so a hand-rolled source signals
change the way any React value does. Publish a new `rows` array when the data
changes and keep the same one when it does not — derived views compare arrays
by identity.

To back a custom source with an engine, build it with `createTableEngine` from
`@adapttable/core` — see
[the engine, and why it has no React in it](./concepts.md#the-framework-neutral-engine).

## Notes

- `groupBy` alone never groups a one-page source. Grouping needs
  `allFilteredRows` (client) or `groups` (server); without either, the table
  ignores it and the status bar carries the reason.
- In infinite mode, `rows` is every row loaded so far and `fetchNextPage`
  appends to it — the adapters call it when the bottom of the list scrolls
  into view and from the Load more button.
- `refetch` may return a promise or nothing. The table calls it from the error
  state's Retry and does not await it.
- The table never owns the data. Edits, adds, deletes and reorders reach your
  app through feature callbacks; a source reports the result through `rows`.
- `TableSource`, `TableStateMutators`, `TableSourceCapabilities`,
  `sourceCapabilities` and `capabilityReason` are exported from
  `@adapttable/core`; the [API reference](./api.md#source-capabilities) lists
  their types.

---

# Build a React table adapter for any UI kit

An adapter is a kit's `DataTable` plus one entry per feature, each drawn with
that kit's own components. Every built-in adapter is built from
`@adapttable/react/adapter` — the same public entry a new one uses, with the
same semver promise as the main entry. The shell behind it resolves the data
tier, runs filters, sorting, paging, selection and keyboard wiring, and hands
back state; the adapter renders it.

**Related:** [Concepts](./concepts.md) · [Feature composition](./features.md) ·
[Customization](./customization.md) ·
[The adapter contract](./api.md#the-adapter-contract)

## Two ways to reach a kit

| Your kit                                                                    | Build                                                                           | Reference                                                                                           |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| A class-based design system (utility classes, tokens, no component library) | A class map over `@adapttable/unstyled`, which renders native elements          | [`@adapttable/shadcn`](../packages/react/adapter-shadcn/src/DataTable.tsx)                          |
| A component library (buttons, inputs, menus, drawers as components)         | A full adapter on `useDataTableShell`, every control drawn with a kit component | [`@adapttable/unstyled`](../packages/react/adapter-unstyled/src/DataTable.tsx), and each themed kit |

The shadcn adapter is the whole of the first pattern: its `DataTable` passes
`shadcnClassNames` to the unstyled `DataTable`, merging a caller's
`classNames` over the preset per part, and each feature subpath re-exports the
unstyled one (`export * from "@adapttable/unstyled/density"`). The rest of this
page covers the second pattern.

## Example — the root table

`@acme/ui` stands for your kit. `DesktopTable`, `MobileCards` and `Pager` are
the adapter's own files (see [the worked reference](#the-worked-reference--adapttableunstyled)).

```ts
// src/types.ts
import type { TableSource } from "@adapttable/core";
import type { BaseDataTableProps, UrlStateAdapter } from "@adapttable/react";
import type { DataModeProps } from "@adapttable/react/adapter";
import type { ReactNode } from "react";

/** The five wrapper hooks every themed kit honours. */
export interface DataTableClassNames {
  root?: string;
  toolbar?: string;
  table?: string;
  card?: string;
  footer?: string;
}

export interface DataTableSlots {
  empty?: ReactNode;
  noResults?: ReactNode;
  skeleton?: ReactNode;
}

export interface DataTablePropsBase<TRow> extends Omit<
  BaseDataTableProps<TRow>,
  "source"
> {
  source?: TableSource<TRow>;
  data?: readonly TRow[];
  total?: number;
  loading?: boolean;
  error?: Error | null;
  urlAdapter?: UrlStateAdapter;
  urlSync?: boolean;
  urlKey?: string;
  classNames?: DataTableClassNames;
  slots?: DataTableSlots;
}

/** `mode="server"` requires `onQueryChange` at compile time. */
export type DataTableProps<TRow> = DataTablePropsBase<TRow> &
  DataModeProps<TRow>;
```

```tsx
// src/DataTable.tsx
import { Alert, Button, Spinner, TextInput } from "@acme/ui";
import {
  DataTableShellView,
  FeatureHostProvider,
  FeatureProviders,
  FeatureSlot,
  STATUS_BAR,
  TableStatusAnnouncer,
  TOOLBAR_EXTRAS,
  useDataTableShell,
  useTableFeatures,
} from "@adapttable/react/adapter";
import type { ReactNode } from "react";

import { DesktopTable } from "./components/DesktopTable";
import { MobileCards } from "./components/MobileCards";
import { Pager } from "./components/Pager";
import type { DataTableProps } from "./types";

function noAutoForm(): ReactNode {
  return null;
}

function DataTableContent<TRow>(incoming: Readonly<DataTableProps<TRow>>) {
  const props = useTableFeatures(incoming);
  const shell = useDataTableShell<TRow>(props, noAutoForm);

  return (
    <DataTableShellView shell={shell}>
      {(view) => {
        const { chrome, labels, source, table } = view;
        const body = {
          skeleton: props.slots?.skeleton ?? <Spinner label={labels.loading} />,
          empty: (chrome.emptyVariant === "noResults"
            ? props.slots?.noResults
            : undefined) ??
            props.slots?.empty ?? (
              <p data-adapttable-part="empty">
                {chrome.emptyVariant === "noResults"
                  ? labels.noResults
                  : labels.noData}
              </p>
            ),
          desktop: <DesktopTable {...view.tableProps} />,
          mobile: <MobileCards {...view.tableProps} />,
        }[chrome.body];

        return (
          <FeatureHostProvider host={view.featureHost}>
            <div
              ref={view.rootRef}
              dir={props.dir}
              data-adapttable-part="root"
              data-mobile={chrome.isMobile || undefined}
              aria-busy={chrome.isRefreshing || undefined}
              className={props.classNames?.root}
            >
              <TableStatusAnnouncer announcement={view.statusAnnouncement} />
              <div
                data-adapttable-part="toolbar"
                className={props.classNames?.toolbar}
              >
                {props.searchable !== false && (
                  <TextInput
                    {...table.getSearchInputProps()}
                    data-adapttable-part="search"
                  />
                )}
                <FeatureSlot
                  slot={TOOLBAR_EXTRAS}
                  props={{ ...view.toolbarProps, labels }}
                />
              </div>
              {chrome.errorState ? (
                <Alert tone="danger" data-adapttable-part="error">
                  {chrome.errorState.error.message}
                  {chrome.errorState.retry && (
                    <Button onClick={chrome.errorState.retry}>
                      {labels.retry}
                    </Button>
                  )}
                </Alert>
              ) : (
                body
              )}
              {chrome.showFooter && (
                <Pager
                  pagination={table.pagination}
                  source={source}
                  labels={labels}
                />
              )}
              <FeatureSlot
                slot={STATUS_BAR}
                props={{
                  enabled: props.statusBar === true,
                  notices: chrome.featureNotices,
                  shown: source.rows.length,
                  page: source.page,
                  limit: source.limit,
                  total: source.total,
                  selected: table.selection?.selectedCount ?? 0,
                  stats: view.selectionStats,
                  labels,
                  locale: props.locale,
                }}
              />
            </div>
          </FeatureHostProvider>
        );
      }}
    </DataTableShellView>
  );
}

/** Mount the providers the composed features contribute, then the table. */
export function DataTable<TRow>(incoming: Readonly<DataTableProps<TRow>>) {
  const props = useTableFeatures(incoming);
  return (
    <FeatureProviders props={props}>
      <DataTableContent<TRow> {...props} />
    </FeatureProviders>
  );
}
```

The skeleton leaves out the filters, saved views, column menu, bulk bar and
the other root slots; [where the table places slots](#where-the-table-places-slots)
lists each one and where the unstyled adapter puts it.

## Example — a feature entry

A feature that draws a control keeps the headless factory's behaviour and adds
the kit's component to a slot. This is the whole of a kit's
`/fullscreen` entry (`@acme/adapttable/fullscreen` in the package below):

```tsx
// src/fullscreen.tsx
import { IconButton } from "@acme/ui";
import {
  extendFeature,
  slotRender,
  type StaticTableFeature,
  TOOLBAR_EXTRAS,
  type ToolbarExtrasSlotProps,
} from "@adapttable/react/adapter";
import { fullscreen as core } from "@adapttable/react/features";

function FullscreenButton(props: Readonly<ToolbarExtrasSlotProps>) {
  const { onToggleFullscreen, isFullscreen, labels } = props;
  if (!onToggleFullscreen) return null;
  return (
    <IconButton
      aria-label={
        isFullscreen === true ? labels.exitFullscreen : labels.enterFullscreen
      }
      data-adapttable-part="fullscreen-toggle"
      onClick={onToggleFullscreen}
    >
      {isFullscreen === true ? "✕" : "⛶"}
    </IconButton>
  );
}

/** Take the table fullscreen, with this kit's own toolbar toggle. */
export function fullscreen(): StaticTableFeature {
  return extendFeature(core(), [
    slotRender(TOOLBAR_EXTRAS, (props) => <FullscreenButton {...props} />),
  ]);
}
```

## Example — a Chrome and its required slot

A `*Chrome` component owns structure, labels and wiring; the visible control
is a slot the adapter must fill. `ColumnGroupToggleChrome` takes one:

```tsx
// src/column-groups.tsx
import { IconButton } from "@acme/ui";
import {
  COLUMN_GROUP_TOGGLE,
  type ColumnGroupToggleButtonProps,
  ColumnGroupToggleChrome,
  type ColumnGroupToggleProps,
  extendFeature,
  slotRender,
  type StaticTableFeature,
} from "@adapttable/react/adapter";
import { collapsibleColumnGroups as core } from "@adapttable/react/features";

/** The one visible control the chrome needs — drawn with this kit. */
function ToggleButton({
  label,
  expanded,
  className,
  onClick,
}: Readonly<ColumnGroupToggleButtonProps>) {
  return (
    <IconButton
      type="button"
      data-adapttable-part="column-group-toggle"
      aria-expanded={expanded}
      aria-label={label}
      className={className}
      onClick={onClick}
    >
      {expanded ? "▼" : "▶"}
    </IconButton>
  );
}

export function ColumnGroupToggle(props: Readonly<ColumnGroupToggleProps>) {
  return (
    <ColumnGroupToggleChrome {...props} slots={{ Button: ToggleButton }} />
  );
}

/** Collapse a header group, with this kit's chevron. */
export function collapsibleColumnGroups(): StaticTableFeature {
  return extendFeature(core(), [
    slotRender(COLUMN_GROUP_TOGGLE, (props) => (
      <ColumnGroupToggle {...props} />
    )),
  ]);
}
```

The unstyled adapter fills the same slot with a `<button>` and Mantine with an
`ActionIcon`, both carrying `data-adapttable-part="column-group-toggle"` —
same model, each kit's pixels.

## How it works

- **Two components.** `DataTable` resolves `features` with `useTableFeatures`
  and mounts `FeatureProviders` above the body, because a feature that owns
  hooks owns a provider, and its state has to exist before the body reads it.
  `DataTableContent` does the rendering.
- **`useDataTableShell(props, renderAutoForm)`** resolves the tier
  (`source`, else `onQueryChange`, else `data`), builds the declarative-filter
  runtime, wires the chrome, and returns the `tableProps` and `toolbarProps`
  bundles. `renderAutoForm(defs, source, registry)` runs only when there are
  declarative filter definitions, and returns the kit's filter form.
- **`DataTableShellView`** mounts the live gates — edit history, the chrome
  extras, the chrome body, find, grid focus, export and fullscreen — and
  calls its child with the finished view. The hook alone holds inert
  stand-ins for those, so read `toolbarProps`, `gridFocus`, `find` and
  `fullscreen` from `view`, not from the hook's return value.
- **`chrome.body`** names the region to draw: `"skeleton"`, `"empty"`,
  `"desktop"` or `"mobile"`. `chrome.errorState` is set while the source
  reports an error, and carries `retry` only when the source can refetch.
- **`FeatureSlot`** draws whatever the composed features put in that slot, and
  nothing when none did. A three-prop table therefore renders no fullscreen
  button, and pays nothing for one, until a caller composes `fullscreen()`.

## The Chrome + slots contract

AdaptTable unifies the model, never the pixels:

- The `*Chrome` components on `@adapttable/react/adapter` own structure only —
  layout, recursion over groups, keyboard wiring, localized labels and
  `data-adapttable-part` names. The state machines behind them live in
  `@adapttable/core`; the binding's hooks adapt them to React.
- Every visible control — input, select, checkbox, button, anything a reader
  clicks — is a **required slot** the adapter fills with its own kit's
  component. Slot members are required in the types, so a missing one fails
  to compile:

  ```text
  Property 'Button' is missing in type '{}' but required in type 'ColumnGroupToggleSlots'.
  ```

- Slots have no native fallback. `@adapttable/unstyled` renders native
  controls because native HTML is its kit; the shadcn adapter builds on it.
- Invisible chrome — live regions, announcers, layout structure — ships with
  the binding and is used as is: `TableStatusAnnouncer`, `GridFocusAnnouncer`,
  `RowReorderAnnouncer`.
- When a kit's overlay or portal misbehaves inside the filter popover, the fix
  belongs in that adapter — `disablePortal`, `getPopupContainer`, or the kit's
  native select — never in the slot contract.

## Feature entries

Each feature ships as its own subpath, named after the feature id in kebab
case. [Kit subpaths](./features.md#kit-subpaths) lists every one and whether it
draws kit UI; `/features` forwards `@adapttable/react/features` unchanged and
`/preset` builds `standardFeatures`. An adapter fills each one in one of three
ways.

### Factory helpers

For features with several kit surfaces, a helper takes the components and
returns the finished factory.

| Helper                               | Returns                                                                                      | Components you supply                                                                                                                                                                                                              |
| ------------------------------------ | -------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createAdapterFiltersFeature`        | `filters`                                                                                    | `FiltersForm`, `ActiveFilterChips`, `FilterDrawer`, `FilterPopover`                                                                                                                                                                |
| `createAdapterGroupingFeature`       | `grouping`                                                                                   | `GroupHeaderRow`, `GroupHeaderCard`                                                                                                                                                                                                |
| `createAdapterGroupingPanelFeature`  | `groupingPanel`                                                                              | `GroupHeaderRow`, `GroupHeaderCard`, `GroupingPanel`                                                                                                                                                                               |
| `createAdapterEditingFeatures`       | `editing`, `rowEditing`, `batchEditing`, `dirtyIndicators`, `editHistory`, `undoRedoButtons` | `EditableCell`, `RowEditActions`, `BatchEditBar`, `UndoRedoButtons`; optional `historyIncludesControls`                                                                                                                            |
| `createAdapterRowDetailFeatures`     | `rowDetail`, `nestedTable`                                                                   | `ExpandToggle`                                                                                                                                                                                                                     |
| `createAdapterRowReorderFeature`     | `rowReorder`                                                                                 | `RowReorderHandle`, `RowReorderButtons`                                                                                                                                                                                            |
| `createAdapterCommandPaletteFeature` | `commandPalette`                                                                             | one `CommandPalette` component; optional `Trigger` (`AdapterCommandPaletteTriggerProps`), drawn in the toolbar for `commandPalette({ button: true })`                                                                              |
| `createAdapterContextMenuFeature`    | `contextMenu`                                                                                | one `ContextMenu` component                                                                                                                                                                                                        |
| `createAdapterTableAssistantFeature` | a `StaticTableFeature` — the kit wraps it as `tableAssistant()`                              | one `TableAssistant` component                                                                                                                                                                                                     |
| `createAdapterAgentApprovalFeature`  | a `StaticTableFeature` — the kit wraps it as `agentApproval()`                               | one `AgentApproval` component                                                                                                                                                                                                      |
| `createAdapterStandardFeatures`      | `standardFeatures`                                                                           | the kit's own factories: `columnMenu`, `densityChooser`, `exportCsv`, `findInTable`, `fitColumns`, `fullscreen`, `headerFilters`, `multiSort`, `resizableColumns`, `statusBar`, `grouping`, `bulkActions`, `filters`, `savedViews` |

```tsx
// src/filters.tsx
import { createAdapterFiltersFeature } from "@adapttable/react/adapter";

import {
  FilterChips,
  FilterDrawer,
  FilterPopover,
  FiltersForm,
} from "./components/filterParts";

/** Declarative filters, drawn with this kit's form, chips and overlays. */
export const filters = createAdapterFiltersFeature({
  FiltersForm,
  ActiveFilterChips: FilterChips,
  FilterDrawer,
  FilterPopover,
});
```

Each component's props are exported beside the helper —
`FiltersFormSlotProps`, `ActiveFilterChipsSlotProps`, `FilterOverlaySlotProps`,
`GroupHeaderRowSlotProps`, `EditableCellRenderProps` and the rest.

### `extendFeature` + `slotRender`

For a feature with one surface, keep the headless factory and append the
kit's render, as in [the fullscreen example](#example--a-feature-entry).
`extendFeature` concatenates onto the factory's own `renders`; replacing
`renders` instead would drop the live slots the factory already fills.

| Slot                                  | Features                                             |
| ------------------------------------- | ---------------------------------------------------- |
| `TOOLBAR_EXTRAS`                      | `densityChooser`, `exportCsv`, `fullscreen`, `print` |
| `STATUS_BAR`                          | `statusBar`, `selectionStats`                        |
| `COLUMN_MENU`, `COLUMN_HEADER_RENAME` | `columnMenu`                                         |
| `COLUMN_GROUP_TOGGLE`                 | `collapsibleColumnGroups`                            |
| `COLUMN_SELECT`                       | `columnSelectionCheckbox`                            |
| `BULK_BAR`                            | `bulkActions`                                        |
| `FIND_BAR`                            | `findInTable`                                        |
| `FILTER_HEADER`                       | `headerFilters`                                      |
| `FILL_HANDLE`                         | `cellNavigation`                                     |
| `SAVED_VIEWS`                         | `savedViews`                                         |
| `SIDE_PANEL`                          | `sidePanel`                                          |
| `TREE_CELL`, `TREE_TOGGLE`            | `tree`                                               |

`findInTable({ button: true })` also appends `findButtonRender(FindButton)`,
which draws the kit's Find control in `TOOLBAR_EXTRAS` with
`AdapterFindButtonProps` — the toolbar props plus `onOpenFind` and `findOpen`
— and renders nothing outside a table that composed find. Another control reads
the same state with `useFindState()`. A kit that builds its own cell props when
`cellNavigation()` is not composed passes them through
`withFindMarks(base, find, firstRowIndex)`, so matches carry the same
`data-cell-match` / `data-cell-match-current` attributes the grid writes.

### Headless pass-through

A feature that draws no control of its own is re-exported unchanged:

```ts
// src/multi-sort.ts
export { multiSort } from "@adapttable/react/features";
```

`rowPinning`, `pinnedSummaryRows`, `cellSpan`, `extraRows`, `rowAppearance`,
`rowActions`, `virtualize`, `resizableColumns`, `fitColumns` and `multiSort`
take this form. The `/pivot` entry exports the kit's `PivotPanel` and
re-exports `@adapttable/core/pivot`.

## Where the table places slots

A feature fills a slot; the adapter decides where it sits. The unstyled root
places these in [`DataTable.tsx`](../packages/react/adapter-unstyled/src/DataTable.tsx):

| Region          | Slots                                                                                                                                          |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| Announcers      | `GRID_FOCUS_ANNOUNCER`, `ROW_REORDER_ANNOUNCER`                                                                                                |
| Toolbar         | `FIND_BAR`, `FILTER_POPOVER` (wrapping the Filters button in popover mode), `SAVED_VIEWS`, `COLUMN_MENU`, `TOOLBAR_EXTRAS`                     |
| Above the body  | `FILTER_DRAWER` (drawer mode), `ACTIVE_FILTER_CHIPS`, `BATCH_EDIT_BAR`, `AGENT_APPROVAL`, `BULK_BAR`, `COMMAND_PALETTE_LIVE`, `GROUPING_PANEL` |
| Around the body | `SIDE_PANEL` through `OptionalSidePanel`, then `STATUS_BAR` below the footer                                                                   |
| Filter form     | `FILTERS_FORM`, returned from the `renderAutoForm` callback                                                                                    |

The root is wrapped in `ContextMenuLiveGate`, which spreads the context-menu
region props onto it. Inside the desktop table and mobile cards,
[`components/featureSlots.tsx`](../packages/react/adapter-unstyled/src/components/featureSlots.tsx)
places `EDITABLE_CELL`, `TREE_CELL`, `TREE_TOGGLE`, `FILL_HANDLE`,
`EXPAND_TOGGLE`, `FILTER_HEADER`, `ROW_EDIT_ACTIONS`, `ROW_REORDER_HANDLE`,
`ROW_REORDER_BUTTONS`, `COLUMN_GROUP_TOGGLE`, `COLUMN_SELECT`,
`COLUMN_HEADER_RENAME`, `GROUP_HEADER_ROW` and `GROUP_HEADER_CARD`. Those
wrappers live in their own file so the table imports them instead of the kit
implementations — omitting a feature omits its components.

## Parts and `classNames`

`data-adapttable-part` names and their placement are public contract: the same
part lands on the same element in every adapter. Seven are guaranteed in every
kit that renders the shell —
[the structural parts](./customization.md#the-structural-parts-every-adapter-names):

| Part                        | Element                          |
| --------------------------- | -------------------------------- |
| `table` / `thead` / `tbody` | The `<table>` and its sections.  |
| `row` / `cell`              | One body `<tr>` and one `<td>`.  |
| `header-cell`               | One header `<th>`.               |
| `toolbar`                   | The toolbar row above the table. |

Beyond those, a part a kit renders carries the name the other kits use for it
— `column-group-toggle`, `fullscreen-toggle`, `filters-button`, and so on.
Take names from the unstyled adapter, which names every node it renders.

`classNames` follows the parts. A themed kit exposes the five wrapper keys
`root`, `toolbar`, `table`, `card` and `footer`; the unstyled adapter exposes a
key for every node, and each node's part name is the kebab-case form of its
key (`searchField` → `data-adapttable-part="search-field"`). A documented
key is honoured by every adapter that renders its part. When module-scope slot components need the class map, the unstyled
adapter publishes it through context
([`components/classNamesContext.tsx`](../packages/react/adapter-unstyled/src/components/classNamesContext.tsx))
rather than defining slots inside the render: a component defined in render is
a new type on every render, and React remounts it — an input loses focus
mid-keystroke.

## Parity

Parity means every feature exists in the kit **with the kit's own
components**. A shared look is not parity: copying another adapter's
raw-HTML control is the same defect as drawing it in core. For each feature:

- every subpath the other kits publish exists in this one;
- the mobile card layout is handled, or the behaviour on cards is stated;
- RTL mirrors correctly — pass `dir` through and use logical CSS properties;
- keyboard access and screen-reader announcements match the other kits;
- the header cell spreads core's header props whole (`...leaf.headerProps`),
  or hands `getHeaderCellProps` to a kit that builds its own `<th>`, so
  attributes core adds reach every kit;
- overlays use the kit's own primitive. `filtersMode="popover"` is an anchored
  card with no backdrop that closes on Escape and outside click, restores
  focus and sets `aria-expanded` on its trigger; `filtersMode="drawer"` is the
  kit's Drawer with a real backdrop.

The root `DataTable` imports neither another kit nor the `/features` barrel.
A feature reaches a table only through the `features` array, which is what
keeps a three-prop table's bundle free of every feature it did not name.

## The worked reference — `@adapttable/unstyled`

[`packages/react/adapter-unstyled/src`](../packages/react/adapter-unstyled/src) is the
complete adapter for native HTML. Its layout is the one to copy:

| File                               | Holds                                                                                                                                                   |
| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DataTable.tsx`                    | The root: `useTableFeatures`, `FeatureProviders`, `useDataTableShell`, `DataTableShellView`, the toolbar, body switch and slot placement.               |
| `types.ts`                         | `DataTableClassNames`, `DataTableSlots`, `DataTablePropsBase`, and `DataTableProps` intersected with `DataModeProps`.                                   |
| `index.ts`                         | `DataTable`, its types, and the re-exported builders and types an app needs without importing core.                                                     |
| `<feature>.tsx` / `<feature>.ts`   | One file per subpath: a helper call, an `extendFeature` wrap, or a pass-through re-export.                                                              |
| `features.ts` / `preset.ts`        | The `/features` forward and the `/preset` `standardFeatures`.                                                                                           |
| `components/DesktopTable.tsx`      | The desktop table, built on `useDesktopTableAssembly` and `createDesktopRow` — see [desktop table assembly](./customization.md#desktop-table-assembly). |
| `components/MobileCards.tsx`       | The card layout.                                                                                                                                        |
| `components/featureSlots.tsx`      | The lean `FeatureSlot` wrappers the table and cards import.                                                                                             |
| `components/kitControls.tsx`       | Chrome components with their slots filled: `ColumnGroupToggle`, `TreeCell`, `FindBar`, `RowReorderHandle`, and the rest.                                |
| `components/classNamesContext.tsx` | The per-table class map for module-scope slots.                                                                                                         |
| `tsdown.config.ts`                 | One build entry per subpath, a `"use client"` banner, and `react`, `react-dom` and `@adapttable/core` kept external.                                    |

Each themed kit (`packages/react/adapter-mantine`, `adapter-mui`, `adapter-chakra`,
`adapter-antd`, `adapter-radix`, `adapter-base-ui`) has the same shape with
its kit's components in place of native elements. Ant Design renders the
desktop table through antd's own `<Table>` instead of the shared assembly.

## Neutral helpers the bindings share

`@adapttable/core/binding` holds the framework-neutral decisions every
binding makes the same way: `sourceViewKey` / `SourceViewKeyInput` and
`sameItems` / `retainItems` (whether a source refresh changed anything,
without walking the rows, and keeping the previous list when it did not), `cachedSnapshot` / `CachedSnapshot` (reuse the last snapshot when
those inputs match), `withFrontendRowModel` / `frontendRowModelOf` (the
evaluator a composed grouping feature carries for the plain-table source),
`virtualItemMeta` (the virtualizer fields a row keeps), and
`approvalValueText` / `approvalArgumentPairs` / `ApprovalArgumentPair` (an
approval review's values and arguments as readable text).

## Package exports and peer dependencies

The built-in adapters declare their kit (where there is one) and React as
peers, and the AdaptTable packages as dependencies pinned to exact versions:

```json
{
  "name": "@acme/adapttable",
  "type": "module",
  "sideEffects": false,
  "engines": { "node": ">=22.12.0" },
  "exports": {
    ".": {
      "import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
      "require": {
        "types": "./dist/index.d.cts",
        "default": "./dist/index.cjs"
      }
    },
    "./fullscreen": {
      "import": {
        "types": "./dist/fullscreen.d.ts",
        "default": "./dist/fullscreen.js"
      },
      "require": {
        "types": "./dist/fullscreen.d.cts",
        "default": "./dist/fullscreen.cjs"
      }
    }
  },
  "peerDependencies": {
    "@acme/ui": "^3.0.0",
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "dependencies": {
    "@adapttable/core": "3.0.0",
    "@adapttable/react": "1.0.0"
  }
}
```

- One `exports` entry per feature subpath, each with `import` and `require`
  conditions, so a bundler loads a feature only when a table names it.
- The kit is a peer: the app owns its version and its theme provider, and the
  adapter renders inside that theme.
- Exact pins keep `@adapttable/react` and `@adapttable/core` on the versions
  the adapter was built against, so an app installs the adapter alone.
- Built entries start with `"use client"`, so a Next.js App Router page can
  import the table without a client wrapper of its own.
- The built-in adapters also compile with the React Compiler, which is why
  they depend on `react-compiler-runtime`; an adapter built without it does
  not need that dependency.

## Testing

`@adapttable/core/conformance` is the suite every built-in adapter passes:
the structural parts, the table's accessible name, row and cell identity,
sorting and its `aria-sort`, right-to-left, mobile cards, the empty state, row
selection, the status region's announcements after a sort or a page, the
dataset size on a partial page, the host's labels in controls and
announcements, and keyboard cell navigation — the grid role, the roving tab
stop, arrow keys and the focus announcer. It asserts against the DOM and
depends on no test runner and no framework. A driver renders the kit's table
for each scenario; the runner registers the tests the suite returns:

```tsx
// src/conformance.test.tsx
import {
  type ConformanceDriver,
  type ConformanceRow,
  type ConformanceScenario,
  tableConformanceTests,
} from "@adapttable/core/conformance";
import { fireEvent, render, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { bulkActions } from "./bulk-actions";
import { cellNavigation } from "./cell-navigation";
import { type ColumnDef, DataTable } from "./index";

function columnsFor(
  scenario: ConformanceScenario
): ColumnDef<ConformanceRow>[] {
  return scenario.columns.map((column) => ({
    key: column.key,
    header: column.header,
    sortable: column.sortable,
    accessor: (row: ConformanceRow) => row[column.key],
  }));
}

const driver: ConformanceDriver = {
  name: "acme",
  mount: (scenario) =>
    render(
      <DataTable
        data={[...scenario.rows]}
        columns={columnsFor(scenario)}
        rowKey={(row) => row.id}
        urlSync={false}
        tableLabel={scenario.tableLabel}
        dir={scenario.dir}
        forceMobile={scenario.mobile}
        defaults={
          scenario.pageSize === undefined
            ? undefined
            : { limit: scenario.pageSize }
        }
        labels={scenario.labels}
        features={[
          ...(scenario.selectable
            ? [
                bulkActions([
                  {
                    key: "archive",
                    label: "Archive",
                    onClick: () => undefined,
                  },
                ]),
              ]
            : []),
          ...(scenario.navigable ? [cellNavigation()] : []),
        ]}
      />
    ),
};

describe(`table conformance — ${driver.name}`, () => {
  for (const test of tableConformanceTests(driver, {
    expect,
    fireEvent,
    waitFor,
  })) {
    it(test.name, test.run);
  }
});
```

jsdom has no `matchMedia`; stub it with a non-matching implementation in the
test setup so the table renders its desktop layout.

A binding for another framework plugs in the same way. Its driver mounts the
binding's table component with that framework's test utilities — Vue Test
Utils' `mount` with `attachTo`, or Angular's `TestBed` — maps the scenario onto
the component's inputs, and returns the element it rendered into and a
function that unmounts it. The harness is the same: the runner's `expect` and
DOM Testing Library's `fireEvent` and `waitFor`, which dispatch real DOM events
whatever rendered the table. Nothing in the suite changes.

A queued mount can also return `ready: nextTick()` (Vue) or its framework's
render-completion promise. The suite awaits `ready` before its first DOM
assertion and always unmounts, including when readiness rejects. Synchronous
drivers can omit it. All eight Vue kits register the same shared cases over
their native components, plus LTR and RTL keyboard range checks.

Beyond the suite, the built-in adapters are held to parity by:

- per-package unit tests — Vitest, Testing Library and `vitest-axe` — that
  render the kit's `DataTable` (the accessibility suite, for example, over
  `useFrontendData` with `createMemoryAdapter()`) and assert behaviour and
  zero axe violations;
- `pnpm check:parts` (`scripts/check-parts-parity.mjs`), which fails when a
  part is named by some shell kits and not others, or when one of the seven
  contract parts is missing from a kit;
- `pnpm check:features`, which among its checks requires every published kit
  to expose the same feature subpaths and pass core's header props through
  whole;
- Playwright specs in the showcase — `e2e/aria-parity.spec.ts` compares the
  ARIA shape every built kit produces, and `e2e/kit-classnames.spec.ts` checks
  the computed look of the class-map kits.

## Notes

- `@adapttable/react/adapter` is aimed at adapter authors; its exports are
  listed under [the adapter contract](./api.md#the-adapter-contract) and
  [the builder tier](./api.md#the-builder-tier).
- `DataModeProps` is the discriminated `mode` union: intersect it into the
  kit's props so `mode="server"` without `onQueryChange` fails to compile.
- Re-export the source builders and hooks (`useFrontendData`,
  `useServerData`, `useQuerySource`, `useTableUrlState`) and the common types
  from the kit's root, as the built-in adapters do, so an app imports from one
  package.
- Whether a feature draws kit UI, and which kit components it needs, is
  recorded once for every kit in
  [`scripts/feature-classification.json`](../scripts/feature-classification.json).
- Take visible text from the `labels` (`Required<TableLabels>`) or label
  props the chrome passes, rather than hard-coding it, so bundled locales and
  a caller's overrides reach the kit's controls.

---

# 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<Person>) {
  return (
    <Badge color={row.status === "active" ? "green" : "yellow"}>
      {row.status}
    </Badge>
  );
}

const columns: ColumnDef<Person>[] = [
  // 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 (
    <DataTable
      data={people}
      columns={columns}
      rowKey={(r) => 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`, `sortIndex`, `toggleSort({ shiftKey })`) 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 `formatValue`, `exportValue`, then the key's data path; a JSX `accessor` is never compared. 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, row grouping, 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<CellProps<TRow>>`                  | —                                         | Component per row, receives `{ row, rowIndex }`; wins over `accessor`.                                        |
| `sortable`                                                  | `boolean`                                         | `false`                                   | Enable sorting for this column.                                                                               |
| `sortValue`                                                 | `(row) => SortableValue`                          | `formatValue` → `exportValue` → data path | Primitive extractor for the client-side sort. See [sorting](./sorting.md).                                    |
| `formatValue`                                               | `(row) => string`                                 | —                                         | The cell as text, for screen readers, labels and copy. See below.                                             |
| `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.                                                             |
| `minWidth` / `maxWidth` / `flex`                            | `number`                                          | —                                         | Width bounds and a share of the leftover space. See [column management](./column-management.md).              |
| `lockPosition` / `lockVisibility` / `lockWidth` / `lockPin` | `boolean`                                         | `false`                                   | Disable the matching Columns-menu and resize controls for this column.                                        |
| `filter`                                                    | `FilterType \| Omit<FilterDef, "key" \| "label">` | —                                         | Declare this column's filter. See [filtering](./filtering.md).                                                |
| `editable` / `editor`                                       | `boolean \| (row) => boolean` / `CellEditor`      | —                                         | Inline editing for this column. See [cell editing](./cell-editing.md).                                        |
| `colSpan` / `rowSpan`                                       | `number \| (row) => number`                       | —                                         | Span every cell of this column. See [row spanning](./row-spanning.md).                                        |
| `groupShow`                                                 | `"open" \| "closed" \| "always"`                  | `"open"` under a collapsible group        | When this leaf shows under a collapsed group. See [column groups](./column-groups.md).                        |
| `responsivePriority`                                        | `number`                                          | —                                         | How readily the column is given up when the table is too narrow; priority 1 is kept longest.                  |
| `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<string, string>`                          | —                                         | Per-locale data paths for the column's value.                                                                 |
| `meta`                                                      | `Record<string, unknown>`                         | —                                         | 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) => <Badge color={tone(row)}>{row.status}</Badge>,
  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. the key's data path

The first that yields a string, number, boolean or `Date` (read as its ISO day)
wins; nothing yields `""`. A React `accessor` or `Cell` is never consulted, so a
column whose rendered cell differs from its data path sets `formatValue` to say
what it shows.

## 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/react";

const columns = [
  { key: "quantity" },
  { key: "unitPrice" },
  computed<Order, number>({
    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`, `Cell`, `sortValue`,
  `exportValue` and `formatValue` 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. `@adapttable/core` exports the same helper typed as a neutral `ColumnModel`, with its spec type `ComputedColumnSpec`, for headless and non-React callers.

## 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<Person>[] = [
  { 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.

Compose `collapsibleColumnGroups()` from `@adapttable/<kit>/column-groups` 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 sorts by its data path unless it sets `sortValue` (or `formatValue` / `exportValue`); without `sortValue` 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://adapttable.orwamahmoud.com/react/demo/).

---

# React table column groups — spanning headers, collapsible

▶ **See it working:** [collapse column groups in Mantine](https://adapttable.orwamahmoud.com/react/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. Without
`collapsibleColumnGroups()` the headers stay static.

This is not [row grouping](./row-grouping.md). Rows fold under `grouping("team")`;
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";
import { collapsibleColumnGroups } from "@adapttable/mantine/column-groups";

const columns: ColumnInput<Person>[] = [
  {
    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" },
    ],
  },
];

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[collapsibleColumnGroups()]}
/>;
```

| `ColumnGroupDef` field | Type                           | Default | Description                                                    |
| ---------------------- | ------------------------------ | ------- | -------------------------------------------------------------- |
| `header`               | `string`                       | —       | Group caption; required.                                       |
| `children`             | `ColumnInput[]`                | —       | Leaves and nested groups; required.                            |
| `headerTooltip`        | `string`                       | —       | Native tooltip on the group caption.                           |
| `align`                | `"start" \| "center" \| "end"` | —       | Alignment of the group caption.                                |
| `collapsedKey`         | `string`                       | —       | Child that stays visible while collapsed.                      |
| `collapsedRender`      | `(row) => DisplayValue`        | —       | One cell shown while collapsed; wins over `collapsedKey`.      |
| `marryChildren`        | `boolean`                      | `true`  | Keep the children together; `false` lets a reorder split them. |

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 `<th>`. HTML kits
draw the line under an open group with `groupedHeaderChildRule` so adjacent
groups do not share one stroke.

## Collapse

Compose `collapsibleColumnGroups()` from `@adapttable/<kit>/column-groups` 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 (with `collapsibleColumnGroups()`) 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<Person>[] = [
  { 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

React adapter authors can import `flattenReactColumnTree` and the
`ReactColumnGroupDef<TRow>` type from `@adapttable/react/adapter`.
`ReactColumnGroupDef` is the same type exported by `@adapttable/react`;
its children keep React column render callbacks when passed to the flattener.

`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://adapttable.orwamahmoud.com/react/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://adapttable.orwamahmoud.com/react/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",
  }),
];

<DataTable data={rows} columns={columns} rowKey={(row) => 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`).

| Option   | Type                                               | Default         | Description                                                     |
| -------- | -------------------------------------------------- | --------------- | --------------------------------------------------------------- |
| `key`    | `string`                                           | —               | Column key.                                                     |
| `values` | `(row: TRow) => readonly number[]`                 | —               | The series. Non-finite values are dropped before drawing.       |
| `kind`   | `"bar" \| "line" \| "area"`                        | `"line"`        | Chart form.                                                     |
| `header` | `ReactNode`                                        | —               | Column header.                                                  |
| `width`  | `number`                                           | `80`            | SVG width in CSS pixels.                                        |
| `height` | `number`                                           | `28`            | SVG height in CSS pixels.                                       |
| `color`  | `string`                                           | `currentColor`  | Fill and stroke colour.                                         |
| `label`  | `(values: readonly number[], row: TRow) => string` | numeric summary | Accessible name of the chart.                                   |
| `column` | `Partial<ColumnDef<TRow>>`                         | —               | Extra column fields (`width`, `sortable`, …) merged underneath. |

`<Sparkline>` takes `values`, `kind`, `width`, `height`, `color` and a string
`label`. The SVG carries `data-adapttable-part="sparkline"` and `data-kind`.
`sparklineSummary`, `sparklineExportValue` and `finiteSparklineValues` are the
default label, the export text and the finite-value filter on their own.

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 + `multiSort()` — shift-click a second header); 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 feature import: `multiSort()` from `@adapttable/<kit>/multi-sort`.

## Example

```tsx
import {
  type ColumnDef,
  DataTable,
  useFrontendData,
} from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled
import { multiSort } from "@adapttable/mantine/multi-sort";

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<Person>[] = [
  { 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 (
    <DataTable
      source={source}
      columns={columns}
      rowKey={(r) => r.id}
      features={[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 `formatValue`, `exportValue`, then the key's data path. Numbers and booleans 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` with `multiSort()` composed. `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). The first shift-click keeps the current sort as level one, so click Name then shift-click Salary sorts Name → Salary. 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()`   | factory from `@adapttable/<kit>/multi-sort`                | off                          | Multi-column sorting via shift-click / shift-Enter.                                |
| `getSortValue`  | `(row, columnKey) => SortableValue` (on `useFrontendData`) | —                            | One sort-key extractor for the whole row; wins over per-column `sortValue`.        |
| `defaults`      | `Partial<TableQueryParams> & { 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 `<DataTable>` 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 sorts by its data path unless it sets `sortValue` (or `formatValue` / `exportValue`); without `sortValue` a development warning fires. A `sortBy` that matches 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://adapttable.orwamahmoud.com/react/demo/).

---

# React table search — debounced, URL-synced and server-ready

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — the search box is already in `src/App.tsx` (`searchPlaceholder`); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

Every `DataTable` has a search box. It is part of the base table — no feature
import — and it narrows the rows to those that match: on frontend data the
table matches each row's searchable text, and on a server tier it hands the
term to your backend. The committed term lives in the URL as `q`, so a
reload or a shared link keeps it. Turn the box off with `searchable={false}`.

## Example

```tsx
import {
  type ColumnDef,
  DataTable,
  useFrontendData,
} from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled

interface Person {
  id: string;
  firstName: string;
  lastName: string;
  city: string;
  internalRef: string;
}

const people: Person[] = [
  {
    id: "1",
    firstName: "Amira",
    lastName: "Haddad",
    city: "Lisbon",
    internalRef: "EMP-0042",
  },
  {
    id: "2",
    firstName: "Jonas",
    lastName: "Weber",
    city: "Berlin",
    internalRef: "EMP-0107",
  },
  {
    id: "3",
    firstName: "Priya",
    lastName: "Nair",
    city: "Pune",
    internalRef: "EMP-0213",
  },
  {
    id: "4",
    firstName: "Sam",
    lastName: "Ortiz",
    city: "Bogotá",
    internalRef: "EMP-0350",
  },
];

const columns: ColumnDef<Person>[] = [
  {
    key: "name",
    header: "Name",
    accessor: (row) => `${row.firstName} ${row.lastName}`,
    sortable: true,
  },
  { key: "city", sortable: true },
];

export function PeopleDirectory() {
  const source = useFrontendData({
    data: people,
    columns,
    // Match the full name and the city; internalRef stays out of search.
    getSearchText: (row) => `${row.firstName} ${row.lastName} ${row.city}`,
  });
  return (
    <DataTable
      source={source}
      columns={columns}
      rowKey={(row) => row.id}
      searchPlaceholder="Search by name or city…"
      searchDebounceMs={200}
    />
  );
}
```

With the zero-ceremony `data` prop the box works as it is; build the source
with `useFrontendData` (or `useTableData`) when you want to choose what a row
is searched by.

## How it works

- **Typing is local, committing is debounced.** The input updates on every
  keystroke. After `searchDebounceMs` of quiet (300 ms by default) the trimmed
  term is committed to the source, which writes `q`, resets the page to 1 and
  re-queries. A change from outside — the back button, a link, a saved view —
  flows back into the input without clobbering a keystroke in flight.
- **Frontend matching.** On the `data` / `useFrontendData` tier a row matches
  when its searchable text contains the trimmed term, case-insensitively, as
  a plain substring. Search runs before the filters and the
  [AND/OR tree](./filter-tree.md); all of them must pass.
- **Searchable text.** `getSearchText(row)` projects a row to the string
  search reads. The default, `defaultSearchText`, joins the row's own
  top-level values with spaces; a nested object contributes its JSON text
  (keys included), and `null` / `undefined` contribute nothing. It reads the raw row, not the
  rendered cells, so a formatted date is found by its underlying value.
  Override it to reach nested fields, add computed text, or leave fields out.
  The projection is cached per row id until the `data` array changes, so
  keep it a pure function of the row.
- **Server tiers.** The table does not match anything; it sends the term.
  `useServerData`'s `onQueryChange(query)` receives it as `query.search` (an
  empty string when there is none), and `useQuerySource` passes it to your
  query as `params.search` on `TableQueryParams` (omitted when empty).
  `getSearchText` is unused there.
- **Empty results.** Zero rows under an active search render the "no
  results" state (`labels.noResults`) rather than the empty-data state.
  Clearing the filters leaves the search as it is.

## Options

| Option / prop       | Type                                   | Default                    | Description                                                                           |
| ------------------- | -------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
| `searchable`        | `boolean`                              | `true`                     | `DataTable` prop. `false` removes the search box.                                     |
| `searchPlaceholder` | `string`                               | `labels.searchPlaceholder` | `DataTable` prop. Placeholder text; the built-in label is "Search…".                  |
| `searchDebounceMs`  | `number`                               | `300`                      | `DataTable` prop. Quiet time before the typed term commits to the source and the URL. |
| `getSearchText`     | `(row) => string`                      | `defaultSearchText`        | On `useFrontendData` / `useTableData`. The text a row is searched by.                 |
| `defaults.search`   | `string` (`Partial<TableQueryParams>`) | —                          | Initial term, on `DataTable` or a source builder. Applies while the URL has no `q`.   |
| `labels.search`     | `string`                               | `"Search"`                 | Accessible name of the search box.                                                    |

## Server-side search

A server source forwards the committed term and leaves the matching to the
backend:

```tsx
import { useState } from "react";
import { DataTable } from "@adapttable/mantine";

interface Person {
  id: string;
  name: string;
  city: string;
}

export function PeopleSearch() {
  const [rows, setRows] = useState<Person[]>([]);
  const [total, setTotal] = useState(0);

  return (
    <DataTable
      data={rows}
      total={total}
      onQueryChange={async (query, { signal }) => {
        const params = new URLSearchParams({
          page: String(query.page),
          limit: String(query.limit),
        });
        if (query.search) params.set("q", query.search);
        const res = await fetch(`/api/people?${params}`, { signal });
        const body = (await res.json()) as { items: Person[]; total: number };
        setRows(body.items);
        setTotal(body.total);
      }}
      columns={[{ key: "name" }, { key: "city" }]}
      rowKey={(row) => row.id}
      searchPlaceholder="Search people…"
    />
  );
}
```

On the backend, `parseTableQuery` from `@adapttable/server` reads `q` (under
`<urlKey>.` when set) and returns it as `search`, absent when the request has none —
see [server queries](./server-queries.md). Treat it like any user input:
parameterise it.

## Search is not find-in-table

Search and find answer different questions, and a table can have both.

|                   | Search                                        | Find in table                                          |
| ----------------- | --------------------------------------------- | ------------------------------------------------------ |
| Question          | "Show me only the rows that match"            | "Where does this appear in what I am looking at?"      |
| Rows              | Filters them; server tiers ask the server     | Leaves every row in place and walks the matching cells |
| Reads             | `getSearchText(row)` — the raw row            | What each cell shows                                   |
| Scope             | The whole dataset                             | The loaded rows — the current page, or what is fetched |
| URL               | `q`                                           | `find`                                                 |
| How to turn it on | Always there; `searchable={false}` removes it | `findInTable()` from `@adapttable/<kit>/find-in-table` |

Find works with or without `cellNavigation()`: **Ctrl/Cmd+F** opens it while
focus is inside the table or after a click in it, and with cell navigation
the walk moves focus from match to match.
[Keyboard & cell navigation](./cell-navigation.md#find-in-table) covers the
find bar.

## Notes

- **URL state.** `q` holds the committed, trimmed term and is written with
  replace-state, so typing does not fill the history. An empty term deletes
  the param; when `defaults.search` is set, clearing writes `q=` so the
  default does not come back. With `urlKey="people"` it is `people.q`.
  `searchable={false}` removes only the box: a `q` in the URL or in
  `defaults` still narrows the rows. See [URL state](./url-state.md).
- **Saved views** store `q` with the rest of the table state — see
  [saved views](./saved-views.md).
- **Mobile.** The search box stays in the toolbar above the cards and
  narrows them the same way. With the default `paginationMode`, the cards
  load more as you scroll instead of paging.
- **Keyboard and screen readers.** The box is an `<input type="search">`
  with `role="searchbox"`, named by `labels.search`. Translate `search` and
  `searchPlaceholder` through `labels` — see [i18n & RTL](./i18n-rtl.md).
- **Custom markup.** `useSearchInput(search, setSearch, debounceMs)` in
  `@adapttable/react` is the debounced input state behind the box, and
  `getSearchInputProps()` on `useDataTable` returns the input's props.

---

# 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) — the starter's column filters are already wired in `src/App.tsx` (`filter` on each column + `filters([])`), with chips and URL state; 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_<key>` 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. <MantineProvider>).
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 (
    <DataTable
      data={data}
      rowKey={(r) => 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/<kit>/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 / relative / empty. The operator token is
  stored as `f_<key>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` is
  notified after the table clears them — supply `source.clearExtras` to replace
  the clear itself.

## Options

`FilterDef` (entries of `filters`, and the column `filter` object minus
`key`/`label`):

| Prop          | Type                                                        | Default               | Description                                                                                                                                                                                                                                                     |
| ------------- | ----------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `key`         | `string`                                                    | —                     | State key and `f_<key>` 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<FilterOption[]>` | —                     | 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.                                                                                                                                                                                                                               |
| `column`      | `string`                                                    | `key`                 | Column whose header filter shows this widget.                                                                                                                                                                                                                   |
| `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: the per-column funnel from `headerFilters()`, which alone already selects header mode. |
| `onClearFilters`            | `() => void`                        | —           | Notification after the table clears the filters (drawer, chip strip, no-results action).                                                                                     |
| `filterLabels`              | `Record<string, ChipLabelResolver>` | 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.                                               |
| `filterFields`              | `boolean`                           | `true`      | Mount the per-field form inside Filters; `false` leaves only the AND/OR tree.                                                                                                |
| `filterTypes([…])`          | `FilterTypeSpec[]`                  | built-ins   | Factory from `@adapttable/<kit>/filters`; extra or replacement filter types merged onto `defaultFilterRegistry`. Same `type` replaces.                                       |
| `headerFilters()`           | factory                             | off         | Factory from `@adapttable/<kit>/header-filters`; per-column header filter funnel. Composing it selects header mode. 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_<key>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([…])` (from `@adapttable/<kit>/filters`) 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; `host.extendFilterType("text", { ops })` in a feature's `setup` adds
  operators to the AND/OR builder for that type; the field widgets keep their
  own operator menus. See [custom filter types](./custom-filter-types.md). `urlArray` persists a custom type's value as a
  comma-separated array in the URL; `urlNumberKeys` persists its range bounds
  as numbers.
  `emptyFilterRegistry` seeds a registry from scratch.
  `FilterTypeRegistry` / `FilterWidgetKind` / `FilterWidgetRenderProps`
  are the types.
- **Header filters**: compose `headerFilters()` and each filterable column
  header gains a funnel that opens that column's field; composing it selects
  header mode on its own and hides the toolbar Filters button unless the
  AND/OR tree needs it (`resolveFilterMode` / `FilterChromeMode`). See
  [header filters](./header-filters.md). `FilterHeaderRow` /
  `FilterHeaderControl` over `FilterHeaderChrome` / `FilterHeaderControlChrome`
  are the building blocks for a custom header. Helpers `filterDefForColumn`
  (core) and `headerFilterStickTop` (`@adapttable/react`). A def whose bag key
  differs from the column key sets `column` (`key: "name"` under
  `column: "person"`). Checklist / multiSelect open a closed menu of
  checkboxes, not a native `<select multiple>`. The funnel overlay stays open
  while you fill a multi-input field; nested kit dropdowns are not treated as
  outside clicks. Pass `closeHeaderFilterOnSelect` to dismiss after a finished
  single-control write (`useHeaderFilterOverlay` /
  `bindHeaderFilterDismiss` / `headerFilterFieldIsComplete` /
  `usePointerDismiss` / `HeaderFilterSessionProps` /
  `HeaderFilterOpenProvider` / `HeaderFilterOpenContext` /
  `HeaderFilterOpenHost`).
- **Range widgets**: `useRangeFilterWidget` is the kit-agnostic logic behind
  `numberRange` / `dateRange` fields — it returns a `RangeWidgetState` whose
  `RangeFieldWidget` entries carry the visible bounds, the active
  `RangeOp`, and a `RangeOpArity` (`none` / `one` / `two` / `list`);
  `RANGE_SUFFIXES` names the persisted key pair per type (`Min` / `Max` for
  `numberRange`, `From` / `To` for `dateRange`),
  `RANGE_OPS` is the historical four-operator set (`eq` / `gte` / `lte` /
  `between`), and `RANGE_OP_LABEL_KEYS` / `RangeOpLabelKeys` map each
  operator to its `TableLabels` key. `writeRangeFilter` persists the pair
  plus `f_<key>Op`.
- **Definitions and state**: `filterStateKeys` lists the state keys a
  definition reads and writes; `scalarFilterText` renders a scalar filter
  value as input text; `listFilterValues` normalizes a multi-select value
  list; `isDeclarativeFilters` narrows a `filters(…)` argument to its array form;
  `FilterFormSource` is the minimal source shape a filter form needs;
  `ResolvedFilterOptions` is the loaded state of a `filter`'s options
  (including `options: "auto"`); `FilterRuntime` is everything the engine
  derives from the resolved definitions (defs, chip labels, URL keys,
  predicate).
- **Search** ([page](./search.md)): `defaultSearchText` is the default searchable-text projector —
  it flattens a row's own values into the string the search box matches
  against. Replace it per source with `getSearchText`:

  ```tsx
  const source = useFrontendData({
    data: people,
    columns,
    // search the full name and the city, and nothing else
    getSearchText: (row) => `${row.firstName} ${row.lastName} ${row.city}`,
  });
  ```

  It is one projector for the whole row, not a per-column setting: search
  asks "does this row match?", so the row is what gets projected. Include a
  computed value here to make it searchable, or leave a field out to exclude
  it — an id column nobody searches by, say.

## Notes

- `"auto"` needs the full dataset, so it only works on the frontend tier
  (`data` without `onQueryChange`). On the server/source tiers it dev-warns
  and resolves to no options — pass an array or an async loader instead.
- The `dateRange` upper bound is inclusive end-of-day: "On or before
  2026-01-31" keeps that day's rows.
- Relative date filters (`DATE_OPS` token `relative`) store a **token**
  (`today`, `yesterday`, `tomorrow`, `thisWeek`, `thisMonth`,
  `previousMonth`, `last:N`, `next:N`) in `${key}From` plus
  `f_<key>Op=relative`. The URL and Saved Views never hold a resolved
  calendar day — a shared "last 7 days" link stays the last 7 days
  tomorrow. `resolveRelativeRange` is the only resolver; the frontend
  predicate and a server query both call it so they agree. Weeks are ISO
  (Monday–Sunday, local time).
- `Equal` writes the same value to both range keys; clearing a field clears
  its key, so half-filled widgets never leak stale bounds.
- Async loaders run once (the promise is cached); until they resolve, chips
  label with the raw value. A failed load dev-warns and yields no options.
- Passing JSX to `filters(…)` switches off every derivation — your controls
  update table state themselves (live by default), and you supply
  `filterLabels` / `extraChips` / `activeFilterCount` for the chips and badge.
- Changing any filter resets the page to 1. `multiSelect` URL values are
  comma-separated with each entry encoded, so values containing commas
  round-trip safely. With `urlKey="left"`, params become `left.f_status`, ….

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/).

---

# React table advanced filters — AND/OR filter tree

▶ **Try it live:** [Filtering in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/filtering/)
— open Filters; Advanced is the first block in the popover. Switch the page
to Drawer, or to Header (column icons for one field, Filters for the tree).
[Other UI kits →](./getting-started.md#try-it-in-stackblitz)

The simple filter form is one control per field. When a reader needs
"(Team is Core **or** Platform) **and** Budget ≥ 20k", that is a tree:
nested AND/OR groups, one kit-native builder, one `ft=1.{…}` URL param.

It lives in the **same** Filters popover or drawer as the field list — at
the top, not under it. Header mode keeps a Filters button for the tree
because a column icon cannot express a group.

## Example

```tsx
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, shadcn, unstyled
import { filters } from "@adapttable/mantine/filters";

export function People({ rows }) {
  return (
    <DataTable
      data={rows}
      rowKey={(row) => row.id}
      columns={[
        { key: "name", filter: "text" },
        { key: "team", filter: { type: "select", options: "auto" } },
        { key: "budget", filter: "numberRange" },
      ]}
      features={[filters([])]}
      filtersMode="popover"
      urlKey="f"
    />
  );
}
```

Compose `filters(…)`: the `data` tier then evaluates the tree itself. A host
that builds its own source with `useFrontendData` passes `filterTreeFn`; a
server source declares `supports.filterTree`. Each adapter's
`FilterTreeBuilder` mounts at the top of the Filters form whenever the source
exposes `source.setFilterTree` (every built-in source does) and at least one
filter definition exists.

## How it works

- **Same chrome.** Popover and drawer are the field list's container. The
  builder is the first child of `data-adapttable-part="filters-form"`.
  Header icons filter one column; `toolbarShowsFilters` keeps the toolbar
  Filters button when the tree setter is on so Advanced is not trapped
  behind a column.
- **Model.** A `QueryFilterGroup` of `QueryCondition`s (`isFilterGroup`
  narrows a child). Combinators are `and` / `or`. Leaves name a field,
  an operator from that type's registry, and a value.
- **URL.** `ft=1.{…}` — versioned JSON. Unknown versions are dropped, never
  reinterpreted. Clear all drops `ft` with the flat `f_*` keys.
- **Chips.** Tree leaves become chips via `useFilterTreeChips`. Removing a
  chip rewrites that node; it does not flatten the group.
- **Tree only.** `filterFields={false}` removes the field list and opens the
  builder expanded.
- **Server.** A source that declares `supports.filterTree` receives the
  same tree on `query.filterTree`. The server must apply it all-or-nothing
  — dropping one condition out of an AND would lie.

The field widgets, operators and chips are on [filtering](./filtering.md).
The URL codec is on [url-state](./url-state.md).

---

# React table header filters — a funnel on every filtered column

▶ **See it working:** [Filtering in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/filtering/) — switch the layout to **Header**: each filtered column gets a funnel, and Filters keeps the AND/OR tree. The same page exists for every kit.

Header filters put each filter where its column is. Compose `headerFilters()`
from `@adapttable/<kit>/header-filters` and every column with a filter
definition shows a funnel button in its header cell. The funnel opens the same
kit-native field the Filters panel draws, bound to the same state, URL params
and chips. The filter definitions still come from `filters(…)`
([filtering](./filtering.md)); header filters only move where the fields open.

## Example

```tsx
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { filters } from "@adapttable/mantine/filters";
import { headerFilters } from "@adapttable/mantine/header-filters";

interface Project {
  id: string;
  name: string;
  owner: { name: string };
  status: "active" | "paused" | "done";
  budget: number;
  dueAt: string; // ISO date
}

const projects: Project[] = [
  {
    id: "1",
    name: "Billing rewrite",
    owner: { name: "Amira Haddad" },
    status: "active",
    budget: 42000,
    dueAt: "2026-11-30",
  },
  {
    id: "2",
    name: "Search index",
    owner: { name: "Jonas Weber" },
    status: "paused",
    budget: 18000,
    dueAt: "2026-10-15",
  },
  {
    id: "3",
    name: "Design tokens",
    owner: { name: "Priya Nair" },
    status: "done",
    budget: 9500,
    dueAt: "2026-08-01",
  },
  {
    id: "4",
    name: "Audit log",
    owner: { name: "Amira Haddad" },
    status: "active",
    budget: 27000,
    dueAt: "2027-01-20",
  },
];

export function Projects() {
  return (
    <DataTable
      data={projects}
      rowKey={(row) => row.id}
      columns={[
        { key: "name", filter: "text", sortable: true },
        { key: "owner", header: "Owner", accessor: (row) => row.owner.name },
        {
          key: "status",
          filter: {
            type: "select",
            options: [
              { value: "active", label: "Active" },
              { value: "paused", label: "Paused" },
              { value: "done", label: "Done" },
            ],
          },
        },
        { key: "budget", filter: "numberRange", sortable: true },
        { key: "dueAt", header: "Due", filter: "dateRange" },
      ]}
      features={[
        filters([
          // The state key is a row path; `column` puts the funnel on "owner".
          { key: "owner.name", column: "owner", type: "text", label: "Owner" },
        ]),
        headerFilters(),
      ]}
      closeHeaderFilterOnSelect
    />
  );
}
```

Both features are required. `filters([…])` turns the column `filter`
declarations and the standalone definitions into a filter runtime;
`headerFilters()` draws a funnel for each of them.

## How it works

- **Composing it selects header mode.** The table resolves one filter
  container through `resolveFilterMode(filtersMode, headerFilters)` in
  `@adapttable/core`: header mode wins whenever `headerFilters()` is composed,
  whatever `filtersMode` says, so the toolbar popover or drawer and the header
  funnels are never mounted for the same fields. `filtersMode="header"`
  without the feature resolves to header mode too, but nothing draws the
  funnels — compose `headerFilters()`.
- **One funnel per column.** A column shows a funnel when a definition
  matches it: `filterDefForColumn` picks the first definition whose
  `column ?? key` equals the column key. A column `filter` declaration matches
  its own column. A standalone definition whose state key differs from the
  column key sets `FilterDef.column` — `key: "owner.name"` under
  `column: "owner"` above. A definition that matches no column gets no funnel.
- **The same field as the panel.** The funnel's overlay renders the kit's
  auto-built form for that single definition: the operator-first text,
  number and date widgets, selects, multi-selects, the checklist, and any
  custom type's `render` ([custom filter types](./custom-filter-types.md)).
  Writes go to the same extra bag, so the URL, chips, the page reset and (on
  frontend data) the row predicate behave exactly as they do from the panel.
- **Active state.** A funnel whose definition has any non-empty state key
  carries `data-active` and the kit's active styling.
- **Staying open.** The overlay stays open while a field is being filled —
  picking an operator, typing a term, toggling multi-select options. A
  dropdown the kit portals out of the overlay (a select menu, a date picker)
  is not treated as an outside click. A true outside press or **Escape**
  closes it.
- **`closeHeaderFilterOnSelect`.** Off by default. When set, the overlay also
  closes after a finished single-control write: a `select` or `boolean`
  value, or a valueless operator such as "Is empty". Typed text, range bounds
  and multi-value lists never close it, because another control is still
  waiting. The check reads the type's `widget`, so a custom type drawn as a
  `select` closes the same way.

## The Filters button and the AND/OR tree

In header mode the per-field list leaves the Filters panel — the funnels own
those fields. The toolbar keeps its Filters button only for the
[AND/OR filter tree](./filter-tree.md), which has no column of its own
(`toolbarShowsFilters`). Every built-in source exposes
`source.setFilterTree`, so the button stays and opens the tree builder
expanded.

## Options

| Option / prop               | Type                                | Default            | Description                                                                                                     |
| --------------------------- | ----------------------------------- | ------------------ | --------------------------------------------------------------------------------------------------------------- |
| `headerFilters()`           | factory, no arguments               | off                | From `@adapttable/<kit>/header-filters`. A funnel per filtered column; selects header mode. Needs `filters(…)`. |
| `closeHeaderFilterOnSelect` | `boolean` (`DataTable` prop)        | `false`            | Close the overlay after a finished single-control write. Outside press and Escape always close it.              |
| `filtersMode`               | `"popover" \| "drawer" \| "header"` | `"popover"`        | Ignored for the fields while `headerFilters()` is composed; header mode wins.                                   |
| `FilterDef.column`          | `string`                            | `key`              | Column whose header carries this definition's funnel.                                                           |
| `labels.headerFilters`      | `string`                            | `"Column filters"` | Accessible name of the exported compact filter row (`FilterHeaderRow`).                                         |

## Per-kit differences

Every kit draws the funnel with its own button and popover; the field inside
is that kit's auto-built filter form.

- **Ant Design** renders its own table header, so the funnel lives inside the
  column `title`. That keeps `fixed` columns on antd's own header. The
  overlay is portalled to `document.body` and positioned against the funnel,
  following scroll and resize. The funnel stops its click from reaching the
  header, so opening it never toggles antd's sort. Open state is held above
  the header (`HeaderFilterOpenProvider`), because antd rebuilds column
  titles on every filter write.
- **The other kits** render the funnel inside the header cell next to the
  caption and the sort control, so it moves with pinned columns, sticky
  headers and column windowing.

For custom header markup, each kit also exports `FilterHeaderControl` (one
column's compact control) and `FilterHeaderRow` (a filter row for a custom
header) over `FilterHeaderControlChrome` / `FilterHeaderChrome`. The overlay
logic is headless in `@adapttable/react`: `useHeaderFilterOverlay`,
`bindHeaderFilterDismiss`, `headerFilterFieldIsComplete` and
`usePointerDismiss`.

## Notes

- **Desktop only.** Mobile cards have no column headers, so no funnel
  renders below the mobile breakpoint. Active filters still show as removable
  chips, and the Filters button still opens the AND/OR tree. A table that
  must offer per-field filters on phones uses `filtersMode="popover"` or
  `"drawer"` without `headerFilters()`.
- **URL state and saved views.** Header filters write the same `f_<key>`
  params as the panel — `f_name` and `f_nameOp`, `f_budgetMin` /
  `f_budgetMax`, `f_dueAtFrom` / `f_dueAtTo`, `f_owner.name` above — so a
  shared link or a [saved view](./saved-views.md) reopens with the same
  funnels active. See [URL state](./url-state.md).
- **Keyboard and screen readers.** The funnel is a real button, reached with
  **Tab** and named by the definition's label (`filterLabel`: the `label`,
  or the humanized key). **Escape** closes the overlay.
- **The standard preset.** `standardFeatures()` includes `headerFilters()`,
  so a table composed from the preset opens its filters from the headers.
  The preset composes `filters(…)` only when its `filters` option is given —
  pass `standardFeatures({ filters: [] })` when every filter is declared on a
  column. Compose the members individually for popover or drawer filters — see
  [feature composition](./features.md#the-standard-preset--one-import-for-a-good-table).

---

# React table custom filter types — your own widgets, operators and predicates

The seven built-in filter types (`FILTER_TYPES`) are entries in a registry,
not a switch statement. A custom type is one more entry: a `FilterTypeSpec`
names the widget, the operators, the row predicate, the chips and the AND/OR
tree projection, and every filter definition whose `type` names it gets the
full treatment — the form field, the header funnel, the `f_<key>` URL params,
removable chips and the frontend predicate. Register specs with
`filterTypes([…])` from `@adapttable/<kit>/filters`, or from a feature's
`setup(host)`.

## Example

A `tags` type for rows that carry an array, with "has any" and "has all"
operators:

```tsx
import { filterLabel, filterOpKey, getPath } from "@adapttable/core";
import { DataTable, type FilterTypeSpec } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { filters, filterTypes } from "@adapttable/mantine/filters";

interface Article {
  id: string;
  title: string;
  tags: string[];
}

const articles: Article[] = [
  { id: "1", title: "Server-side sorting", tags: ["react", "server"] },
  { id: "2", title: "Mobile card layouts", tags: ["react", "mobile"] },
  { id: "3", title: "Streaming row patches", tags: ["server", "realtime"] },
  { id: "4", title: "Keyboard navigation", tags: ["a11y", "react"] },
];

// A list from the URL arrives as an array; a tree condition's value is
// the text typed into the builder, so split it on commas.
function asList(value: unknown): string[] {
  if (Array.isArray(value)) return value.map(String);
  if (typeof value !== "string") return [];
  return value
    .split(",")
    .map((entry) => entry.trim())
    .filter(Boolean);
}

const tagsFilter: FilterTypeSpec = {
  type: "tags",
  widget: "multiSelect",
  ops: ["hasAny", "hasAll"],
  defaultOp: "hasAny",
  urlArray: true,
  stateKeys: (def) => [def.key, filterOpKey(def.key)],
  match: (def, extra, row) => {
    const wanted = asList(extra[def.key]);
    if (wanted.length === 0) return true;
    const tags = asList(
      def.getValue ? def.getValue(row) : getPath(row, def.key)
    );
    return extra[filterOpKey(def.key)] === "hasAll"
      ? wanted.every((tag) => tags.includes(tag))
      : wanted.some((tag) => tags.includes(tag));
  },
  chips: (def) => ({
    [def.key]: (value) => `${filterLabel(def)}: ${value}`,
  }),
  conditionToExtra: (def, condition) => ({
    [def.key]: asList(condition.value),
    [filterOpKey(def.key)]: condition.op,
  }),
};

export function Articles() {
  return (
    <DataTable
      data={articles}
      rowKey={(row) => row.id}
      columns={[
        { key: "title", filter: "text" },
        {
          key: "tags",
          accessor: (row) => row.tags.join(", "),
          filter: {
            type: "tags",
            options: [
              { value: "react", label: "React" },
              { value: "server", label: "Server" },
              { value: "mobile", label: "Mobile" },
              { value: "realtime", label: "Realtime" },
              { value: "a11y", label: "Accessibility" },
            ],
          },
        },
      ]}
      features={[filterTypes([tagsFilter]), filters([])]}
    />
  );
}
```

The field draws as the kit's multi-select, the selection writes
`f_tags=react,server`, each selected tag becomes a chip, and the AND/OR
builder offers `hasAny` / `hasAll` for the Tags field.

## How it works

- **A registry of specs.** The table resolves its registry from the built-ins
  (`builtInFilterSpecs`, collected in `defaultFilterRegistry`) plus every
  registered spec (`resolveFilterRegistry`). A spec whose `type` matches an
  existing one replaces it — a built-in included. Registries are immutable:
  each registration builds the next one.
- **Definitions name a type.** A column `filter: { type: "tags", … }` or a
  standalone `filters([{ key, type: "tags" }])` entry uses the spec. Column
  declarations still need `filters(…)` composed; `filterTypes(…)` only
  teaches the table new types. A definition whose type is not registered
  warns in development, draws no field and matches every row.
- **Everything else is looked up.** `filterWidgetKind`, `filterTypeOps`,
  `filterTypeDefaultOp` and `filterTypeSpec` read a spec for a definition;
  `renderRegisteredFilter` returns a spec's `render` output, or `undefined`
  when the kit widget should draw. Each kit's auto-built form and header
  funnel call it first.
- **State is a flat bag.** A type owns the keys `stateKeys(def)` returns.
  Widgets write those keys with `source.setExtra` / `setExtras`, Clear all
  resets them, and the header funnel shows as active while any of them holds
  a value.

## The `FilterTypeSpec` contract

| Member             | Type                                               | Required | Description                                                                                                                                                                                   |
| ------------------ | -------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `type`             | `string`                                           | yes      | The name a `FilterDef.type` references.                                                                                                                                                       |
| `widget`           | `FilterWidgetKind`                                 | yes      | Built-in widget the kit draws: `text`, `select`, `multiSelect`, `checklist`, `boolean`, `dateRange` or `numberRange`. Ignored for the form when `render` is set.                              |
| `ops`              | `readonly string[]`                                | yes      | Operators the AND/OR builder offers for this type, in menu order.                                                                                                                             |
| `defaultOp`        | `string`                                           | yes      | Operator a new tree condition starts with.                                                                                                                                                    |
| `stateKeys(def)`   | `(def) => string[]`                                | yes      | The extra-bag keys this type owns for one definition. Each persists as `f_<stateKey>`.                                                                                                        |
| `match`            | `(def, extra, row) => boolean`                     | yes      | Frontend predicate. Return `true` when the filter is inactive.                                                                                                                                |
| `chips(def)`       | `(def) => Record<string, ChipLabelResolver>`       | yes      | Chip text per state key: `(value, extra?) => string`. An array value yields one chip per entry.                                                                                               |
| `conditionToExtra` | `(def, condition: QueryCondition) => ExtraFilters` | yes      | Projects one tree condition (`{ key, op, value }`) onto the flat bag `match` reads, so the tree reuses the predicate.                                                                         |
| `render`           | `(props: FilterWidgetRenderProps) => DisplayValue` | no       | Draw the field yourself. Receives `def`, `source` (`extra`, `setExtra`, `setExtras`, `allFilteredRows`, `facets`), resolved `labels` and `className`. Used by the form and the header funnel. |
| `urlArray`         | `boolean`                                          | no       | Read and write `f_<key>` as a comma-separated array, each entry percent-encoded.                                                                                                              |
| `urlNumberKeys`    | `boolean`                                          | no       | Parse `f_<key>Min` and `f_<key>Max` as numbers.                                                                                                                                               |

`widget` also decides the parts the type does not draw itself: the value
input a tree condition shows, whether `closeHeaderFilterOnSelect` treats a
write as finished, and — for `widget: "checklist"` — whether the frontend
tier computes facet counts for the key.

## Operators

`ops` and `defaultOp` belong to the [AND/OR filter tree](./filter-tree.md):
they are the operator menu of a condition row, and the stored
`condition.op`. The built-in field widgets keep their own operator menus —
`TEXT_OPS` for text, `NUMBER_OPS` for numbers, `DATE_OPS` for dates — and
store the choice in `f_<key>Op` (`filterOpKey(key)`). An operator token with a
built-in label (`contains`, `gte`, `before`, …) shows that label in the
builder; any other token shows as written, so name custom tokens readably.

The operator a type evaluates lives wherever `match` reads it. The example
reads `f_tagsOp` and falls back to "has any"; `conditionToExtra` writes the
tree condition's `op` into that same key, which is how one predicate serves
both the field and the tree. `isValuelessFilterOp`, `isListFilterOp` and
`isBetweenFilterOp` classify the built-in tokens when a custom predicate
reuses them.

## Registering from a feature

`filterTypes(specs)` is a feature whose `setup` calls
`host.registerFilterType` once per spec. A plugin can do the same next to its
other registrations, and patch an existing type with
`host.extendFilterType(type, patch)` — the patch merges onto the current
spec, so a feature changes one member without forking the rest:

```tsx
import { filterLabel, getPath, RANGE_SUFFIXES } from "@adapttable/core";
import { type FilterTypeSpec } from "@adapttable/mantine";
import type { TableFeature } from "@adapttable/mantine/features";
import { SegmentedControl } from "@mantine/core";

const minKey = (key: string) => key + RANGE_SUFFIXES.numberRange.start;

const minRating: FilterTypeSpec = {
  type: "minRating",
  widget: "numberRange",
  ops: ["gte"],
  defaultOp: "gte",
  urlNumberKeys: true, // f_<key>Min parses as a number
  stateKeys: (def) => [minKey(def.key)],
  match: (def, extra, row) => {
    const min = extra[minKey(def.key)];
    if (typeof min !== "number") return true;
    const value = Number(
      def.getValue ? def.getValue(row) : getPath(row, def.key)
    );
    return Number.isFinite(value) && value >= min;
  },
  chips: (def) => ({
    [minKey(def.key)]: (value) => `${filterLabel(def)} ≥ ${value}`,
  }),
  conditionToExtra: (def, condition) => {
    const min = Number(condition.value);
    const set = condition.value != null && condition.value !== "";
    return { [minKey(def.key)]: set && Number.isFinite(min) ? min : undefined };
  },
  render: ({ def, source }) => (
    <SegmentedControl
      aria-label={filterLabel(def)}
      data={["Any", "3", "4", "5"]}
      value={String(source.extra[minKey(def.key)] ?? "Any")}
      onChange={(next) =>
        source.setExtra(
          minKey(def.key),
          next === "Any" ? undefined : Number(next)
        )
      }
    />
  ),
};

export const ratingFilters: TableFeature = {
  id: "rating-filters",
  setup(host) {
    host.registerFilterType(minRating);
    // New text conditions in the AND/OR builder start at "starts with".
    host.extendFilterType("text", { defaultOp: "startsWith" });
  },
};
```

`features={[ratingFilters, filters([])]}` with a column declaring
`filter: { type: "minRating" }` draws the segmented control in the Filters
panel and in the header funnel, persists `f_ratingMin=4`, and chips it as
"Rating ≥ 4".

Registrations apply in order, then the patches, so a feature can register a
type and extend it in the same `setup`. `extendFilterType` on an unknown type
warns in development and changes nothing. `setup` may return a cleanup
function, or call `host.onDispose`, for when the table unmounts or `features`
changes.

`filterTypes` has one feature id (`filter-types`), and a later feature with
the same id replaces an earlier one. Pass every spec to one
`filterTypes([…])` call.

## URL state and server queries

- **URL.** Each state key persists as `f_<stateKey>` — `f_tags`, `f_tagsOp`,
  `f_ratingMin` — under the table's `urlKey` namespace. Values read back as
  strings, except `f_<key>` with `urlArray` (an array) and
  `f_<key>Min` / `f_<key>Max` with `urlNumberKeys` (numbers).
  `RANGE_SUFFIXES` names the pairs the built-ins use: `Min` / `Max` for
  `numberRange`, `From` / `To` for `dateRange`. Empty values delete their
  param. A tree condition travels inside `ft=1.{…}` with its custom `op`.
  See [URL state](./url-state.md).
- **Saved views** capture those params like any other filter — see
  [saved views](./saved-views.md).
- **Server tier.** `match` runs only on frontend data. A server source
  receives the same flat bag: `query.filters` in `useServerData`'s
  `onQueryChange`, `params.filters` in `useQuerySource`, keyed by state key
  without the `f_` prefix (`{ tags: ["react", "server"], tagsOp: "hasAll" }`).
  A source that declares `supports.filterTree` also receives
  `query.filterTree`, whose conditions carry the custom `op` tokens. The
  backend implements the same semantics as `match`.
- **Backend parsing.** `parseTableQuery` from `@adapttable/server` returns
  `filters` keyed by the name after `f_` and rejects names outside the
  schema's `columns` allowlist — list every state key (`"tags"`,
  `"tagsOp"`). An array arrives as one comma-separated string; split and
  decode it (`value.split(",").map(decodeURIComponent)`). See
  [server queries](./server-queries.md).

## Notes

- **Header filters.** A custom type gets a funnel like any built-in once
  [header filters](./header-filters.md) are composed; a `render` spec draws
  the same control there.
- **Mobile.** On the card layout the Filters popover or drawer draws the
  same field — kit widget or `render` — as on desktop.
- **Accessibility.** A spec without `render` inherits the kit widget's
  labelling. A `render` owns its own: name the control from
  `filterLabel(def)`, as above, and use `labels` for any built-in strings.
- **Headless pieces.** `createFilterRegistry(specs)` and
  `emptyFilterRegistry()` build a registry without the built-ins for tests
  or custom forms; `FilterTypeRegistry` exposes `get`, `has` and `types`.
  The built-in widgets are available to a custom form as hooks —
  `useTextFilterWidget` and `useRangeFilterWidget` in `@adapttable/react`
  ([filtering](./filtering.md#headless-filter-primitives)).

---

# React table pagination — paged, infinite scroll & auto by device

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — numbered pagination is on by default in the starter; edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

Every table paginates out of the box. Choose between a classic paged footer,
infinite scroll, or `"auto"` (the default), which picks per device.

## Example

```tsx
import { DataTable } from "@adapttable/mantine"; // or @adapttable/mui, chakra, antd, radix, shadcn, unstyled

interface Person {
  id: string;
  name: string;
  city: string;
}

const data: Person[] = Array.from({ length: 120 }, (_, i) => ({
  id: String(i + 1),
  name: `Person ${i + 1}`,
  city: i % 2 === 0 ? "Dubai" : "Oslo",
}));

export function People() {
  return (
    <DataTable
      data={data}
      columns={[{ key: "name", sortable: true }, { key: "city" }]}
      rowKey={(r) => r.id}
      paginationMode="paged"
      defaults={{ limit: 10 }}
    />
  );
}
```

## How it works

- `paginationMode` accepts `"paged"`, `"infinite"`, or `"auto"` (the default).
  `"auto"` resolves by device: **infinite scroll on mobile, paged on desktop**,
  by the same rule as the card/table layout switch — the 768px media query, or
  `mobileBreakpoint` when you set it, and `forceMobile` over both.
- **Paged** renders a footer with a rows-per-page select, page buttons, and a
  "Showing X–Y of Z" summary.
- **Infinite** auto-loads the next page when a sentinel below the last row
  scrolls into view (`IntersectionObserver`, prefetching early), and also
  renders an explicit **Load more** button as a keyboard- and
  screen-reader-friendly fallback.
- Page and page size live in the URL (`?page=`, `?limit=`), so reloads and
  shared links restore the exact view. `defaults={{ limit }}` applies only
  while the URL is silent about a key. The rows-per-page list is 10 / 25 /
  50 / 100 (`PAGE_SIZE_OPTIONS`; `pageSizeOptions()` builds the list, both
  from `@adapttable/core`), plus that default when it isn't already in the
  list — so a table that starts at 500 still offers 500 after you pick 10.
- On the server tier, pagination state arrives in the consolidated
  `TableQuery` (`{ page, limit, … }`) passed to `onQueryChange` — forward both
  to your API and return `rows` + `total`; `total` drives the pager.

## Cursor pagination

Offset paging asks for "rows 100–125". If a row is inserted or deleted while
someone is reading, every later page shifts by one, and they see an entry twice
or never see it at all. Cursor pagination asks for "the 25 rows after _this_
one", which cannot drift.

Declare the capability and hand back the token your API returned:

```tsx
import { useState } from "react";
import { useServerData } from "@adapttable/react";
import { DataTable } from "@adapttable/mantine";

interface Row {
  id: string;
  name: string;
}

export function Rows() {
  const [data, setData] = useState<{
    rows: Row[];
    nextCursor: string | null;
  }>({ rows: [], nextCursor: null });

  const source = useServerData<Row>({
    rows: data.rows,
    total: 0, // cursor APIs usually have no count to give
    nextCursor: data.nextCursor,
    supports: { cursor: true },
    onQueryChange: async ({ cursor, limit, search }, { signal }) => {
      const params = new URLSearchParams({ limit: String(limit), search });
      if (cursor) params.set("cursor", cursor);
      const res = await fetch(`/api/rows?${params}`, { signal });
      setData(await res.json()); // { rows, nextCursor }
    },
  });

  return (
    <DataTable
      source={source}
      columns={[{ key: "name" }]}
      rowKey={(r) => r.id}
    />
  );
}
```

Return `nextCursor: null` when there are no more rows — that is what ends the
list, since there is no total to count against.

What the table guarantees:

- **It only ever sends a token your server issued.** The first page carries no
  `cursor` field at all, so the query is byte-for-byte the one an offset
  endpoint already receives.
- **Back works.** Tokens are kept as a trail, so paging back through what the
  user has already seen replays their own cursors.
- **A jump to an unvisited page does nothing.** Page 7 has no token, and
  sending none would silently re-serve page 1. This is the honest shape of
  cursor pagination rather than a limitation to route around — use offset
  paging (omit `supports.cursor`) where arbitrary jumps matter.
- **A trail that no longer means anything is thrown away.** Change the search,
  the sort, a filter or the page size and every held token points into a result
  that no longer exists, so the trail resets to page 1 rather than paging into
  the previous query's rows.

### With a query library

`useQuerySource` takes the same two options, so cursor mode is not tied to the
hand-rolled tier: declare the capability and say where the token lives on your
page.

```tsx
import { useInfiniteQuery } from "@tanstack/react-query";
import type { TableQueryParams } from "@adapttable/core";
import { useQuerySource } from "@adapttable/mantine";

interface Person {
  id: string;
  name: string;
}
type PeopleParams = TableQueryParams;
interface PeoplePage {
  items: Person[];
  next: string | null;
}

function usePeopleQuery(params: Partial<PeopleParams>) {
  return useInfiniteQuery({
    queryKey: ["people", params],
    queryFn: async (): Promise<PeoplePage> => {
      const search = new URLSearchParams();
      for (const [key, value] of Object.entries(params)) {
        if (value !== undefined) search.set(key, String(value));
      }
      return (await fetch(`/api/people?${search}`)).json();
    },
    initialPageParam: params.page ?? 1,
    getNextPageParam: () => undefined,
  });
}

export function usePeopleSource() {
  return useQuerySource<Person, PeopleParams, PeoplePage>({
    usePaginatedQuery: usePeopleQuery,
    selectPage: (page) => ({ rows: page.items, total: 0 }),
    supports: { cursor: true },
    nextCursor: (page) => page.next, // the token that opens the NEXT page
  });
}
```

The token reaches your query function as `params.cursor`, alongside the params
it already receives — nothing else about the hook changes.

- **A new sort, filter, search, or page size resets to the first page.** Those
  tokens describe a position in a result set that no longer exists.
- **The URL keeps everything except the cursor.** Sort, filters, search and
  page size stay shareable; an opaque token would be meaningless to whoever
  opened the link, and stale by the time they did.

## Options

| Prop               | Type                                   | Default                  | Description                                                                      |
| ------------------ | -------------------------------------- | ------------------------ | -------------------------------------------------------------------------------- |
| `paginationMode`   | `"paged" \| "infinite" \| "auto"`      | `"auto"`                 | Pagination behaviour; `"auto"` = infinite on mobile, paged on desktop.           |
| `defaults`         | `{ page?: number; limit?: number; … }` | `{ page: 1, limit: 25 }` | Initial page/page-size, used while the URL has no value.                         |
| `labels`           | `TableLabels`                          | English                  | Override `rowsPerPage`, `loadMore`, and the `showing` range builder.             |
| `skeletonRows`     | `number`                               | page size                | Number of skeleton rows shown while loading.                                     |
| `mobileBreakpoint` | `number`                               | `768`                    | Viewport breakpoint for cards and automatic infinite pagination.                 |
| `forceMobile`      | `boolean`                              | —                        | Pins cards (`true`) or the table (`false`), including automatic pagination mode. |

## Notes

- A URL `limit` outside **1–500** falls back to the default page size, and
  the rows-per-page control clamps to 1–500. `defaults.limit` is used as
  given.
- `paginationMode` applies to every tier. On the `onQueryChange` tier
  (`useServerData`) infinite mode appends each new page to the rows already
  shown.
- `nextCursor` is read only when the source declares `supports: { cursor: true }`.
  Without that declaration the field is ignored and no `cursor` is ever sent,
  so an endpoint written before cursors existed keeps its exact query.
- In infinite mode the table slices `page × limit` rows, so "page" really
  means "how much is loaded" — `fetchNextPage` just bumps it.
- The infinite-scroll sentinel is exported from `@adapttable/react` as a
  headless hook, `useInfiniteScroll`, for custom markup (`rootMargin`
  defaults to `"200px"`); it no-ops safely where
  `IntersectionObserver` is unavailable (SSR, tests).

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/).

---

# React table row selection & bulk actions — checkbox, keyboard, 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) — a real AdaptTable you can edit in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

Import `bulkActions` from `@adapttable/<kit>/bulk-actions` and compose it in
`features` — checkboxes on every row, a tri-state header checkbox, and a
selection toolbar with your action buttons. The import and `features` entry
are the switch; there is no `bulkActions` table prop. See
[feature composition](./features.md).

## Example

```tsx
// or import from "@adapttable/mui", "@adapttable/chakra", "@adapttable/antd",
// "@adapttable/radix", "@adapttable/shadcn", "@adapttable/unstyled" — same props everywhere.
import {
  type BulkAction,
  DataTable,
  type RowAction,
} from "@adapttable/mantine";
import { bulkActions } from "@adapttable/mantine/bulk-actions";
import { rowActions } from "@adapttable/mantine/row-actions";

interface Person {
  id: string;
  name: string;
  role: string;
  status: string;
}

const PEOPLE: Person[] = [
  { id: "1", name: "Ada Lovelace", role: "Engineer", status: "active" },
  { id: "2", name: "Alan Turing", role: "Founder", status: "active" },
  { id: "3", name: "Grace Hopper", role: "Admiral", status: "retired" },
];

// Per-row: trailing buttons on each desktop row / mobile card.
const rowActionDefs: RowAction<Person>[] = [
  { key: "edit", label: "Edit", onClick: (row) => console.log("edit", row.id) },
];

// Bulk: buttons in the selection toolbar, fired with the selected ids.
const bulkActionDefs: BulkAction[] = [
  {
    key: "archive",
    label: "Archive",
    onClick: (ids, { allMatching, total }) => {
      if (allMatching) console.log(`archive all ${total} matching rows`);
      else console.log("archive", ids);
    },
  },
  {
    key: "delete",
    label: "Delete",
    color: "red",
    confirm: {
      title: "Delete people",
      message: (count) => `Delete ${count} people? This cannot be undone.`,
      confirmLabel: "Delete",
      danger: true,
    },
    onClick: (ids) => console.log("delete", ids),
  },
];

export function PeopleTable() {
  return (
    <DataTable
      data={PEOPLE}
      columns={[
        { key: "name", sortable: true },
        { key: "role" },
        { key: "status" },
      ]}
      rowKey={(r) => r.id}
      features={[rowActions(rowActionDefs), bulkActions(bulkActionDefs)]}
      onSelectionChange={(ids) => console.log("selected", ids)}
    />
  );
}
```

## How it works

- `bulkActions(actions)` is the switch: composing it enables the checkbox
  column, the header tri-state (all / some / none of the visible rows), and
  the bulk bar that appears once at least one row is selected.
  `selectionStats()` and `columnSelectionCheckbox()` share the same selection
  state, so either one also adds the checkbox column; without `bulkActions`
  there is no bulk bar.
- **`RowAction` vs `BulkAction`**: a row action runs on one row
  (`onClick(row)`, `confirm.message(row)`); a bulk action runs on the
  selection (`onClick(ids, context)`, `confirm.message(count)`).
- **Select all on the page vs all N matching**: the header checkbox selects
  the visible page. When the whole page is selected and more rows match, a
  Gmail-style banner offers "Select all N matching"; accepting widens the
  scope and your action receives `BulkActionContext` —
  `{ allMatching: true, total }` — so you act on the whole filtered set
  server-side, not just the page `ids`. Any explicit toggle narrows the scope
  back to concrete ids. The banner appears when the table can name the whole
  matching set — client data, or a server source that reports `total`.
- **Confirmation sized by scope**: a bulk action's `confirm.message(count)`
  receives `context.total` when all-matching is active, the page ids count
  otherwise. The dialog goes through the table's `confirm` handler
  (`window.confirm` by default); `danger: true` marks it destructive.
- **Controlled or uncontrolled**: omit `selectedIds` and the table owns the
  selection (`onSelectionChange` is then an observer). Pass `selectedIds` and
  it becomes controlled — apply `onSelectionChange` requests to your state to
  accept them, the same split as `columnLayout`.
- Selection is keyed by id (`selectionGetId`, defaulting to `rowKey`), so it
  survives page, sort, and page-size changes — and resets automatically when
  the result _set_ changes (a new search term, different filter values, or a
  grouping change).

## Options

| Factory / prop                   | Type                       | Default          | Description                                                                                                                                                                                                                      |
| -------------------------------- | -------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bulkActions(actions)`           | `BulkAction[]`             | —                | Factory from `@adapttable/<kit>/bulk-actions`; composing it turns on row selection and mounts the bulk bar with these buttons.                                                                                                   |
| `rowActions(actions, handlers?)` | `RowAction<TRow>[]`        | —                | Factory from `@adapttable/<kit>/row-actions`; trailing per-row actions, independent of selection. `handlers` add Add / Duplicate / Delete — see [row actions](./row-actions.md).                                                 |
| `rowActionsLayout`               | `RowActionsLayout`         | `"buttons"`      | Omit or `"buttons"` for the strip; `"menu"` for a 3-dot menu.                                                                                                                                                                    |
| `renderRowActions`               | `RowActionsRenderer<TRow>` | —                | Replace the trailing actions cell; wins over `rowActionsLayout`.                                                                                                                                                                 |
| `selectedIds`                    | `readonly string[]`        | — (uncontrolled) | Controlled selection ids.                                                                                                                                                                                                        |
| `onSelectionChange`              | `(ids: string[]) => void`  | —                | Uncontrolled: observer for every change — once on mount with the (empty) initial set, per toggle/select-all, and on the automatic reset when search or a filter changes. Controlled: the change-request handler (no mount fire). |
| `selectionGetId`                 | `(row: TRow) => string`    | `rowKey`         | Selection id extractor when it must differ from the React key.                                                                                                                                                                   |
| `confirm`                        | `ConfirmHandler`           | `window.confirm` | Confirmation handler for actions with a `confirm` block; pass your own for a styled dialog.                                                                                                                                      |

## Notes

- `BulkAction.onClick(ids, context)` may return a promise: the button shows a
  loading state, other bulk buttons disable while it runs, and the selection
  clears after a successful run.
- `BulkAction.disabledReason(ids)` returns a non-empty string to grey the
  button out _and_ explain why (shown as its tooltip). Row actions have
  `disabledReason(row)`, `isDisabled(row)`, and `isHidden(row)`.
- Both action types take `icon` and `color`. A `RowAction` with
  `editsRow: true` opens row edit mode and carries no `onClick` (see
  [cell editing](./cell-editing.md#opening-the-row-from-your-own-pencil)).
- The per-action `confirm` block is
  `{ title, message, confirmLabel, danger? }` — all strings pre-translated.
  Your `confirm` handler receives the full `ConfirmRequest` (including
  `cancelLabel` and `onConfirm`).
- With [`tableAgent`](./agent-capabilities.md#row-and-bulk-actions)
  composed, each row and bulk action is also an agent capability
  (`rowAction.<key>`, `bulkAction.<key>`) under the table's approval policy;
  `ai: false` on an action keeps it away from the agent, and `ai.approval`
  overrides the policy for that action.
- The selection toolbar's strings (`selectedCount`, `selectAllMatching`,
  `allMatchingSelected`, …) are overridable via the `labels` prop.
- Headless consumers can reuse the same machinery: `useSelection` (with a
  `resetKey`) and `useBulkActionRunner` are exported from `@adapttable/react`;
  `runRowAction` from `@adapttable/core`.

See it live in the [selection demo](https://adapttable.orwamahmoud.com/react/demo/mantine/selection/).

---

# React table row actions — add, duplicate and delete rows

▶ **See it working:** [row actions in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/rows/) — a trailing actions column, collapsed into a 3-dot menu. The same pages exist for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

Import `rowActions` from `@adapttable/<kit>/row-actions`. It takes two
optional arguments: your own per-row actions, and the handlers that add,
duplicate and delete rows. A table that does not compose it carries none of
this — see [feature composition](./features.md).

```tsx
import { DataTable } from "@adapttable/mantine";
import { rowActions } from "@adapttable/mantine/row-actions";

function Tasks({ rows, setRows }: TasksProps) {
  return (
    <DataTable
      data={rows}
      columns={columns}
      rowKey={(row) => row.id}
      features={[
        rowActions(
          [{ key: "open", label: "Open", onClick: (row) => openTask(row.id) }],
          {
            onAddRow: () => setRows((current) => [blankTask(), ...current]),
            onDuplicateRow: (row) =>
              setRows((current) => [{ ...row, id: nextId() }, ...current]),
            onDeleteRow: (row) =>
              setRows((current) => current.filter((r) => r.id !== row.id)),
          }
        ),
      ]}
    />
  );
}
```

## Your own actions

Each `RowAction` is `{ key, label, onClick }`, plus optional `icon` (renders
icon-only, with `label` as the tooltip and accessible name), `color`,
`isDisabled` / `disabledReason` (a non-empty reason disables the action and
explains why), `isHidden`, a `confirm` dialog (`title`, `message(row)`,
`confirmLabel`, `danger`) and `editsRow`, which opens the row's
[row-mode form](./cell-editing.md) instead of writing anything itself.

They render as trailing buttons on desktop and card buttons on mobile, in an
actions column that the Columns menu can hide or pin to the end.
`rowActionsLayout="menu"` collapses them into a 3-dot menu drawn with each
kit's own Menu (`labels.rowActionsMenu` names the trigger);
`renderRowActions` replaces the cell entirely and wins over the layout.

## Add, duplicate and delete

The second argument is `RowMutationHandlers`:

| Handler            | What appears                                                      |
| ------------------ | ----------------------------------------------------------------- |
| `onAddRow`         | An **Add row** button in the toolbar (part `add-row`).            |
| `onDuplicateRow`   | An icon-only **Duplicate row** action on every row.               |
| `onDeleteRow`      | An icon-only **Delete row** action on every row, confirmed first. |
| `confirmDeleteRow` | `false` deletes without the confirmation dialog.                  |

Duplicate and Delete come after your own actions, so a delete stays last, under
the keys `DUPLICATE_ROW_ACTION_KEY` and `DELETE_ROW_ACTION_KEY`. The delete
dialog uses `labels.deleteRow` as its title and `labels.deleteRowConfirm` as
its question. `labels.addRow`, `labels.duplicateRow`, `labels.deleteRow` and
`labels.deleteRowConfirm` are translated in every bundled locale, and the
controls mirror in a right-to-left table.

**The table asks; you do the rest.** It stores no row and holds no draft. A row
you add arrives through the source like every other row — editable, filterable,
sortable, counted, grouped and virtualized from the moment it lands. What a copy
means (which fields carry over, what id it gets) is yours. For an in-memory
table, `insertRow`, `removeRow` and `applyRowPatches` from `@adapttable/core`
do the list work. Pair the handlers with [`editing()`](./cell-editing.md) and a
reader adds a blank row and fills it in place.

## Keyboard and screen readers

Every action is a real button in the kit's own component: Tab reaches it, Enter
and Space run it, and its label is the accessible name. With
[`rowPinning`](./row-pinning.md) composed, its pin actions join the same column.

## Headless

`useRowMutations` (`UseRowMutationsOptions` in, `RowMutationsState` out) builds
the state a kit renders: `canAdd`, `addRow`, and the Duplicate and Delete
actions, which `chrome.rowActions` already includes. See
[headless](./headless.md) and [building an adapter](./building-an-adapter.md).

---

# React table expandable rows — detail panels with keyboard support

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — a real AdaptTable you can edit in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [nested tables in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/nested-tables/) — open a row onto another table, not a blank panel. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

Render a detail panel under any row by composing `rowDetail(render)` from
`@adapttable/<kit>/row-detail`. To put a whole table in the panel instead, the
same subpath exports `nestedTable(...)` — see
[nested tables](./nested-tables.md).

## Example

```tsx
import { DataTable } from "@adapttable/mantine"; // or @adapttable/mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { rowDetail } from "@adapttable/mantine/row-detail";

interface Order {
  id: string;
  customer: string;
  total: number;
  items: string[];
}

const data: Order[] = [
  { id: "1", customer: "Aisha", total: 240, items: ["Desk", "Lamp"] },
  { id: "2", customer: "Jonas", total: 90, items: ["Chair"] },
  { id: "3", customer: "Mei", total: 410, items: ["Monitor", "Dock", "Cable"] },
];

export function Orders() {
  return (
    <DataTable
      data={data}
      columns={[{ key: "customer", sortable: true }, { key: "total" }]}
      rowKey={(r) => r.id}
      features={[
        rowDetail((row: Order) => (
          <ul>
            {row.items.map((item) => (
              <li key={item}>{item}</li>
            ))}
          </ul>
        )),
      ]}
    />
  );
}
```

## How it works

- Composing `rowDetail(render)` adds a leading expand chevron on desktop
  rows and a detail section on mobile cards — no extra flag. The second
  argument, `defaultExpandedRowIds`, opens those rows on the first render; after
  that the reader's toggles own the set.
- **Multiple rows may be open at once.** Expansion is keyed by row id, so an
  open panel survives sorting and paging: a row that leaves the page simply
  re-opens when it returns.
- The chevron carries `aria-expanded` plus the `expandRow` / `collapseRow`
  labels (defaults "Expand row" / "Collapse row"; the i18n presets translate
  them).
- Ant Design maps the contract onto its **native** `expandable` API
  (`expandedRowKeys` / `expandedRowRender`), with a custom expand icon so the
  `labels.expandRow` / `labels.collapseRow` contract still applies instead of
  antd's ConfigProvider locale.
- The state lives in a headless hook, `useRowExpansion()` — use it for custom
  markup: `{ expandedIds, isExpanded(id), toggle(id) }`.

## Options

`rowDetail(renderRowDetail, defaultExpandedRowIds?)`:

| Argument                | Type                     | Default | Description                                                 |
| ----------------------- | ------------------------ | ------- | ----------------------------------------------------------- |
| `renderRowDetail`       | `(row: TRow) => unknown` | —       | Detail-panel renderer.                                      |
| `defaultExpandedRowIds` | `readonly string[]`      | —       | Row ids whose panel starts open. Later toggles own the set. |

Related `DataTable` props:

| Prop     | Type                    | Default | Description                                                              |
| -------- | ----------------------- | ------- | ------------------------------------------------------------------------ |
| `labels` | `TableLabels`           | English | Override `expandRow` / `collapseRow` for the chevron's accessible label. |
| `rowKey` | `(row: TRow) => string` | —       | Already required; expansion state is keyed by this id.                   |

## Notes

- Works with the `virtualize()` feature. A table cannot nest a detail panel inside the row it
  belongs to, so the two are separate elements — the window measures them
  **together**, and an open panel reports its real height instead of its row's.
  A panel that grows later (an image loading, a nested table opening) corrects
  the height as it happens.
- The detail cell spans the full row width, including selection, actions, and
  the expansion column itself.
- Kits with native expand affordances keep their own look; only the labels
  are unified through `labels`.

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/).

---

# React nested tables — master/detail with nestedTable()

▶ **See it working:** [nested tables in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/nested-tables/) — open a person onto their recent orders; the inner table has its own columns and row keys. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

A nested table is a whole `DataTable` inside an expanded row: the kit's own
component, with its own columns, row type, sorting, paging and keyboard
support. It is opt-in — compose `nestedTable(nested)` from
`@adapttable/<kit>/nested-table`. Without it, rows carry no expand control
and nothing renders beneath them.

The factory ships for `@adapttable/mantine`, `mui`, `chakra`, `antd`,
`radix`, `base-ui`, `shadcn` and `unstyled`, and
`@adapttable/<kit>/row-detail` exports the same factory beside `rowDetail`.

## Example

```tsx
import { type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { nestedTable } from "@adapttable/mantine/nested-table";

interface Order {
  id: string;
  item: string;
  total: number;
}

interface Customer {
  id: string;
  name: string;
  city: string;
  orders: Order[];
}

const customers: Customer[] = [
  {
    id: "c1",
    name: "Aisha Rahman",
    city: "Cairo",
    orders: [
      { id: "o1", item: "Desk", total: 240 },
      { id: "o2", item: "Lamp", total: 45 },
    ],
  },
  {
    id: "c2",
    name: "Jonas Weber",
    city: "Berlin",
    orders: [{ id: "o3", item: "Chair", total: 90 }],
  },
];

const customerColumns: ColumnDef<Customer>[] = [
  { key: "name", sortable: true },
  { key: "city", sortable: true },
];

const orderColumns: ColumnDef<Order>[] = [
  { key: "item", sortable: true },
  { key: "total", sortable: true },
];

export function Customers() {
  return (
    <DataTable
      data={customers}
      columns={customerColumns}
      rowKey={(row) => row.id}
      features={[
        nestedTable(
          (row: Customer) => ({
            label: `Orders for ${row.name}`,
            table: (defaults) => (
              <DataTable
                {...defaults}
                data={row.orders}
                columns={orderColumns}
                rowKey={(order) => order.id}
              />
            ),
          }),
          ["c1"]
        ),
      ]}
    />
  );
}
```

The first customer opens on the first render; every other row starts
closed. Annotate the callback's row parameter (or call
`nestedTable<Customer>(…)`): the factory cannot infer the parent row type
from the surrounding `features` array.

## How it works

- **`NestedTableFor<TRow>`** is the callback: `(row) => NestedTable |
undefined`. It runs for an expanded row and returns that row's nested
  table, or `undefined` for none.
- **`NestedTable`** is `{ label?, table }`. `table(defaults)` mounts the
  kit's own `DataTable` and returns it. The child rows keep their own type
  — the closure holds it — so `orderColumns` is a `ColumnDef<Order>[]`, not
  something erased to fit the parent.
- **The defaults come first.** Spread `defaults` onto the inner table, then
  pass anything that should differ after it. See
  [the defaults](#the-defaults-nestedtabledefaults).
- **The inner table is independent.** It takes its own `features`,
  `columns`, `rowKey` and data tier. Nothing the parent composes — grouping,
  selection, editing — reaches it unless you pass it again.
- **Expansion is keyed by row id**, so an open row survives sorting and
  paging. Several rows may be open at once. The second argument,
  `defaultExpandedRowIds`, is uncontrolled initial state; after the first
  render the reader's toggles own the set.
- **Every row carries the expand chevron.** A row whose callback returns
  `undefined` opens an empty panel unless `rowDetail` is composed as its
  fallback (below).

## The defaults: `NestedTableDefaults`

`table` receives a `NestedTableDefaults` — the props a table inside a row
needs:

| Field        | Value                                  | Why                                                                                              |
| ------------ | -------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `urlSync`    | `false`                                | Two tables writing `?page=` to one URL overwrite each other.                                     |
| `searchable` | `false`                                | A second search box inside a row reads as chrome. Pass `searchable` after the spread to keep it. |
| `density`    | the parent's `density` prop            | The child matches the parent.                                                                    |
| `labels`     | the parent's `labels` prop             | The child is localized like the parent.                                                          |
| `tableLabel` | `label`, or `"Row details"` if omitted | The inner table's accessible name — the same string names the region around it.                  |

`"Row details"` is a fixed English fallback, not a translated label. Give
every nested table a `label`, named after the row it belongs to ("Orders
for Aisha Rahman") rather than after the feature — it is what a
screen-reader user hears once focus is inside it.

`nestedTableDefaults(label, parent?)` on `@adapttable/react/adapter`
builds the same object, for a custom adapter or a host rendering its own
detail row. `nestedTableDetail({ nestedTable, renderRowDetail?, parent? })`
on the same entry turns a declaration into the detail renderer the
factories use.

## Rows without a nested table: `rowDetail` fallback

Compose `rowDetail(render)` beside `nestedTable` and each row resolves in
order: the nested table when the callback returns one, otherwise the
`rowDetail` panel, otherwise nothing. Master/detail panels and nested
tables can share one table:

```tsx
import { nestedTable } from "@adapttable/mantine/nested-table";
import { rowDetail } from "@adapttable/mantine/row-detail";

features={[
  nestedTable((row: Customer) =>
    row.orders.length === 0
      ? undefined
      : {
          label: `Orders for ${row.name}`,
          table: (defaults) => (
            <DataTable
              {...defaults}
              data={row.orders}
              columns={orderColumns}
              rowKey={(order) => order.id}
            />
          ),
        }
  ),
  rowDetail((row: Customer) => <p>{row.name} has no orders yet.</p>),
]}
```

Both factories share one expansion state. `defaultExpandedRowIds` may be
passed to either; when both pass one, the later feature in the array wins.
`rowDetail` on its own is covered in [row expansion](./row-expansion.md).

## Keyboard and screen readers

- The chevron is a real button with `aria-expanded` and the
  `expandRow` / `collapseRow` labels ("Expand row" / "Collapse row" by
  default, translated by the `@adapttable/i18n` locales). Enter and Space
  toggle it.
- The nested table renders inside
  `<section data-adapttable-part="nested-table" aria-label={label}>`, and
  the inner table carries the same name through `tableLabel`, so the region
  and the table both announce which row they belong to.
- The inner table keeps its own tab stops, and its own keyboard features —
  `cellNavigation()` included — only when you compose them on it.
- Ant Design maps expansion onto its native `expandable` API with a custom
  expand icon, so the same labels apply there.

## Virtualization

With `virtualize()` composed on the parent, an open row and its panel are
separate elements — a `<tr>` cannot contain the row beneath it — and the
window measures them together, reporting the open panel's real height. A
nested table that grows after it opens corrects the height as it happens.
See [virtualization](./virtualization.md).

## Mobile cards

On a phone the parent renders cards, and each card carries the same
chevron and opens its panel inside the card. The inner table is a
`DataTable` too, so it follows the same responsive switch and renders cards
on a phone. See [mobile cards](./mobile.md).

## URL state and saved views

The inner table never writes the URL (`urlSync: false`), and its sort,
page and filters are local to that mount. Which rows are expanded is not
URL state either: `defaultExpandedRowIds` sets the first render, and the
reader's toggles live in memory. The parent keeps its own URL state and
Saved Views as usual.

## Options

`nestedTable(nested, defaultExpandedRowIds?)`:

| Argument                | Type                   | Default | Description                                                   |
| ----------------------- | ---------------------- | ------- | ------------------------------------------------------------- |
| `nested`                | `NestedTableFor<TRow>` | —       | `(row) => NestedTable \| undefined` — the row's nested table. |
| `defaultExpandedRowIds` | `readonly string[]`    | —       | Row ids that start open. Later toggles own the set.           |

`NestedTable`:

| Field   | Type                                           | Default         | Description                                                    |
| ------- | ---------------------------------------------- | --------------- | -------------------------------------------------------------- |
| `label` | `string`                                       | `"Row details"` | Accessible name for the nested table and its region.           |
| `table` | `(defaults: NestedTableDefaults) => ReactNode` | —               | Mount the kit's `DataTable` with `{...defaults}` spread first. |

The types `NestedTable`, `NestedTableDefaults` and `NestedTableFor` are
exported from `@adapttable/react` and `@adapttable/react/features`.

Related: [tree data](./tree-data.md) for one row type in a hierarchy ·
[row expansion](./row-expansion.md) for a free-form panel ·
[feature composition](./features.md)

---

# React table inline cell editing — edit rows in place, validate & commit

▶ **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` (`editable` columns + `editing()` + `editHistory()` for undo and redo); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [edit cells in the live demo](https://adapttable.orwamahmoud.com/react/demo/mantine/editing/) — a real table you can type into, not a recording.

Edit a cell in place by composing `editing` from `@adapttable/<kit>/editing`
and marking columns `editable`. Omit the factory and the table never opens an
editor — even if columns declare `editable`. The table never mutates rows; your
handler applies the change. See [feature composition](./features.md).

## Example

```tsx
import { useState } from "react";
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { editing } from "@adapttable/mantine/editing";

interface Person {
  id: string;
  name: string;
  role: string;
  status: "Active" | "Planned" | "Blocked";
}

const SEED: Person[] = [
  { id: "1", name: "Aisha", role: "Engineer", status: "Active" },
  { id: "2", name: "Jonas", role: "Designer", status: "Planned" },
];

export function People() {
  const [rows, setRows] = useState(SEED);
  return (
    <DataTable
      data={rows}
      columns={[
        { key: "name", sortable: true, editable: true },
        {
          key: "status",
          editable: true,
          editor: {
            type: "select",
            options: ["Active", "Planned", "Blocked"],
          },
        },
        { key: "role", editable: (row) => row.status !== "Blocked" },
      ]}
      rowKey={(r) => r.id}
      features={[
        editing((row, key, nextValue) => {
          setRows((prev) =>
            prev.map((r) =>
              r.id === row.id ? { ...r, [key]: nextValue as never } : r
            )
          );
        }),
      ]}
    />
  );
}
```

## How it works

- **Opt-in.** `editing()` is the switch. Without it, cells stay plain display
  (package DNA: nothing is pushed on the developer).
- **Per-column.** `editable` is `true`, `false`, or `(row) => boolean`. The
  editor defaults to `"text"`; use `"number"` or
  `{ type: "select", options }` for the other kits.
- **Keyboard.** Double-click / Enter / F2 begins; Enter commits; Escape
  cancels and restores focus; Tab / Shift+Tab commits and advances to the
  next editable cell.
- **One click.** `editing(handler, { editActivation: "click" })` opens a
  cell's editor on the first click, the way a form field does — right for a
  table people mostly edit. The default, `"doubleClick"`, keeps a single click
  for selecting the cell. Enter and F2 open the editor either way.
- **One-way data flow.** The commit payload is
  `(row, key, nextValue)` on the handler you pass to `editing()` — adapters
  render kit-native inputs; core owns the state machine so every kit behaves
  the same.
- **Beyond one cell.** Row-level edit mode (`rowEditing(onRowEdit)`) opens
  every field together and commits one patch; `validate` / `validateRow` reject
  a commit and mark the cells that failed; `onEditRollback` restores a row after
  a rejected save, and `dirtyIndicators()` marks what nobody has confirmed yet.
- **The table never writes to a row.** Persistence stays with the host: every
  commit reaches your handler, and the stored data changes only when you change
  it.

## Options

| Prop / field     | Type                                                    | Default         | Description                                                                                              |
| ---------------- | ------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------------------------------- |
| `editing(fn)`    | `(row: TRow, key: string, nextValue: unknown) => void`  | —               | First argument to `editing()`; its presence enables editing.                                             |
| `editable`       | `boolean \| ((row: TRow) => boolean)`                   | —               | Whether this column can open an editor (still requires `editing()`).                                     |
| `editor`         | `CellEditor` — see [the editor set](#the-editor-set)    | `"text"`        | Widget for the active cell: text, number, boolean, date, datetime, time, select, multi-select or custom. |
| `editValue`      | `(row: TRow) => string`                                 | —               | Draft seed when the displayed cell is formatted but editing needs the raw.                               |
| `parseValue`     | `(draft: string, row: TRow) => unknown`                 | —               | Turns the edited text into the value committed to your edit handler.                                     |
| `labels`         | `TableLabels`                                           | English         | Override `editCell` for the activate control's accessible name.                                          |
| `editActivation` | `"doubleClick" \| "click"`                              | `"doubleClick"` | The pointer gesture that opens a cell's editor; Enter and F2 open it either way.                         |
| `validate`       | `(value, row) => string \| undefined \| Promise<…>`     | —               | Column rule: return a message to reject the commit (see below).                                          |
| `validateRow`    | `(row) => string \| Record<string,string> \| undefined` | —               | Table rule over the row the edit would produce (cross-field).                                            |
| `applyEdit`      | `(row, columnKey, value) => TRow`                       | spread          | How an edit lands on a row for `validateRow` to judge.                                                   |

### Lifecycle events

`onEditStart`, `onEditCancel`, `onEditCommit`, `onValidationFail` and
`onEditError` observe what the editor does. They cannot change it: a throw is
swallowed, a return value is ignored. The same events fire for a cell, a row
and a batch, and on a mobile card — the commit unit does not change with the
layout.

```tsx
<DataTable
  {...props}
  features={[
    editing(save, {
      onEditStart: (e) => analytics.track("edit_start", e.columnKey),
      onEditCommit: (e) => analytics.track("edit_commit", e.value),
      onEditCancel: (e) => analytics.track("edit_cancel", e.columnKey),
      onValidationFail: (e) => toast.error(e.error),
      onEditError: (e) => toast.error(e.error),
    }),
  ]}
/>
```

The payload is `EditEvent<TRow>`: `row`, `rowId`, `columnKey` (empty for a row
or batch), `value`, `previousValue`, `unit` (`"cell"` / `"row"` / `"batch"`)
and optional `error`. For `onEditError`, `value` is the attempted save and
`previousValue` is the cell's previous value. Omit every handler and nothing
is called.

### Live-update conflicts

A refetch, a websocket, another user — the row under an open editor can change.
(Live row updates that are not an open-editor fight are
[realtime](./realtime.md).)
The table does not merge. It can keep what you typed, take the incoming value,
or ask. Silently discarding a draft is the one outcome nobody forgives, so the
default is to ask.

```tsx
<DataTable
  {...props}
  features={[
    editing(save, {
      editConflictPolicy: "ask",
      rowVersion: (row) => row.updatedAt,
      onEditConflict: (conflict) => {
        analytics.track("edit_conflict", conflict.columnKey);
      },
    }),
  ]}
/>
```

`onEditConflict` may return `"keep"` or `"take"` to resolve it; returning
nothing uses the policy. `"ask"` puts Keep mine / Take theirs on the editor
(`data-conflict`, the same `aria-describedby` channel as a validation message)
and shows the incoming value (`labels.theirsValue`) so the reader can see
what they would take.
Without `rowVersion`, only the edited column's stored value counts as a change.

### The editor set

| `editor`                            | Control                | Commits                         |
| ----------------------------------- | ---------------------- | ------------------------------- |
| `"text"` (default)                  | text input             | the string                      |
| `"number"`                          | number input           | `number \| null` (empty → null) |
| `"boolean"`                         | checkbox               | `true` / `false`                |
| `"date"`                            | date input             | `"YYYY-MM-DD"`                  |
| `"datetime"`                        | datetime input         | `"YYYY-MM-DDTHH:mm"`            |
| `"time"`                            | time input             | `"HH:mm"`                       |
| `{ type: "select", options }`       | the kit's select       | the chosen value                |
| `{ type: "multi-select", options }` | the kit's multi-select | the chosen values, an array     |

The date, time and checkbox editors use the platform's own controls: they are
keyboard-complete and localized by the browser, which is a better answer than
nine hand-built pickers.

**Dates commit strings, not `Date` objects.** `"2026-08-13"` is a day; a `Date`
is an instant in a timezone the reader never chose, and converting to one moves
the day for most of the world. A column storing a `Date` still seeds its editor
correctly — the local parts are read off it — and `parseValue` turns the committed
string back into whatever you store.

**A boolean commits on the tick.** A checkbox has one gesture, so waiting for
Enter would leave a ticked box that changed nothing.

**A multi-select seeds itself from a stored array** and commits an array back, so
a host stores exactly what it gave — no separator to parse, no single-value
special case. An empty selection commits `[]`, not `""`.

Each kit picks several values the way that kit picks several values: Ant Design
and Mantine open their own multi-select, MUI and Chakra render a multiple list
box, and Radix Themes and Base UI — whose select holds one value by
construction — show a group of their own checkboxes. That group is
`MultiSelectEditorChrome`, core's structure with a `Checkbox` slot, so all three
kits share one accessible group and none of them ships hand-written markup.

### Bring your own editor

An autocomplete, a rich-text field, a colour picker — anything that is not one
of the eight. The table keeps everything it already owned; the component owns
only what the reader looks at:

```tsx
{
  key: "colour",
  editable: true,
  editor: {
    type: "custom",
    render: (ctrl) => (
      <SwatchPicker
        value={ctrl.draft}
        onKeyDown={ctrl.onKeyDown}
        firstSwatchRef={ctrl.focusRef}
        onPick={(swatch) => {
          ctrl.setDraft(swatch);
          ctrl.commit();
        }}
      />
    ),
  },
}
```

Double-click / Enter / F2 still activates the cell, focus still returns to it
afterwards, Enter still commits, Escape still cancels, Tab still moves on, and
validators still gate the commit — none of that is yours to write again. What
arrives is `draft` and the calls that change it: `setDraft`, `commit` (for a
picker, where choosing IS the gesture), `cancel`, `onKeyDown` (wire it to keep
the keyboard flow; an editor that owns Enter simply does not call it for Enter),
`onBlur`, and `focusRef` to point at what should take focus. `error`,
`validating` and `errorId` are there so a component can mark itself invalid —
the table renders the message either way. `label` is the accessible name for
the control, and `conflict` is set while a live-update conflict is open on the
cell.

The draft is a string, as it is for every editor; `parseValue` is still how a
column turns it into whatever gets stored.

### Display, draft, and committed value

Three moments in a cell's life, and a formatted column needs a different value
at each:

| Moment    | Field        | For a currency column |
| --------- | ------------ | --------------------- |
| Displayed | `accessor`   | `"$1,240.00"`         |
| Edited    | `editValue`  | `"1240"`              |
| Committed | `parseValue` | `1240`                |

```tsx
{
  key: "budget",
  editable: true,
  editor: "number",
  accessor: (row) => money.format(row.budget),
  editValue: (row) => String(row.budget),
  parseValue: (draft) => Number(draft.replace(/[^0-9.-]/g, "")),
}
```

`parseValue` receives the draft exactly as typed plus the row, and replaces the
editor's own parsing rather than running after it — so a column that says how
to read its drafts is in full control. Return anything your edit handler should
receive, including a value no built-in editor produces, such as a `Date`.

Without it nothing changes: a `number` editor commits `number | null` and every
other editor commits the raw string.

## Validation

A validator gates the commit and nothing else. It never rejects a keystroke,
never rewrites a value, and never decides what to save — the host still owns
persistence, and will usually validate again on the server.

Two levels, because they answer different questions. A **column** rule knows one
value:

```tsx
const columns: ColumnDef<Task>[] = [
  {
    key: "title",
    editable: true,
    validate: (value) =>
      String(value).trim() === "" ? "A title is required" : undefined,
  },
];
```

A **table** rule sees the row the edit would produce, and answers what no single
cell can:

```tsx
<DataTable
  {...props}
  features={[
    editing(apply, {
      validateRow: (task) =>
        task.end <= task.start
          ? "The end must come after the start"
          : undefined,
    }),
  ]}
/>
```

Return a string for a row-level problem, or a map of column key → message to
mark individual cells — which is how a cross-field rule points at the field the
reader should look at. Return nothing to allow the commit. When a column key is
not the field (a nested path), pass `applyEdit` so the rule sees the right row.

**A rejected value never reaches your edit handler.** The editor stays open holding
what the reader typed, and the message is announced rather than only painted.
Where it renders depends on the kit: Mantine and MUI put it in their own input's
error slot (their components own the field's `aria-describedby`), and every other
kit renders `data-adapttable-part="edit-cell-error"` with `role="alert"`, the
editor carrying `aria-invalid` and `aria-describedby` pointing at it. Either way
the message exists once in the DOM and is read with the field. Escape clears it
with the draft.

Both levels may be async — "is this SKU real" is a request. The editor stays open
and carries `aria-busy` while the check runs, and a newer draft supersedes an
older check, so a stale answer can never mark a value the reader has already
changed. A column with no validator commits synchronously, exactly as before.

## Saving, and what happens when it fails

Return a promise from your `editing()` handler and the table knows something the reader
cannot see: the value is on its way somewhere. The cell says so until the promise
settles — `data-save="saving"` and `aria-busy` on the cell — and says why if it
rejects, in a live region beside it (`data-adapttable-part="edit-cell-save-error"`,
`role="alert"`).

```tsx
<DataTable
  {...props}
  features={[
    editing(
      async (row, key, value) => {
        setRows(applyOptimistically(row, key, value));
        await api.save(row.id, { [key]: value });
      },
      {
        onEditRollback: (previous, columnKey) => {
          setRows((current) => restore(current, previous));
        },
        formatEditError: (error) => humanize(error),
      }
    ),
  ]}
/>
```

A table that shows the new value before the server has agreed has to put the old
one back when it disagrees — and only the host can write to its own rows, so
`onEditRollback` receives the row as it was and restores it. The failed cell then
offers an **Undo** (`labels.undoEdit`, localized in every bundled locale), and
pressing it calls that handler. Omit `onEditRollback` and the message shows
without an undo, which is right for a table that refetches instead.

A newer save supersedes an older one, so a slow rejection can never mark a value
the reader has already replaced. A host that saves synchronously pays nothing:
with nothing to wait for, there is no saving state to render.

Headless: `useCellSaveState` (`CellSaveState`, `CellSaveStatus`,
`FailedCellSave`, `UseCellSaveStateOptions`); the controller carries
`saveStatus`, `saveFailure`, `canRollback`, `rollback` and `dismissFailure`.

## Editing a whole row at once

Cell editing commits each field as the reader leaves it, which is right for a
spreadsheet and wrong for a form. A row whose fields constrain each other cannot
be edited one cell at a time without passing through states that are invalid on
the way — a start date after the end date it is about to replace. Row mode holds
every field's draft until the reader saves, then hands you one patch:

```tsx
import { rowEditing } from "@adapttable/mantine/editing";

<DataTable
  {...props}
  features={[
    rowEditing((row, patch) => {
      // patch === { title: "Ship it", points: 8 } — only what changed
      setRows((current) => applyPatch(current, row.id, patch));
    }),
  ]}
/>;
```

Each row grows an **Edit** control; opening one turns every editable column of
that row into its editor at once, seeded from the row. **Save** hands over one
patch of only the fields that actually changed — an untouched row reports
nothing at all — and **Cancel** throws the drafts away. Enter and Escape do the
same from any field, because in row mode the unit is the row.

Only one row is open at a time: opening another closes the first. The same
editors, the same `parseValue`, the same column-level `editable` predicate. The
mobile card behaves identically — the fields open in the card and the three
controls sit in its action area.

Both are one factory: `rowEditing(onRowEdit)` arms the mode and wires the patch
channel. `editing()` is not required — a table that only wants row-level commits
leaves it out, and its cells stay display-only until a row is opened.

### Opening the row from your own pencil

A table that already has a pencil in its actions column does not want a second
control beside it saying the same thing. Mark that action `editsRow` and it
becomes the way in — the built-in **Edit** control stands down, and save and
cancel still come from the open row:

```tsx
import { DataTable } from "@adapttable/mantine";
import { rowEditing } from "@adapttable/mantine/editing";
import { rowActions } from "@adapttable/mantine/row-actions";

const actions = [
  { key: "edit", label: "Edit", icon: <PencilIcon />, editsRow: true },
  { key: "delete", label: "Delete", onClick: remove },
];

<DataTable {...props} features={[rowActions(actions), rowEditing(save)]} />;
```

Such an action writes nothing itself, so it carries no `onClick`, and it renders
only where there is a form to open: a table without `rowEditing` drops it rather
than showing a control that cannot do what it says. While its own row is open it
steps aside, because that row is already showing save and cancel.

### An incoming row while a form is open

A row form is measured against the row it opened on, so a change arriving
underneath is the same question a cell already asks — asked of every field that
moved, and of no others. Each marks itself, shows the value that arrived, and
offers **Keep mine** / **Take theirs**, exactly as a cell does; answering on any
of them answers for the row, because the row moved as a whole. The form offers
nothing to save until it is answered.

Fields the reader never touched are not a problem to solve: a save sends a patch
of what the reader changed, so a value they left alone is never written back
over the one that arrived.

A batch asks the same way. Every cell is a field there, so an untouched one
already reads the row itself; a cell the reader changed marks itself and is
answered on its own, and **Save all** waits until nothing is outstanding.

`editConflictPolicy` and `onEditConflict` decide it without asking, exactly as
they do for a cell.

Parts: the cell's own `edit-cell-conflict`, `edit-cell-conflict-message`,
`edit-cell-incoming`, `edit-cell-keep-mine`, `edit-cell-take-theirs`, plus
`batch-edit-conflict` where the batch bar says why it is not saving yet.
Labels: `labels.editConflict`, `labels.keepMine`, `labels.takeTheirs`,
`labels.theirsValue`.

### The controls are glyphs

Each kit draws its own pencil, check and cross, with `labels.editRow`,
`labels.saveRow` and `labels.cancel` as both the accessible name and the hover
title — an actions column is a narrow place, and three words per control crowd
out the row. `rowEditIcons` changes that per control: a node is your own glyph,
`false` asks for the label as text.

```tsx
import { DataTable } from "@adapttable/mantine";
import { rowEditing } from "@adapttable/mantine/editing";

<DataTable
  {...props}
  features={[
    rowEditing(save, { rowEditIcons: { save: <TickIcon />, cancel: false } }),
  ]}
/>;
```

Parts: `row-edit-begin`, `row-edit-actions`, `row-edit-save`, `row-edit-cancel`.
Labels: `labels.editRow`, `labels.saveRow`, `labels.cancel`, all localized in
every locale.

Headless: `useRowEditing` (`RowEditingState`, `RowEditDrafts`,
`UseRowEditingOptions`), with `RowEditCell` (`RowEditCellProps`),
`rowEditControls` (`RowEditControlsOptions` in, `RowEditControls` out)
from `@adapttable/react/adapter`, `RowEditIcons` typing the glyph overrides,
and `rowEditConflict` (`RowEditConflict` out) telling one row's controls that
an answer is outstanding. `useEditConflict` returns `reconcileRow`
(`ReconcileLiveRowEdit` in) and `isRowConflict` for the row unit, and
`reconcileBatch` (`ReconcileLiveBatchEdit` in) for a batch;
`CellConflictAsk` is what one field's notice needs, and `EditConflictChange`
names each field that moved. Each adapter mounts `RowEditActions`
(`RowEditActionsProps`) over `RowEditActionsChrome`, and resolves one row's
actions against its edit state with `resolveRowEditTrigger` (`RowEditTrigger`
out) — which rewires an `editsRow` action to the form and reports whether the
built-in control still draws itself.

## Changing many rows, saving once

A review pass — walking a list correcting values — wants one write at the end,
not one per row. Compose `batchEditing()` and every editable cell becomes a field and holds
every change until the reader saves them all:

```tsx
import { batchEditing } from "@adapttable/mantine/editing";

<DataTable
  {...props}
  features={[
    batchEditing((edits) => {
      // edits === [{ row, rowId, patch }, …] — every pending row, once
      return api.saveAll(
        edits.map((edit) => ({ id: edit.rowId, ...edit.patch }))
      );
    }),
  ]}
/>;
```

The `batchEditing` handler is called **once** per save, which is what lets the
whole batch be one request — and makes it atomic if your endpoint treats it that way. A bar
appears as soon as something is pending, with the count, Save all and Cancel all;
it is a live region, so the count is heard as well as seen. Cancel restores
everything at once, because nothing was ever applied.

The count is **rows**, not cells: three changes across two rows read "2 unsaved
rows". A value typed back to what it was stops counting. Changed cells carry
`data-changed`, so the reader can find their way back to what they touched.

Labels: `pendingRows(count)`, `saveAll`, `cancelAll` — localized in every locale.
Parts: `batch-edit-cell`, `batch-edit-bar`, `batch-edit-count`,
`batch-edit-save`, `batch-edit-cancel`, `batch-edit-error`.

The bar exposes a busy state during a pending save and announces a rejected
host save in `batch-edit-error`. Drafts remain available for retry or cancel;
repeated Save clicks while a request is pending do not send another request.
If the host has confirmed only part of a batch, reconcile those incoming rows
with **Take theirs** before retrying: the accepted edits leave the pending
batch, and only the remaining edits are sent. Cancel discards the remaining
drafts while retaining the host's already applied rows.

`batchEditing` is also its own entry, `@adapttable/<kit>/batch-editing`, for a
table that wants only the batch mode.

Headless: `useBatchEditing` (`BatchEditingState`, `BatchRowEdit`,
`UseBatchEditingOptions`), with `BatchEditCell` on
`@adapttable/react/adapter`. Each adapter mounts `BatchEditBar` over
`BatchEditBarChrome`.

## Adding, duplicating and deleting rows

Editing a value is one thing; changing which rows exist is another. Pass the
handlers as the second argument of `rowActions`, and each one puts its own
control on screen:

```tsx
import { rowActions } from "@adapttable/mantine/row-actions";

<DataTable
  {...props}
  features={[
    rowActions(undefined, {
      onAddRow: () => setRows((rows) => [blankTask(), ...rows]),
      onDuplicateRow: (row) =>
        setRows((rows) => [{ ...row, id: nextId() }, ...rows]),
      onDeleteRow: (row) =>
        setRows((rows) => rows.filter((r) => r.id !== row.id)),
    }),
  ]}
/>;
```

`onAddRow` puts an **Add row** button in the toolbar. `onDuplicateRow` and
`onDeleteRow` put icon-only **Duplicate row** and **Delete row** on every row
(the labels are the tooltip and accessible name), after your own row actions —
the first argument of `rowActions` — so a delete stays last, where a
destructive action belongs. They ride the actions column like any other row
action: hideable and end-pinnable from the Columns menu, buttons on desktop and
card buttons on mobile. The [row actions](./row-actions.md) page covers the
column itself.

**A delete asks first.** It goes through the same confirmation dialog a
`rowActions` entry with a `confirm` block uses — `labels.deleteRow` as the title,
`labels.deleteRowConfirm` as the question, both translated in every bundled
locale. Pass `confirmDeleteRow: false` when your own UI already asked, or when
your delete is reversible.

**The table asks; you do the rest.** It holds no draft and stores no row. A row
you add arrives through the source like every other row, which is what keeps it
ordinary — editable, filterable, sortable, counted, grouped and virtualized from
the moment it lands, with nothing about it special. It also means what a copy
_means_ is yours: which fields carry over, which reset, what id it gets.

For an in-memory table, [`insertRow`, `removeRow` and `applyRowPatches`](./api.md)
do the list work.

Nothing here needs `editing()`. Pair them and a reader adds a blank row and
fills it in place; leave editing off and Add is simply a button that runs your
handler.

Headless: `useRowMutations` (`RowMutationHandlers`, `RowMutationsState`) builds
the same state a kit renders, and `chrome.rowMutations` carries it — `canAdd`
plus `addRow`, with the duplicate and delete actions already folded into
`chrome.rowActions`.

## Reordering rows

Changing which row sits where is the same one-way write: compose `rowReorder`
and a grip appears. See [row reordering](./row-reordering.md).
Compose `rowPinning()` and pin actions appear. See
[row pinning](./row-pinning.md).

## Dirty marks

A table that looks identical before and after a save leaves the reader no way to
tell what is still at risk. Compose `dirtyIndicators()` and a changed cell carries
`data-dirty` until its value settles — and so does its row, so a long table can
be scanned without hunting for the cell inside it.

```tsx
import { dirtyIndicators, editing } from "@adapttable/mantine/editing";

<DataTable {...props} features={[editing(save), dirtyIndicators()]} />;
```

A mark clears when the save **resolves**, and stays when it fails: the value is
still at risk until the reader undoes it or tries again. A rollback clears it too,
since the value it belonged to is gone. Nothing clears on a timer — a mark that
fades on its own says the change is safe when nobody checked.

Off by default, because a mark is a claim about what the server has agreed to and
a table whose host never says would be guessing. For a table that confirms its
own state another way — a refetch that agrees, a websocket echoing the value back
— `editing(commit, { onDirtyChange })` tracks unsaved edits on its own and hands
them over on mount and whenever one moves: `{ count, confirm, confirmRow,
confirmAll }`, a `count` for an "unsaved changes" line and the functions that
settle them. It draws no marks; add `dirtyIndicators()` for those, and both read
the same set.

```tsx
const [dirty, setDirty] = useState<DirtyEdits>();

<DataTable
  {...props}
  features={[editing(save, { onDirtyChange: setDirty })]}
/>;
// dirty?.count — "3 unsaved changes"; dirty?.confirmAll() after a refetch
```

Headless: `useDirtyCells` (`DirtyCellState`, `UseDirtyCellsOptions`) and
`rowIsDirty(editing, rowId)` from `@adapttable/react/adapter`.

## Headless editing

The editing engine is exported for custom adapters and fully custom tables.
`useCellEditing` is the state machine (one active cell, a draft string, and
the Enter / Escape / Tab keyboard flow); `EditableCellGate` is the reusable
activation wrapper every built-in adapter renders (double-click / Enter / F2
to begin, with the cell value as the accessible name and the edit hint as
its `title`).

`useCellEditing`, `EditableCellGate` and the `MultiSelectEditor*` exports come
from `@adapttable/react`; `isCellEditable`, `hasEditableColumns`,
`parseCellEditValue`, `resolveCellEditor` and `resolveCommitValue` from
`@adapttable/core`; `editorValidationProps` from `@adapttable/react/adapter`.

| Export                                                                                                                   | Purpose                                                                                                   |
| ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `useCellEditing` / `CellEditingState`                                                                                    | The state machine hook and the state it returns.                                                          |
| `EditableCellGate` / `EditableCellGateProps`                                                                             | Activation wrapper: display content when idle, the kit's editor while active.                             |
| `EditableCellEditing`                                                                                                    | The editing bundle adapters receive from the chrome (`chrome.editing`).                                   |
| `CellEditCommit` / `CellEditTarget`                                                                                      | A commit payload (`row`, `key`, `value`) and the active-cell address.                                     |
| `CellEditor` / `CellEditorOption`                                                                                        | The column `editor` descriptor and one option of a select editor.                                         |
| `CellEditKeyAction` / `CellEditKeyOutcome` / `CellEditNavigation`                                                        | Keyboard-flow vocabulary: what a key press means and where focus goes next.                               |
| `EditableCellController` / `EditableCellEditorCtrl` / `EditableCellMode`                                                 | The controller handed to a custom editor: draft, commit/cancel, mode.                                     |
| `MultiSelectEditorChrome` / `MultiSelectEditorChromeProps` / `MultiSelectEditorSlots` / `MultiSelectEditorCheckboxProps` | Checkbox-group multi-select editor for kits whose select holds one value.                                 |
| `EditableColumnLike` / `isCellEditable` / `hasEditableColumns`                                                           | The minimal column shape editing reads, plus the two predicates the chrome uses.                          |
| `parseCellEditValue` / `resolveCellEditor` / `normalizeEditorOptions`                                                    | Draft parsing (number editors yield `number \| null`) and editor/option resolution.                       |
| `EditValidationState` / `ValidationCheckResult`                                                                          | Validation state: which cells carry a message, which are still checking, and what a check returned.       |
| `CellValidator` / `RowValidator` / `ValidationTarget`                                                                    | The two validator signatures and the cell address a check is about.                                       |
| `resolveCommitValue`                                                                                                     | The row, column and parsed value a commit resolves to — what a validator judges before the host sees it.  |
| `editorValidationProps`                                                                                                  | The `aria-invalid` / `aria-describedby` / `aria-busy` a kit's editor spreads while validation is in play. |

## Applying changes without a refetch

A commit hands you a change; what you do with your data is yours. Refetching
the page to reflect it costs a round trip and throws away the user's scroll
position, open rows, and sometimes their selection.

`applyRowPatches` applies changes to the rows you already hold. The same
helper is how a [realtime feed](./realtime.md) lands:

```tsx
import { applyRowPatches, updateRow, removeRow } from "@adapttable/core";
import { editing } from "@adapttable/mantine/editing";

const [rows, setRows] = useState(initial);
const byId = (row: Person) => row.id;

<DataTable
  data={rows}
  columns={columns}
  rowKey={byId}
  features={[
    editing((row, key, value) =>
      setRows((current) =>
        applyRowPatches(current, [updateRow(byId(row), { [key]: value })], byId)
      )
    ),
  ]}
/>;
```

Four builders — `insertRow`, `updateRow`, `upsertRow`, `removeRow` — and a
batch is just an array, applied in order, so a later patch acts on what an
earlier one did.

Two guarantees make it safe to call on every commit:

- **Untouched rows keep their object identity.** React reconciles them as
  unchanged, and per-row memos — a [computed column](./columns.md)'s cache, a
  `memo`'d cell — stay valid instead of recomputing for the whole page.
- **A patch that changes nothing returns the very same array.** An update whose
  values already match, an upsert of the row already in place, a removal of an
  id that is not there, or an empty batch hands back the original reference, so
  the `setState` does not re-render.

Selection and expansion survive for the same reason: both are keyed by row id,
and a patch never changes the id of a row it did not touch.

`applyRowPatches` is a pure function over an array — the table does not own
your data and this does not make it start.

The patch shapes are exported for code that builds them dynamically:
`RowPatch` is the union, with `InsertPatch`, `UpdatePatch`, `UpsertPatch` and
`RemovePatch` as its members.

## Notes

- Works on desktop rows and mobile cards, LTR and RTL.
- Custom `Cell` / `accessor` still render in display mode; the editor replaces
  them only while that cell is active.
- Prefer updating your row list immutably in your `editing()` handler so React
  sees a new `data` / source identity.

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/) —
double-click an editable cell (Person, Email, or Team) in the editing
section.

## Undo and redo

Compose `editHistory` from `@adapttable/<kit>/editing` and edits can be taken back:

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import { editHistory, editing } from "@adapttable/mantine/editing";

<DataTable
  data={rows}
  rowKey={(row) => row.id}
  features={[cellNavigation(), editHistory(), editing(commit)]}
  columns={[{ key: "budget", header: "Budget", editable: true }]}
/>;
```

**Ctrl/Cmd+Z** undoes, **Ctrl/Cmd+Shift+Z** and **Ctrl+Y** redo. Both announce
what moved — `labels.editUndone`, `labels.editRedone`, or
`labels.editNothingToUndo` when the history is empty.

An undo does not rewrite your data, because the table never owned it. It
**commits the previous value back through your `editing()` handler**, the same
call the original edit made — so validation, a mutation, an optimistic update, a
toast, whatever you wrapped around editing, all run on the way back exactly as
they ran on the way out.

**One gesture is one entry.** A paste of two hundred cells undoes in a single
press, as does a fill; an inline edit is a gesture of one. Fifty gestures are
kept by default — pass `{ depth: 200 }` to keep more.

The keys live on the grid, so they need `cellNavigation()`. For visible
controls, compose `undoRedoButtons()` beside `editHistory()`: Undo and Redo sit
in the toolbar and disable when there is nothing to take back. Without
`editHistory()` they do not render. See
[toolbar and view controls](./toolbar-and-view-controls.md).

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import {
  editHistory,
  editing,
  undoRedoButtons,
} from "@adapttable/mantine/editing";

<DataTable
  {...props}
  features={[
    cellNavigation(),
    editHistory(),
    undoRedoButtons(),
    editing(commit),
  ]}
/>;
```

For your own buttons, `editHistory({ onChange })` hands over the history on
mount and whenever `canUndo` or `canRedo` changes — `undo()`, `redo()`,
`canUndo`, `canRedo` and `clear()`:

```tsx
const [history, setHistory] = useState<EditHistoryHandle>();

<button disabled={!history?.canUndo} onClick={() => history?.undo()}>
  Undo
</button>
<DataTable
  {...props}
  features={[editHistory({ onChange: setHistory }), editing(commit)]}
/>;
```

The functions are stable and always act on the table's current history. Call
`clear()` when you replace the data underneath, since a history of rows that no
longer exist can only put back values nobody wants.

## Headless row and batch save settlement

The neutral `createRowEditStore` and `createBatchEditStore` support a synchronous
or asynchronous host save. The application still owns rows and persistence.
A fulfilled save clears the submitted drafts; a rejected save keeps them
available for correction or retry. A successful callback does not make the
store write the host's data.

```ts
import { createRowEditStore } from "@adapttable/core";

type Person = { id: string; name: string };
const edits = createRowEditStore<Person>({
  enabled: true,
  columns: [{ key: "name", editable: true }],
  validateRow: (next) =>
    next.name.trim() ? undefined : { name: "Enter a name" },
  onRowEdit: async (row, patch) => {
    await savePerson(row.id, patch);
    // Publish the accepted rows through the application's own state.
  },
});
edits.begin(person, person.id);
edits.setDraft("name", "Ada");
edits.save();
```

`savePerson` and `person` above belong to the host. Subscribe to the store and
read `getSnapshot().commit` to render pending and failure states.
`EditCommitSnapshot.phase` is `validating`, `saving`, `invalid` or `failed`;
`commit` is absent while idle and after success. `EditCommitValidationFailure`
identifies a `rowId`, an optional `columnKey`, and a message. The stores keep
`save()` and `saveAll()` as void actions; observe their snapshots instead of
awaiting the action return value.

Column validators inspect parsed changed values. An optional `validateRow`
checks the proposed complete row; provide a pure `applyEdit` projection when a
column key does not correspond to a direct property. These options are named
by `EditCommitValidationOptions<TRow>`. A batch validates all submitted changes
before it calls the host once. `formatEditError`, `onEditError` and
`onValidationFail` let the host format or observe failures without changing
row ownership. Existing synchronous saves retain synchronous callback ordering.

Cancel, incompatible reconfiguration, disabling editing, and `dispose()`
invalidate pending local continuations. They cannot cancel a request the host
already received; the host remains responsible for its own request cancellation
and response ordering. Bindings should dispose their owned store when its scope
ends and render pending/validation state using their own controls.

---

# React table row reordering — flat, grouped and tree rows

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — compose `rowReorder` and a grip appears. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [reorder flat, grouped and tree rows in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/row-reordering/) — Space lifts a row, arrows move it, Space drops it. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

Import `rowReorder` from `@adapttable/<kit>/row-reorder` and a drag handle
appears in a reserved leading column. The import is the switch: a table that
does not compose it never downloads the drag state machine, its keyboard
handling or its announcements — see [feature composition](./features.md).
The table never mutates your array; you apply the move.

```tsx
import { DataTable } from "@adapttable/mantine";
import { applyRowReorder } from "@adapttable/react";
import { rowReorder } from "@adapttable/mantine/row-reorder";
import { useState } from "react";

function Tasks({ seed }: { seed: Task[] }) {
  const [rows, setRows] = useState(seed);
  return (
    <DataTable
      data={rows}
      columns={columns}
      rowKey={(row) => row.id}
      features={[
        rowReorder((from, to) => {
          setRows((current) => applyRowReorder(current, from, to));
        }),
      ]}
    />
  );
}
```

`from` and `to` are **dataset-relative**: the row's index in the current source
plus the page offset, so a virtual window or a paged slice does not lie about
where the row sits. The third argument is the row that moved, so a host that
keys by identity never has to look it up.

`applyRowReorder(rows, from, to)` is the in-memory helper — a copy, never a
mutate. Out of range is a no-op copy.

## Keyboard first

A drag-only reorder is unusable with a keyboard and fails accessibility
review. The grip is a real button:

- **Space** lifts the row (announced: "Row 3 lifted")
- **Arrow Up / Down** (and Left / Right, following `dir`) move the drop target
- **Space** again drops it (announced: "Row moved from 3 to 5")
- **Escape** cancels

`RowReorderAnnouncer` is the live region. It mounts only when reorder is
armed, so a table that does not compose `rowReorder` adds no second status
region (export already owns one).

## Mobile

Cards get **up / down** buttons (`RowReorderButtons`), not a drag handle. The
ends disable rather than wrapping. Grouped and tree cards also get the same
**Move to group…** / **Move under…** menu as desktop, with 44px targets.

## Grouped rows: reorder versus move

The first handler still means **reorder among siblings**. Its `from` and `to`
are positions inside that group, not positions in the flattened list.
Crossing a group boundary is a different write:

```tsx
rowReorder((from, to, row) => reorderInsideGroup(row, from, to), {
  movePolicy: "confirm",
  onGroupMove: (row, fromGroup, toGroup, position) => {
    // toGroup.levels contains each grouping column and its raw value.
    moveRowIntoGroup(row, toGroup.levels, position);
  },
});
```

Dragging over another group's row or choosing **Move to group…** calls
`onGroupMove`. The menu lists loaded leaf groups. A nested `RowGroupRef`
contains a stable `id`, a breadcrumb `label`, and raw `{ key, value, label }`
levels, so the host can write the actual grouping fields.

## Tree rows: reorder versus re-parent

Before/after another sibling calls the ordinary reorder handler with sibling
positions. Dropping into the middle of another node, or choosing **Move
under…**, calls:

```tsx
rowReorder(reorderAmongSiblings, {
  movePolicy: "confirm",
  onTreeMove: (row, fromParent, toParent, position) => {
    updateParentAndOrder(row, toParent.id, position);
  },
});
```

`toParent.id` is `null` for the top level. A row cannot move under itself or
one of its descendants: the cycle guard rejects pointer, keyboard, and menu
paths before any host callback runs and announces the reason.

## Move policy

- `"never"` (default) keeps same-group/same-parent reorder available and
  rejects boundary changes with an announcement.
- `"confirm"` opens the kit-native confirmation in the move menu. Confirm and
  cancel return focus to the trigger. Supply `confirmMove(request)` to use a
  host-owned async confirmation surface instead.
- `"auto"` calls the matching move callback immediately.

Every pointer action has a keyboard/touch equivalent. Space + arrows can cross
the visible boundary; the destination menu reaches loaded groups and parents
directly.

When sorting is active, visual order belongs to the sort. Same-scope
order-only writes are rejected with “Clear sorting before changing row order.”
Cross-group moves and re-parenting remain available because they change row
membership; the active sort remains in force.

**URL / Saved Views.** Row order is the host's array. There is nothing to
serialize. Drag, keyboard, mobile and menu paths call the same host callbacks,
so host persistence receives one identical write contract.

## Column menu

The reorder column uses the reserved key `REORDER_COLUMN_KEY` (`"reorder"`),
the same trick as the actions column. Hide it or pin it to the start from the
Columns menu. CSV export drops it the way it drops actions
(`exportableColumns`).

## Headless

`RowReorderOptions`, `RowMovePolicy`, `RowMoveRequest`, `RowDropPosition`,
`RowGroupMoveHandler`, `RowTreeMoveHandler`, `RowTreeParentRef`,
`RowGroupRef`, `treeMoveCreatesCycle` and `rowDropPosition` are the nested
contracts. `RowReorderDecision` is the internal-seam result used to distinguish
an order write, a membership move, or a rejection. `useRowReorder(options)`
is the grab state: it returns `TableRowReorderState` from `@adapttable/react`,
the same shape adapters receive as `RowReorderState` from
`@adapttable/react/adapter`; `RowReorderHandler` is the ordinal write.
`datasetIndex(localIndex, windowStart)` turns a flat rendered slot into a
dataset index. `rowReorderSignature(reorder, rowId, localIndex)` is the memo
digest so a virtualized row repaints when lifted, targeted, or confirming.
`hostConfirmPending` is true while a host-owned `confirmMove` promise is
unresolved — adapters disable grips, buttons and menu items then, and a
second move is ignored until that promise settles.

Each adapter mounts `RowReorderHandle` (`RowReorderHandleProps`) and
`RowReorderButtons` (`RowReorderButtonsProps`) over
`RowReorderHandleChrome` / `RowReorderButtonsChrome`.
`RowReorderAnnouncer` stays on `@adapttable/react/adapter`.
`REORDER_COLUMN_WIDTH` is the pin-lead width every kit shares.
`ROW_DND_MIME` is the HTML5 drag type.

Styling and test hooks: `row-reorder-handle`, `row-reorder-buttons`,
`row-reorder-up`, `row-reorder-down`, `row-move-menu`,
`row-move-menu-trigger`, `row-move-menu-content`, `row-move-menu-item`, and
`row-move-confirmation`.

---

# React table row pinning — sticky top and bottom rows

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — compose `rowPinning()` and pin actions appear. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [pin and merge rows in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/rows/) — sticky pins, Team written once down consecutive teammates (pin keeps that one merge), and a 3-dot menu. Movement lives on the [row-reordering page](https://adapttable.orwamahmoud.com/react/demo/mantine/row-reordering/). The same pages exist for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

Import `rowPinning` from `@adapttable/<kit>/row-pinning` and compose it; every
row gains icon-only
Pin to top / Pin to bottom / Unpin (labels on hover and as the accessible name),
each hidden when it does not apply. A table that does not compose it carries
none of this — see [feature composition](./features.md). The value is
`{ top, bottom }` lists of row ids, not a flat array: which edge a row
sticks to is the feature.

```tsx
import { DataTable } from "@adapttable/mantine";
import type { RowPinState } from "@adapttable/mantine/features";
import { rowPinning } from "@adapttable/mantine/row-pinning";
import { useState } from "react";

function Tasks({ rows }: { rows: Task[] }) {
  const [pinnedRowIds, setPinnedRowIds] = useState<RowPinState>({
    top: [],
    bottom: [],
  });
  return (
    <DataTable
      data={rows}
      columns={columns}
      rowKey={(row) => row.id}
      features={[
        rowPinning({ pinnedRowIds, onPinnedRowIdsChange: setPinnedRowIds }),
      ]}
    />
  );
}
```

Uncontrolled: a bare `rowPinning()`, or `rowPinning({ onPinnedRowIdsChange })`
to observe the lists, and the table holds them. In that mode the
batteries-included shell writes them to the URL
(`rowPin=id1:top,id2:bottom`) so a shared link keeps the same rows stuck, and
Saved Views capture `rowPin` with the rest of the table. A controlled
`pinnedRowIds` keeps the URL out of it. `useRowPinningUrlState` is the same
pair for a host that wants to own the URL itself.

## Outside the virtual window

A pinned row is **removed** from the scroll list and rendered first or last
in the body (`data-adapttable-part="pinned-top"` /
`"pinned-bottom"` on the row). Leave it in the window and it draws twice. The
virtualizer windows only the unpinned rows; ARIA `rowCount` and
`windowStart` still describe the whole dataset.

Pinned-row cells keep column `pinOffset`, with a z-index between a scrolled
pinned column and the sticky header (`PIN_Z.rowPinned` /
`PIN_Z.rowPinnedColumn`), so a pinned row over a pinned column does not
overlap the header corner.

## Mobile

Cards get the same pin actions and no sticky chrome — a card list is not a
grid. The list order still puts top pins first and bottom pins last.

## What it will not do

**Grouping or a tree.** A nested list is not a flat pin stack. Composing
`rowPinning(…)` while either is armed logs a `devWarn` and the actions do not
render.
Host-owned totals that are not data rows are a different feature —
[`pinnedSummaryRows`](./pinned-summary-rows.md) sticks those objects above
or below the scroll body on every table shape, including grouped and tree
tables.

## Headless

`useRowPinning(options)` returns `RowPinningState`: `state`, `sideOf`,
`pin`, `unpin`, and the three synthesized actions
(`PIN_TOP_ACTION_KEY`, `PIN_BOTTOM_ACTION_KEY`, `UNPIN_ROW_ACTION_KEY`).
`applyRowPin(state, rowId, side)` is the in-memory helper — a copy, never
a mutate. `partitionPinnedRows(rows, state, getRowId)` splits a list into
top / scroll / bottom. `rowPinSignature(pinning, rowId)` is the memo
digest so a virtualized row repaints when it is pinned or unpinned.
`EMPTY_ROW_PIN_STATE` is `{ top: [], bottom: [] }`.

From `@adapttable/react/adapter`: `pinnedRowStickyStyle` / `pinnedRowCellStyle`
are the sticky CSS kits spread; `pinnedRowPart` names the row's
`data-adapttable-part`; `pinnedRowSticky` is the sticky style when the
kit asked for sticky pins; `orderedCardEntries` is the card-list order;
`useOffsetHeight` measures the header so top pins sit under it;
`PINNED_TOP_PART` / `PINNED_BOTTOM_PART` name the sections.
`rowSourceIndex(entry)` is the dataset index when pinning remapped the
window.

Labels: `pinToTop`, `pinToBottom`, `unpinRow` (`RowPinLabels`).

---

# React table pinned summary rows — totals that stay outside the row model

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — compose `pinnedSummaryRows`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [pinned summaries and group totals in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/aggregation/) — host-owned totals stick above and below the list, and they are not data rows. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

These are **not** lifted data rows. You pass separate objects
(`pinnedRows: { top, bottom }`) that never enter sort, filter, grouping,
pagination or selection. Cells still run through the ordinary column
pipeline — formatters, sparklines, spanning — and the rows stick above
or below the scroll window, including on grouped and tree tables where
[lift-a-data-row pinning](./row-pinning.md) stays refused.

```tsx
import { DataTable } from "@adapttable/mantine";
import { pinnedSummaryRows } from "@adapttable/mantine/pinned-summary-rows";

const totals = { id: "totals", name: "Team total", budget: 128_000 };

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    pinnedSummaryRows({
      top: [totals],
      bottom: [{ id: "grand", name: "Grand total", budget: 128_000 }],
    }),
  ]}
/>;
```

The objects are host data. Nothing is written to the URL or a Saved View
— a shared link does not have to invent totals the next load cannot
recompute. They are not exported, selected, edited, reordered, or given
pin / row-action menus.

Desktop rows use `data-adapttable-part="pinned-summary-top"` /
`"pinned-summary-bottom"` and `aria-label` from `pinnedSummaryRow`.
Keyboard cell navigation reaches them. Mobile cards keep the same order
and announcement; a card list has no sticky chrome.

Identities are namespaced (`adapttable:pinned-summary:top:0`) so they
never collide with a data-row id.

Totals computed from rows — `summaryRow` and group aggregates — are on the
[aggregation](./aggregation.md) page. Lift a real person with [`rowPinning`](./row-pinning.md). Stick a total
that is not in the dataset with this factory.

Labels: `pinnedSummaryRow`, `pinnedSummaryTop`, `pinnedSummaryBottom`.

Headless, from `@adapttable/core`: `PinnedRows` is the `{ top, bottom }`
shape and `PinnedSummaryEntry` one resolved row; `resolvePinnedRows`,
`pinnedSummaryEntries` and `allPinnedSummaryEntries` flatten them into keyed
entries. `pinnedSummaryRowId(side, index)` builds the namespaced id,
`isPinnedSummaryRowId(id)` and `pinnedSummarySideFromId(id)` read it back, and
`pinnedSummaryPart(side)` names the row's part (`PINNED_SUMMARY_TOP_PART` /
`PINNED_SUMMARY_BOTTOM_PART`). The id and part helpers are also on
`@adapttable/react/adapter`.

---

# React table row and column spanning

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — add `cellSpan(...)` from `@adapttable/mantine/cell-span` to `features`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [merge cells in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/rows/) — Team is written once down the people who share it. Person stays its own cell. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

A span is a rectangle. The origin cell carries `colSpan` / `rowSpan`; every
covered neighbour is omitted from that row's cell list, so every kit maps one
list instead of `columns.map`. Spanning is the `cellSpan` feature: without it
the list is one cell per column and nothing extra renders, even when a column
sets `colSpan` / `rowSpan`.

By default the origin is painted like a spreadsheet merge: **centered
content, one fill** across the whole span (`data-cell-span` is
`"colSpan x rowSpan"`, e.g. `"1x5"`). That is `cellSpan(getCellSpan, "merged")`,
the default. Pass `"plain"` as the second argument for geometry only — same chrome as a 1×1 cell — if you want
to draw a calendar-style bar yourself. Override the fill with
`--adapttable-cell-span-fill`, or the unstyled `cellSpan` class hook.

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellSpan } from "@adapttable/mantine/cell-span";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    cellSpan(({ column, row, sectionRows, sectionRowIndex }) => {
      if (column.key !== "team") return undefined;
      if (sectionRows[sectionRowIndex - 1]?.team === row.team) return undefined;
      let rowSpan = 1;
      while (sectionRows[sectionRowIndex + rowSpan]?.team === row.team) {
        rowSpan += 1;
      }
      return rowSpan > 1 ? { rowSpan } : undefined;
    }),
  ]}
/>;
```

A column that always spans can say so on the definition:

```tsx
{ key: "team", header: "Team", accessor: (row) => row.team, rowSpan: 2 }
```

The table honours it once `cellSpan` is composed. With no per-cell rule,
pass one that defers to the columns: `features={[cellSpan(() => undefined)]}`.

The callback's value wins per side: a side it leaves unset, or an
`undefined` return, falls back to `column.colSpan` / `column.rowSpan`. Spans clamp to the remaining grid.
They clip at a **column pin** boundary (a pinned cell and a scrolling cell
cannot share one `<td>`) and at the **column window**: a span that starts
off-screen continues on the first visible column it covers.

Arrow keys skip a covered cell. CSV / XLSX write the origin value once and
leave covered cells empty.

## Mobile and the URL

Cards are a list of fields, not a grid — they ignore geometry and still
show every column. Spans are derived from data, so there is nothing to
put in the URL or a saved view.

Row spans stay inside one tbody. Consecutive teammates in visual order
(pinned top, then scroll, then pinned bottom) stay one merge — pinning a
Core person to the floor does not split Core into two cells. Kits render
those rows in the same tbody so HTML can express the span; a sticky pin
and a `rowSpan` taller than one row paint on top of each other, so the
sticky offset is skipped while any body cell spans more than one row.
The row still moves to the top or floor.

---

# React table full-width and separator rows

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — add `extraRows([...])` from `@adapttable/mantine/extra-rows` to `features`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [the all-options demo](https://adapttable.orwamahmoud.com/react/demo/all-options/) — turn **Extra attached to a person** on.

A separator is a thin rule. A full-width row is one cell that spans the
table. Both are host-injected slots in the same `kind`-tagged list grouping
already uses. Without the `extraRows` feature nothing is inserted.

```tsx
import { DataTable } from "@adapttable/mantine";
import { extraRows } from "@adapttable/mantine/extra-rows";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    extraRows([
      { key: "s", kind: "separator", beforeRowId: "2" },
      {
        key: "note",
        kind: "fullWidth",
        render: () => <em>Shipped Friday.</em>,
      },
    ]),
  ]}
/>;
```

`beforeRowId` is a data-row id. Omit it to append after the last data row.
Several extras that share a target keep the host's order. A named extra
stays in front of that person through drag-reorder and pin — it is its
own full-width row, with its own height, not folded into the person
below. When a Team (or any) row span would paint through that extra, the
extra sits on top of the spanned column so the note stays whole. The
extra uses the `backgroundColor` / `background` that
`rowAppearance({ rowStyle })` gives that person (light and dark are whatever
you passed) — AdaptTable does not pick a colour of its own.

```tsx
features={[
  extraRows([
    {
      key: "note",
      kind: "fullWidth",
      beforeRowId: ada.id,
      render: () =>
        `Attached to ${ada.name} — drag or pin them and this extra comes along.`,
    },
  ]),
]}
```

The slots join the grouping entry list when grouping is on, so they window
with the groups. On a flat or tree table they splice into the rendered
body. Mobile cards keep the same slots: a rule between cards, or a
full-width note.

Extras are content, not table state — nothing goes in the URL or a saved
view.

---

# React table row styling and heights

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — add `rowAppearance({ rowStyle, rowHeight })` from `@adapttable/mantine/row-appearance` to `features`. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [the all-options demo](https://adapttable.orwamahmoud.com/react/demo/all-options/) — turn **Row style** on.

`rowAppearance` takes three options: `rowClassName` appends a class, and
`rowStyle` / `rowHeight` set inline style and height. Each is a function of
`(row, index)` (`rowHeight` also takes a number), applied to desktop rows and
mobile cards alike. Without the feature nothing is set.

```tsx
import { DataTable } from "@adapttable/mantine";
import { rowAppearance } from "@adapttable/mantine/row-appearance";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    rowAppearance({
      rowStyle: (row) =>
        row.overdue ? { backgroundColor: "var(--overdue)" } : undefined,
      rowHeight: (row) => (row.tall ? 72 : 48),
    }),
  ]}
/>;
```

A number `rowHeight` is every row. A function is per row. Height wins when
`rowStyle` also names `height` — `rowHeight` is the dedicated override.

Pass a light fill and a dark fill — AdaptTable does not pick those colours.
`light-dark()` follows the page `color-scheme` (and RTL uses logical
properties, so `text-align: start` is already the extra's alignment):

```tsx
rowAppearance({
  rowStyle: (row) =>
    row.overdue
      ? {
          backgroundColor:
            "light-dark(oklch(0.93 0.08 95), oklch(0.38 0.07 85))",
        }
      : undefined,
});
```

CSS variables that flip under the host's dark class work the same way.

The row virtualizer's `estimateSize` reads the same value, so a
variable-height table still windows. `measureElement` stays authoritative
for what the browser actually laid out.

Style and height are functions of the row, not table state — nothing goes
in the URL or a saved view.

---

# React data grid navigation — range selection, copy, paste and fill

Use arrow keys to navigate cells, select a range, copy and paste spreadsheet
values, or extend values with a fill handle. This guide also covers find-in-table,
selection statistics and accessible focus. For host-owned validation and saving,
see [cell editing](./cell-editing.md).

A table with a hundred cells should not be a hundred tab stops. Compose
`cellNavigation` from `@adapttable/<kit>/cell-navigation` and the table becomes
**one** tab stop whose interior is reachable by arrow keys, with correct ARIA
grid semantics and a screen reader that says where you are. See
[feature composition](./features.md).

**Related:** [Accessible React data table](./accessibility.md) ·
[Cell editing](./cell-editing.md) · [Virtualization](./virtualization.md) ·
[Columns](./columns.md)

## Copy and cut, the way a spreadsheet reads it

**Ctrl/Cmd+C** copies the selected rectangle as tab-separated text — the format
Excel, Google Sheets, Numbers and LibreOffice all read — so a paste lands in
columns rather than one cell. Cells carrying a tab, a newline or a quote are
quoted the way those applications quote them.

Values resolve exactly as an export's do, `exportValue` included, so a copy and
a downloaded file cannot disagree about what a cell contains.

**Ctrl/Cmd+X** copies the same text and then calls `onCellCut(range)`. The table
clears nothing itself: what "cut" removes is your decision, and a cut that
emptied cells before the clipboard accepted them would lose the data outright.
With [`contextMenu()`](./command-palette.md#built-in-entries) composed, the
cell menu offers the same Cut.

Either way the outcome is announced — `labels.gridRangeCopied` on success,
`labels.gridRangeCopyFailed` when the browser refuses (the Clipboard API needs a
secure context and can be denied). With nothing selected, both keys are left to
the browser.

Headless: `clipboardRangeText` builds the text and `writeClipboardText` writes
it, reporting whether it landed rather than throwing.

## Pasting a spreadsheet back in

**Ctrl/Cmd+V** parses what a spreadsheet put on the clipboard and commits it as
ordinary cell edits — through your `editing()` handler, the same channel inline
editing uses:

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import { editing } from "@adapttable/mantine/editing";

<DataTable
  data={rows}
  rowKey={(row) => row.id}
  features={[
    cellNavigation(),
    editing((row, key, value) => save(row, key, value)),
  ]}
  columns={[{ key: "budget", header: "Budget", editable: true }]}
/>;
```

That is the whole wiring: a table that can be edited can be pasted into. Each
edit is the same thing an inline commit produces — same `parseValue`, same
shape, same handler — so paste is not a second editing route, and whatever you
wrap around a single-cell commit already covers a paste of two hundred.

To take the batch whole instead — one server round trip, one undo entry — set
`onCellPaste`, which takes precedence:

```tsx
<DataTable
  data={rows}
  rowKey={(row) => row.id}
  features={[cellNavigation(), editing(commit)]}
  onCellPaste={(edits) => saveAll(edits)}
  columns={[{ key: "budget", header: "Budget", editable: true }]}
/>
```

The **clipboard's shape wins** over the selection's: pasting a 3×2 block into one
focused cell writes 3×2, as every spreadsheet does. Cells landing outside the
loaded rows or the rendered columns are dropped rather than invented, and a
column that is not `editable` is skipped — a paste is an edit, and an edit into a
read-only column is not one.

The table writes nothing itself. Applying the edits stays yours, which is what
keeps a paste undoable, validatable and cancellable on your terms. The outcome
is announced through `labels.gridRangePasted`, or `labels.gridRangePasteFailed`
when the browser will not hand over the clipboard.

Headless: `readClipboardText` reads it, `parseClipboardTable` parses it, and
`pasteRangeEdits` maps it onto a range.

## The fill handle

Select a cell or a block and a small square appears on its bottom corner. Drag
it and the selection's values carry on — down, up, or sideways, whichever way
the drag mostly goes. The cells it would write are highlighted before anything
is committed, so the preview and the result cannot disagree.

**Two or more numbers a constant step apart continue the series** (1, 2 → 3, 4);
anything else repeats in order (Mon, Tue → Mon, Tue). A single number repeats
rather than counting — one value carries no step, and guessing `+1` there is the
behaviour spreadsheets are cursed for.

**Ctrl/Cmd+D** is the keyboard route: the selection's top row carries into the
rest of it. The handle itself is not a tab stop, because the grid is one tab
stop and a focusable square inside it would break that; the key press announces
what it wrote through `labels.gridRangeFilled`.

The edits arrive exactly as a paste's do — one call per cell through
`editing()`, or `onCellFill` for the batch — so the handle appears as soon as a
table can be edited, and never when it cannot:

```tsx
<DataTable
  data={rows}
  rowKey={(row) => row.id}
  features={[cellNavigation(), editing(commit)]}
  columns={[{ key: "budget", header: "Budget", editable: true }]}
/>
```

The square paints in the cell's own text colour; `--adapttable-fill-handle`
changes it. In RTL it sits on the row's inline end, which is the left, and a
sideways drag follows the same mirroring the arrow keys do.

Headless: `fillDirection`, `fillTargetRange` and `fillRangeEdits`;
`FillHandleChrome` passes the active corner to an adapter-owned handle.

## Find in table

Compose `findInTable` from `@adapttable/<kit>/find-in-table` and **Ctrl/Cmd+F**
opens a find bar over the table while focus is inside it or after a click in
it — a click elsewhere on the page hands the shortcut back to the browser;
`findInTable({ button: true })` also draws a **Find in table** control among
the toolbar's view controls, with the kit's own button (part `find-button`,
`aria-expanded` while the bar shows; `classNames.findButton` on unstyled and
shadcn):

```tsx
import { DataTable } from "@adapttable/mantine";
import { findInTable } from "@adapttable/mantine/find-in-table";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[findInTable({ button: true })]}
/>;
```

Find works on its own. With [`cellNavigation()`](#selecting-a-range) composed
too, walking the hits also moves the grid's focus and selection with them.

Find is not [search](./search.md). The search box asks "show me only the rows that match", and
on a server tier it asks the server; find asks "where does this appear in what I
am looking at", leaves every row where it is, and walks the hits. Both can be on
at once.

Every hit is marked — `data-cell-match`, and `data-cell-match-current` on the
one you are standing on — painted in the amber every browser paints its own
find hits, because a find highlight is a browser convention rather than a
design-system token. `--adapttable-find-match` and
`--adapttable-find-match-current` change it; in `@adapttable/unstyled` the
`cellMatch` / `cellMatchCurrent` class hooks do (the shadcn preset fills them
in).

**Enter** walks forward, **Shift+Enter** back, **Escape** closes and clears.
The current hit is scrolled into view — a find that highlighted without going
there would leave you hunting for the highlight — and the count is announced.
With cell navigation, walking also moves the grid's focus to the hit, which is
announced and left selected. The bar itself is one input, a count and
three buttons, all named through `labels.findInTable`, `findPlaceholder`,
`findMatchCount`, `findPrevious`, `findNext` and `findClose`.

Matching reads what a cell **shows**, so a column that renders a formatted date
is found by that date rather than by the ISO string underneath. Only the loaded
rows are searched: a hit the table cannot take you to would be a lie, so on a
paged table find covers the page you are on, and under virtualization it covers
what has been fetched. The **query** is part of the table's shareable URL state
(a `find` param beside `q`): opening a link that carries it reopens the bar,
runs the walk, and focuses the first hit. The current-match position stays
ephemeral — the receiving page's data may differ, so the query reproduces and
the cursor does not. Typing writes with replace-state after a short debounce so
browser history stays clean.

On mobile the cards are a list rather than a grid, so the bar opens and searches
but the hits are not marked; the desktop layout is where a cell can be pointed
at.

Headless: `findMatches`, `matchKeySet` and `stepMatch` in `@adapttable/core`,
`useFindInTable` in `@adapttable/react`. Each adapter mounts `FindBar` over
`FindBarChrome`.

## What the selection adds up to

Compose `selectionStats` from `@adapttable/<kit>/selection-stats` and a strip
under the table says what is selected:

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import { selectionStats } from "@adapttable/mantine/selection-stats";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[cellNavigation(), selectionStats()]}
/>;
```

> Count 12 · Sum 1,240.5 · Avg 103.4 · Min 12 · Max 900

Composing `selectionStats()` also adds the row-selection checkbox column. The
figures need `cellNavigation()`, which is what produces a cell range.

The count covers every selected cell; the arithmetic covers the numeric ones,
so a rectangle spanning a name column and a budget column still has a sum.
Numbers are read the way an export reads them — `exportValue` included — so the
total here and the total a spreadsheet computes from a paste of the same cells
cannot disagree. Booleans are not counted as numbers: summing a column of ticks
to 3 answers a question nobody asked.

A single cell shows nothing — it has no total worth reading, and a strip that
flickers in on every arrow press is noise. The strip is a status region, so a
screen reader reads the figures after the range announcement rather than
interrupting it, and every word is localizable (`labels.selectionCount`,
`selectionSum`, `selectionAverage`, `selectionMin`, `selectionMax`). Number
formatting follows the table's `locale`.

Selecting a column covers the LOADED rows, so the figures describe the 500 rows
in hand rather than the 100,000 in the dataset — the table never totals rows it
has never seen.

Headless: `selectionStats(options)` returns the figures;
`SelectionStatsChrome` passes formatted parts to an adapter-owned status bar.

## Selecting with the pointer, and whole columns

Drag across cells to select a block: the press anchors it, crossing a cell
extends it, and releasing anywhere — including outside the table — ends it.

A **column header** selects its whole column. Where the header already sorts,
sorting keeps the plain click and **Ctrl/Cmd+click** selects instead; on a header
that does not sort, a plain click selects. Ctrl/Cmd+click also extends an
existing selection to a second column.

A column selection covers the **loaded** rows. With 500 of 100,000 rows in hand
that is 500 cells, not 100,000 — the table never claims rows the browser has
never seen, because a copy or an export would then invent them.

### The header checkbox

`columnSelectionCheckbox()` adds a checkbox to every column header that selects
that column. The gesture above is unchanged; this is the same state reached two
ways it cannot be. A touchscreen has no Ctrl key to hold, and a gesture nothing
announces is a gesture nobody finds — so the control is what a finger taps and
what a screen reader reads, named `labels.selectColumn` plus the column's own
name ("Select column: Team", translated in every bundled locale).

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";
import { columnSelectionCheckbox } from "@adapttable/mantine/column-selection";

<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[cellNavigation(), columnSelectionCheckbox()]}
/>;
```

It needs `cellNavigation()`, because that is what makes a selection exist at all;
without it the header checkboxes do not render, and `columnSelectionCheckbox()`
alone adds only the row-selection column. Ticking selects the column, unticking clears
— nothing selected is the only state one checkbox can return to, since a
rectangle cannot lose a column out of its middle. A box reads as checked only
when the selection is exactly its column: inside a wider rectangle it stays
clear rather than saying the selection is one column when it is four.

Where the pointer can hover, the box holds its space and fades in on hover or
focus, so a wide header row is not a row of checkboxes and the layout never
moves; a selected column keeps its box on screen either way. Where there is no
hover — a touchscreen — it is always visible.

The control is `data-adapttable-part="column-select"` (`columnSelect` in
`@adapttable/unstyled`'s `classNames`), and it is each kit's own checkbox: core owns the layout, the
name, and keeping the click off the header underneath it, which would otherwise
sort the column the same click just selected.

Whenever the rectangle changes, the live region says what it now covers —
`"selected rows 1 to 2, columns 1 to 2, 4 cells"` — through
`labels.gridRangeSelection`, translated in every bundled locale. A single cell
stays silent: it announces itself already, and repeating "1 cell" on every arrow
press turns navigation into noise.

## The selection is visible, in each kit's own colour

Hold Shift while arrowing (or shift-click) and the extended range is filled with
the kit's own selected-cell token — Mantine's primary-light, MUI's
`action.selected`, Ant Design's active-item background, Radix's accent, and so
on. Nothing to configure.

Every selected cell also carries `data-cell-selected`, so CSS can target the
range directly. In `@adapttable/unstyled` there is no kit colour to borrow, so
the fill is yours through the `cellSelected` class hook (the shadcn preset sets
`bg-accent`).

## Example

```tsx
import { DataTable } from "@adapttable/mantine";
import { cellNavigation } from "@adapttable/mantine/cell-navigation";

<DataTable
  data={people}
  columns={columns}
  rowKey={(row) => row.id}
  features={[cellNavigation()]}
/>;
```

That is the whole opt-in. Omit it and nothing changes — see
[Off means absent](#off-means-absent).

## The keys

| Key                          | Where focus goes                                |
| ---------------------------- | ----------------------------------------------- |
| `↑` `↓` `←` `→`              | One cell, stopping at the edges                 |
| `Home` / `End`               | Start / end of the current row                  |
| `Ctrl`+`Home` / `Ctrl`+`End` | First / last cell of the whole grid             |
| `PageUp` / `PageDown`        | A viewport's worth of rows                      |
| `Enter` / `F2`               | Opens the editor, when the column is `editable` |
| `Ctrl`/`Cmd`+`C` / `X` / `V` | Copy, cut, paste the selected rectangle         |
| `Ctrl`/`Cmd`+`D`             | Fill the selection down from its top row        |
| `Tab`                        | Leaves the table — it is one stop, not hundreds |

Edges **stop rather than wrap.** Wrapping off the last column would move the
user to a different record without saying so; a table is not a spreadsheet.

Under `dir="rtl"` the left and right arrows swap, because arrow keys describe
the screen rather than the data — in a mirrored table the visually-next column
is the previous one. `Home` and `End` do not swap: they mean the start and end
of the row either way.

## What a screen reader hears

Focus alone announces a cell's contents, which is not navigation — "1,240" says
nothing about which column it belongs to or where it sits in a dataset whose end
you cannot see. So a live region announces the column, the cell's text, and the
absolute position:

> Budget, 1,240, row 40,002 of 100,000

The cell's text comes from [`columnText`](./columns.md), so a column whose cell
renders a badge or an avatar needs a `formatValue` to have anything readable.
The position phrase is localizable through `labels.gridCellPosition`, and ships
translated in every bundled locale.

## Virtualization

This is where a naive implementation is wrong and looks right. With 24 rows of
100,000 rendered, `aria-rowindex` must number rows **within the dataset**, not
within the window — otherwise assistive technology reports "row 3 of 24" while
the user is at row 40,000. AdaptTable carries absolute `aria-rowindex` /
`aria-colindex` and dataset-wide `aria-rowcount` / `aria-colcount`.

`Ctrl`+`End` on a 100,000-row table also asks for a cell that is not in the DOM
at all. Focus scrolls it into existence and lands on it once it mounts.

## Selecting a range

Hold **Shift** with any movement key, or shift-click a cell, and the selection
extends from where it began:

| Gesture                       | Result                          |
| ----------------------------- | ------------------------------- |
| `Shift`+`↑` `↓` `←` `→`       | Extend the rectangle one cell   |
| `Shift`+`Home` / `End`        | Extend to the row's start / end |
| `Shift`+`PageUp` / `PageDown` | Extend by a viewport of rows    |
| `Shift`+click                 | Extend to the clicked cell      |
| Any plain move                | Collapse back to a single cell  |

A range is stored as two corners — the **anchor** where it started and the
**head** where it reaches — not as a list of cells. That is what makes
Shift+Down twice then Shift+Up _shrink_ the range instead of starting a new one
upward, and what makes a 50,000-cell selection cost two numbers.

Selected cells carry `data-cell-selected` for styling. `aria-selected` appears
only once a real rectangle exists: marking every focused cell as selected would
tell a screen reader the table is in selection mode when the user has merely
arrowed around.

`cellNavigation({ onRangeChange })` tells the host the rectangle on mount and
whenever it changes — `null` when nothing beyond the focused cell is selected —
which is what a "sum of selection" readout of your own reads:

```tsx
const [range, setRange] = useState<CellRange | null>(null);

<DataTable
  {...props}
  features={[cellNavigation({ onRangeChange: setRange })]}
/>;
```

Headless: `useGridFocus({ onRangeChange })` reports every change, and
`gridFocus.range` holds the current rectangle — which is what
`exportCsv({ scope: "range" })` reads.

Headless: `CellRange` and `CellRangeBounds` are the shapes, `cellRangeBounds`
sorts the corners of a range dragged up or left, `isInCellRange` tests
membership, `cellRangeSize` multiplies rather than enumerating,
`extendCellRange` moves the head while keeping the anchor, `singleCellRange`
and `isSingleCell` handle the one-cell case, and `cellRangeIndices` enumerates
the rows and columns for an exporter — the one place that does.

## Mobile

Cell navigation applies to the **desktop table layout**. Mobile cards are a
list, not a grid: they keep list semantics, and arrow keys are not hijacked
there. This is deliberate — a card is one record's worth of stacked
label/value pairs, and a two-dimensional focus model does not describe it.

## Off means absent

Without `cellNavigation()` composed there is no `role="grid"`, no `tabIndex`, no key
handler, no live region, and no extra attributes. Not "disabled" — absent. A
core test asserts the markup is byte-identical to a table built without the
feature, and each of the eight React adapters asserts there is no grid role and no
focusable cell.

Focus position is also deliberately **not** saved to the URL or a Saved View.
Where the keyboard is sitting is ephemeral UI state, not part of a view someone
would share.

## Headless

| Export                                                    | Purpose                                                                                                                           |
| --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `useGridFocus` / `UseGridFocusOptions` / `GridFocusState` | The hook, its options, and the state it returns (`getGridProps`, `getCellPropsAt`, `getRowPropsAt`, `focusCell`, `announcement`). |
| `GridFocusAnnouncer` / `GridFocusAnnouncerProps`          | The live region, from `@adapttable/react/adapter`. Renders nothing when navigation is off.                                        |
| `moveGridFocus` / `GridFocusMove` / `GridBounds`          | The pure move arithmetic and its vocabulary — no React, no DOM.                                                                   |
| `gridFocusMoveForKey` / `GridKeyPress`                    | Maps a key press to a move, applying the RTL swap.                                                                                |
| `GridCell` / `sameGridCell`                               | A cell address, and address equality.                                                                                             |
| `GRID_CELL_ATTR` / `gridCellAttr`                         | The `data-grid-cell` attribute focus uses to find a cell in the DOM.                                                              |
| `contextMenuCopyTarget` / `ContextMenuCopyTarget`         | Decides what a context-menu Copy takes: the clicked cell, or the selection it landed inside.                                      |

### Copying from the context menu

The context menu names what was clicked by row key and column key. Copying
needs a grid address, and a row key is not one: where a row sits on screen
follows the sort, the filter, the page, the pinned rows and the virtual
window. `gridFocus.cellAt(rowId, columnKey)` resolves it against the very rows
and columns that grid was handed, so no adapter counts positions itself.

`contextMenuCopyTarget(gridFocus, target)` then decides what Copy takes:

```tsx
onCopy: (target) => {
  const copy = contextMenuCopyTarget(gridFocus, target);
  if (!copy.available) return;
  gridFocus.copyCells(copy.cell);
};
```

A cell with nothing selected copies that cell. A click inside a selection
keeps the selection — the rectangle is what the reader built. A click outside
one copies the cell under the cursor. A target that names no cell reports
`available: false`, and the menu greys Copy out rather than copying data
nobody pointed at. `useGridFocus` needs `getRowId` for `cellAt` to answer.

`getCellPropsAt(windowIndex, col)` and `getRowPropsAt(windowIndex)` take the
index an adapter already has — its position in the rendered rows — and convert
to the absolute address internally. That conversion lives in core precisely
because getting it wrong is invisible on screen.

## Notes

- Works in all eight React adapters, verified by the same parity test in each.
- Enter and F2 open the focused cell when its column is `editable`, and do
  nothing when it is not — so arrowing to a cell and pressing Enter edits it,
  which is the whole keyboard path.
- A click that focuses a cell moves the grid's focus too; in Safari, clicking
  the row-selection checkbox does not move focus into the grid.

---

# React table row grouping — interactive panel, nested groups & aggregates

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — the starter is grouped by role with salary subtotals (`groupingPanel("role", { groupAggregates })`); drag another header onto the panel, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [collapse groups and override aggregations in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/grouping/) — a real table you can click, not a recording. Independent pinned totals and the footer grand total live on the [aggregation page](https://adapttable.orwamahmoud.com/react/demo/mantine/aggregation/).

Each subtotal renders in its own column's cell, so it sits under the column it
totals — on a mobile card, where there are no columns to align to, the same
numbers appear captioned by their column instead. A custom renderer can place
them the same way with `groupRowLayout` and `groupAggregateEntries`.

Compose `groupingPanel` from `@adapttable/<kit>/grouping-panel`. It owns both
the grouped-row headers and the interactive panel, so use it instead of plain
`grouping()` when users should be able to configure grouping:

```tsx
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

<DataTable features={[groupingPanel()]} … />;
<DataTable
  features={[
    groupingPanel(["team", "status"], {
      groupAggregates: (rows) => ({ budget: sum(rows) }),
    }),
  ]}
  …
/>;
```

The first argument is an initial column key or ordered list; omit it to start
with an empty strip. The second argument accepts the ordinary grouping extras,
including `groupAggregates`, whose mapper has the **same signature as
`summaryRow`**. Omit the factory and the table mounts neither grouping headers
nor panel chrome. Plain `grouping("team")` from `@adapttable/<kit>/grouping` is
the no-panel path for a host that deliberately fixes grouping in code. See
[feature composition](./features.md).

## The grouping panel

On desktop, the feature adds a dedicated strip above the table. Drag any data
header into it to group — set `groupable: false` on a column and its header
offers no drag and the panel does not list it, the same opt-out shape as
`sortable` — drag the chips to change nesting order — onto another
chip to take its place, or onto the caret between two to land there — or drag a
chip to the remove target to ungroup it. A chip's focusable move control also
supports Arrow Up/Down and logical Arrow Left/Right; the latter reverse in RTL
so movement follows the visible order. Its remove button is the keyboard
dismiss route. Every add, move, removal, and aggregation change is announced
through a polite live region. The captions and announcement templates ship in
every bundled `@adapttable/i18n` locale.

A column decides what it buckets rows by. Without help a group is one distinct
sort value, which is right for a team or a status and wrong for anything
continuous — group by a timestamp and every row becomes its own group, headed
by the raw number. `groupValue` returns the bucket instead, and it is also the
group's caption:

```tsx
{
  key: "start",
  sortValue: (row) => row.start.getTime(),
  groupValue: (row) => monthOf(row.start),
}
```

Every layout also provides **Add grouping column**. Beside the grouping chips,
every active aggregate is its own item — column name, operation selector, and
remove — plus **Add aggregation column** and **Restore defaults** when the
developer sent defaults the reader has changed. Mobile uses these kit-native
controls rather than header drag-and-drop.

A column's `Aggregatable` declaration (`aggregatable`) decides what a reader
may add — `AggregatableConfig` with `default` and `operations` of
`AggregateOperation` values (a built-in name or a `CustomAggregateOperation`).
`resolveAggregatable` / `resolveAggregatableColumns` validate that offer;
`impliedOperations` is what `true` reads from the declared filter or editor
type. Developer defaults (`aggregatable.default`, a declared
`GroupAggregatesMapper` via `aggregate()`, or the original server
`aggregates` query) appear immediately. Removing a developer default records
an explicit suppression (`AGGREGATE_SUPPRESSED` / `"none"` in `groupAgg`);
`restoreAggregationDefaults` puts the original developer configuration back,
not the latest server response. The panel and column menu both read
`aggregationModel`. Display, local calculation and the server request share
`resolveEffectiveAggregation` (`EffectiveAggregationInput` /
`EffectiveAggregationKind`): a valid reader override or permitted
suppression, then a valid executable column default, then the original host
mapper or request. Defaults are not written into reader state — an untouched
table stays at defaults, and those defaults actually calculate and request.

```tsx
{
  key: "budget",
  aggregatable: {
    default: "sum",
    operations: ["sum", "avg", "min", "max", "count"],
  },
}
{
  key: "start",
  aggregatable: { operations: ["min", "max", "count"] },
}
{
  key: "score",
  aggregatable: {
    default: "median",
    operations: [
      "sum",
      "avg",
      { id: "median", label: "Median", calculate: median },
    ],
  },
}
```

`aggregatable: true` offers the operations that suit the column's **declared**
filter or editor type — never a sampled row, and never TypeScript's erased
value type. Numeric for `number` / `numberRange`; min/max/count for
`date` / `dateRange` / `datetime` / `time`; Count otherwise. A numeric column
without that metadata can still list explicit `operations`. `false` or omitted
refuses reader changes; a host mapper or server aggregate on that column still
shows — read-only when the reader cannot replace it, or as a Custom /
application-calculated item they may replace or suppress when they can.
Empty `operations` offers nothing. A custom `{ id, label, calculate }` is
addressed by `id`; omit `calculate` for an operation only the backend can
answer, and list that id on `supports.aggregateOperations`.

`min` and `max` compare numbers, `Date` values and strict ISO date / datetime /
time strings (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ssZ`,
`HH:mm`). Locale-dependent forms are skipped, not guessed. The winning
original value is what `formatAggregate` receives — a number, a `Date`, or
that ISO string — so a date column can format it without a numeric
`sortValue`. `count` still counts present values. An explicit `sortValue`
is respected first.

Grouping a column does not remove its aggregation offer. A grouped column
with Count (or any allowed operation) stays selectable; the group's row
count is not a substitute — missing values make those different.

`groupAgg` encodes both the column key and the operation id, so custom ids
with colons, commas, percents or Unicode round-trip. A built-in operation
encodes as `budget:sum`.

A computed aggregate is a number, and a number under a money column should
read as money. `formatAggregate` on the column says how one reads, and is
given the operation that produced it where the table knows it — the reader's
own choice, or the one a server was asked for — so a count reads as a count
under the same column:

```tsx
{
  key: "budget",
  accessor: (row) => money.format(row.budget),
  sortValue: (row) => row.budget,
  formatAggregate: (value, { aggregation }) =>
    typeof value !== "number" || aggregation === "count"
      ? value
      : money.format(value),
}
```

It runs where the cell is drawn — group header, group footer and mobile group
card alike — so the value the table holds, exports and compares stays the one
the aggregate returned.

**It composes with `aggregate({ format })`, and nothing stops it running
twice.** The two happen at different moments: `format` runs when the value is
computed, `formatAggregate` when the cell is drawn. Set both and the column's
formatter is handed whatever the mapper produced — a string, a node — and will
format that again unless it is written to pass non-numbers through, as the
example above does. The simplest arrangement is to leave group aggregates raw
and let the column own how they read; `format` still belongs on a mapper used
for `summaryRow`, which `formatAggregate` does not touch.

### Server tiers

On a server tier the operation is the one the request carried, and it is
published as of the response being drawn — so a column is told what the
numbers on screen were computed with, not what a request still in flight asks
for. Each request's operations are held against its own key, and only a
response that can be tied back to one of those requests moves what the table
publishes. A request that cannot be tied to one is reported as unknown:
`formatAggregate` is passed no operation, rather than one that may be wrong.

`useQuerySource` gets that association from the query itself: `dataUpdatedAt`
says whether the query showing these pages has an answer of its own, read
against the request it belongs to — two cached results can carry the same
stamp, so a stamp alone is not identity.

`useServerData` cannot see any of it. A controlled tier's rows simply change,
sometimes as the very array that was already there, and a cancelled request
looks exactly like an answered one from the outside. So `onQueryChange` hands
the handler an `info.key` naming the request, and `responseKey` is where the
answer's key comes back:

```tsx
const [rows, setRows] = useState<Person[]>([]);
const [answered, setAnswered] = useState<string>();

<DataTable
  mode="server"
  data={rows}
  total={total}
  loading={loading}
  responseKey={answered}
  onQueryChange={async (query, { signal, key }) => {
    const res = await fetch(`/api/people?${toParams(query)}`, { signal });
    const page = await res.json();
    setRows(page.items);
    setAnswered(key);
  }}
/>;
```

**A controlled tier needs `responseKey` to be accurate about this.** Without
it, rows retained through an in-flight or failed request keep the operations
they came with, but once a request simply stops the rows on screen are
described as unknown — a stopped request is what an abort looks like too, and
the table will not name an operation it cannot place. The same holds for a
hand-rolled `TableSource`: publish `groupAggregations` yourself, or leave it
unset and the table falls back to the reader's own choices. An aggregate
function only the server understands is reported as unknown rather than
guessed at.

Compose `columnMenu()` too and each column row gains **Group by…** or
**Ungroup…**. While grouping is active, an ungrouped column also offers the
same aggregation choice. Both routes write the same `groupBy` / `groupAgg`
state, so the panel, menu, URL, and Saved Views cannot disagree.

## Paging groups, and paging inside one

A table grouped by customer can have ten thousand groups. `groupPageSize` shows
a screenful and offers the rest; `groupRowPageSize` does the same for the rows
inside each group:

```tsx
import { DataTable } from "@adapttable/mantine";
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

<DataTable
  data={ORDERS}
  columns={columns}
  rowKey={rowKey}
  features={[
    groupingPanel("customer", {
      groupPageSize: 25,
      groupRowPageSize: 10,
    }),
  ]}
/>;
```

Each limit adds one row — "Show 42 more groups", "Show 8 more in this group" —
that reveals the next page when clicked. Only the **top level** pages: a nested
level is already inside a group the reader opened, and hiding part of what they
just opened would be a second "more" to hunt for.

On a **server tier** the rest of a group is not in the browser yet, so
`onGroupLoadMore(groupKey)` fires with the group that needs filling. Fetch it,
hand back a longer `rows` for that group, and the next render shows them; the
table reveals whatever it already holds either way. Both labels are localizable
(`labels.moreGroups`, `labels.moreRowsInGroup`) in every bundled locale, and the
rows carry `group-more-row` / `group-more-cell` / `group-more` parts (with
`groupMoreRow` / `groupMoreCell` class hooks in `@adapttable/unstyled`).

## Grouping on the server

A backend that can group is usually the only thing that can: it has the whole
dataset, and the browser has a page of it. Declare the capability and the table
sends the grouping keys with every query:

```tsx
import { DataTable, useQuerySource } from "@adapttable/mantine";
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

const source = useQuerySource<Person, Params, Page>({
  usePaginatedQuery: usePeopleQuery,
  selectPage: (page) => ({ rows: page.rows, total: page.total }),
  supports: { grouping: true, aggregates: true },
  aggregates: [{ key: "budget", fn: "sum" }],
});

<DataTable
  source={source}
  columns={columns}
  rowKey={rowKey}
  features={[groupingPanel("team")]}
/>;
```

`query.groupBy` arrives as an **array**, outermost key first — one entry for a
flat grouping, more for nested — and `query.aggregates` as
`[{ key, fn }]`. Neither is sent unless the source declared the matching
capability, so an endpoint that predates this never sees a field it does not
understand.

### The response shape

```ts
interface QueryGroupRow<TRow> {
  value: unknown; // the group's value, shown as its label
  count: number; // leaves beneath it, across the dataset
  aggregates?: Record<string, unknown>; // by column key
  groups?: QueryGroupRow<TRow>[]; // nested levels, when asked for
  rows?: TRow[]; // leaves, when the server includes them
}
```

They reach the table through the `groups` field of a `TableSource`
(`readonly groups?: readonly QueryGroupRow<TRow>[]`). `useQuerySource` and
`useServerData` send the grouping request and leave `groups` unset, so a
server-grouped tier is a `TableSource` that sets `groups` from the response.
The table renders them exactly as it renders local groups — same headers, same collapsing, same footers, same
selection. **The counts and aggregates are the server's**: a group of 4,000
whose response carried 20 rows says 4,000, because counting what arrived would
be a number the user can see is wrong.

A reference endpoint, grouping one level and returning the first page of each
group's rows:

```ts
app.get("/people", async (req, res) => {
  const groupBy = [req.query.groupBy ?? []].flat(); // e.g. ["team"]
  if (groupBy.length === 0) return res.json(await plainPage(req));

  const key = groupBy[0];
  const rows = await db
    .select(key, db.raw("count(*) as count"), db.raw("sum(budget) as budget"))
    .from("people")
    .groupBy(key)
    .orderBy("count", "desc");

  res.json({
    total: rows.reduce((sum, row) => sum + Number(row.count), 0),
    groups: await Promise.all(
      rows.map(async (row) => ({
        value: row[key],
        count: Number(row.count),
        aggregates: { budget: Number(row.budget) },
        rows: await db
          .select("*")
          .from("people")
          .where(key, row[key])
          .limit(20),
      }))
    ),
  });
});
```

Leave `rows` out to send counts only — the headers render with their counts and
nothing beneath them, which is the right answer for a table of a million rows
in a hundred groups. Fetch a group's rows when it opens by reacting to
`onCollapsedGroupIdsChange`: it tells you exactly which group the user just
expanded, and the next response fills it in.

**Without the capability**, a server tier that is asked to group says so in
the status bar and in development, and renders ungrouped rather than grouping
the page it happens to hold, which would be one page's worth of groups
presented as the whole set.

## Controlling what is open

Groups start expanded and collapse on their own. To hold that state yourself,
pass the pair:

```tsx
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

const [closed, setClosed] = useState<string[]>([]);

<DataTable
  data={PEOPLE}
  columns={columns}
  rowKey={(r) => r.id}
  features={[
    groupingPanel(["team", "status"], {
      collapsedGroupIds: closed,
      onCollapsedGroupIdsChange: setClosed,
    }),
  ]}
/>;
```

The set names what is **closed**, because groups default to open — so an empty
set is a fully expanded table, and the state stays small no matter how many
groups there are. Each key carries the group's whole path, so nesting needs no
extra bookkeeping.

**In the URL**, with `useGroupCollapseUrlState`:

```tsx
import { groupingPanel } from "@adapttable/mantine/grouping-panel";
import { useGroupCollapseUrlState } from "@adapttable/react";

const groups = useGroupCollapseUrlState({ urlKey: "people" });

<DataTable
  {...groups}
  features={[groupingPanel("team")]}
  columns={columns}
  rowKey={rowKey}
/>;
```

A link then carries which groups were folded — part of what someone means when
they send one. Keys are percent-encoded, so a label containing a comma cannot
split the list, and the parameter disappears when everything is open again.

**Whole-tree actions** live on the table's grouping bundle, for a host that
wants its own buttons: `expandAll()`, `collapseAll()`, and
`collapseToDepth(depth)` — depth `0` leaves only the outermost headers showing,
`1` opens the first level inside them.

## Ordering and filtering groups

`groupSort` orders the groups inside their parent, and `groupFilter` decides
which of them are worth showing:

```tsx
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

<DataTable
  data={SALES}
  columns={columns}
  rowKey={(r) => r.id}
  features={[
    groupingPanel("region", {
      groupAggregates: (rows) => ({ amount: sum(rows) }),
      // Biggest region first — the same rows the aggregate is computed from.
      groupSort: (a, b) => sum(b.leafRows) - sum(a.leafRows),
      // And only the regions worth a line.
      groupFilter: (group) => sum(group.leafRows) >= 10_000,
    }),
  ]}
/>;
```

`groupSort` also takes `"label"`, `"label-desc"`, `"count"` and `"count-desc"`.
**To sort by an aggregate, compare the leaves** — an aggregate is a function of
its rows, and comparing the rendered aggregate cell would mean comparing
ReactNodes, which is not an ordering. Both props apply at every level of a
nested group.

### The order things happen in

1. **Row filters and search** run on the source, exactly as they do without
   grouping. Grouping never sees a row a filter removed.
2. **Grouping** partitions what survived, one level per `groupBy` key.
3. **`groupFilter`** drops whole groups — a dropped group takes its leaves with
   it, so the counts and totals that remain describe what is on screen.
4. **`groupSort`** orders the groups within each parent. Without it they keep
   the order the source's own sort produced.
5. **Leaf order inside a group is the source's**, always. Sorting a column sorts
   the rows within each group; it does not reorder the groups themselves —
   that is `groupSort`'s job, and keeping them separate is what lets you sort
   rows by name inside groups ordered by total.

Collapsed state is keyed by the group's path, so reordering or filtering groups
never opens or closes anything by accident.

## Footers and grand totals

`groupFooters` closes every group with a row carrying the same aggregates its
header carries:

```tsx
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

<DataTable
  data={PEOPLE}
  columns={columns}
  rowKey={(r) => r.id}
  features={[
    groupingPanel("team", {
      groupAggregates: (rows) => ({ budget: sum(rows) }),
      groupFooters: true,
    }),
  ]}
  summaryRow={(rows) => ({ budget: sum(rows) })}
/>;
```

The totals then read at the bottom of a group as well as the top — which is
where the reader of a long group is by the time they want them. A footer shows
no chevron and no checkbox: the header already owns both. Nested groups each get
their own, innermost first, and a **collapsed** group shows none at all — its
header is already carrying the numbers, with nothing between them.

`summaryRow` is the table's grand total, and under grouping it totals the whole
filtered set rather than a page of it. The two compose: per-group footers, one
grand total.

On mobile the footer is a card of its own after the group's cards, captioned the
same way. Exports are unaffected — a footer is chrome, not a row, so a CSV
carries the data and nothing else.

Each footer is captioned through `labels.groupTotal`, translated in every
bundled locale, and carries `data-adapttable-part="group-footer-row"` /
`group-footer-cell` (plus the `groupFooterRow` / `groupFooterCell` class hooks
in `@adapttable/unstyled`).

## Nested groups

`groupingPanel` also takes an ordered list, and each key nests inside the one
before it:

```tsx
features={[groupingPanel(["team", "status"])]}
```

> Core (12)
> &nbsp;&nbsp;active (7)
> &nbsp;&nbsp;blocked (5)
> Platform (9)
> &nbsp;&nbsp;active (9)

Every header describes its **whole subtree**: the count beside "Core" is all
twelve of its people, and its `groupAggregates` cells total the same twelve.
Deeper levels indent by logical padding, so nesting mirrors in Arabic and
Hebrew without a second rule.

Each node collapses on its own — "Core > blocked" and "Platform > blocked" are
different groups with different keys, so closing one leaves the other open, and
closing a parent hides its whole subtree in one step. Collapsed keys serialize
exactly as they did with one level.

In the URL and in Saved Views the keys travel as one comma-separated value —
`?groupBy=team,status`. Session aggregation overrides travel beside them as
`groupAgg`, for example `groupAgg=budget:sum,headcount:count`. A single key
(`?groupBy=team`) and a list parse the same way, and `onGroupByChange` reports
the keys as a list.

## Example

```tsx
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

interface Person {
  id: string;
  name: string;
  team: string;
  budget: number;
}

const PEOPLE: Person[] = [
  { id: "1", name: "Aisha", team: "Core", budget: 42_000 },
  { id: "2", name: "Jonas", team: "Platform", budget: 38_000 },
  { id: "3", name: "Mei", team: "Core", budget: 51_000 },
];

export function People() {
  return (
    <DataTable
      data={PEOPLE}
      columns={[
        { key: "name", sortable: true },
        { key: "team", sortable: true },
        {
          key: "budget",
          accessor: (r) => `$${r.budget.toLocaleString()}`,
          sortValue: (r) => r.budget,
        },
      ]}
      rowKey={(r) => r.id}
      features={[
        groupingPanel("team", {
          groupAggregates: (rows) => ({
            budget: (
              <b>
                ${rows.reduce((sum, r) => sum + r.budget, 0).toLocaleString()}
              </b>
            ),
          }),
        }),
      ]}
    />
  );
}
```

## How it works

- **Opt-in.** Compose `groupingPanel()` for user-configurable grouping. The
  source and URL state carry the resolved grouping value, but neither imports
  or mounts the feature.
- **One key or a list.** `groupingPanel("team")` starts with one level;
  `groupingPanel(["team", "status"])` starts nested, and users can add,
  remove, or reorder those keys from the panel.
- **Somewhere that can group.** Either the full filtered set is in memory
  (`allFilteredRows`) or the server returns group rows. A source that can do
  neither — `capabilities.grouping: false`, declared or inferred — renders
  ungrouped, says so in the status bar and warns in development. See
  [source capabilities](./data-tiers.md#what-a-source-can-do--capabilities).
- **Shared mapper.** `groupAggregates(rows)` uses the same `(rows) => cells`
  shape as `summaryRow` (`SummaryRowFn` on `@adapttable/react`); reuse
  one function for both if the math is identical — or build both with
  `aggregate()` (below).
- **Session aggregation overrides.** Each active column has its own operation.
  Reader choices overlay the developer's `aggregatable.default`, declared
  `groupAggregates`, or original `query.aggregates`. Removing a developer
  default writes `none`; **Restore defaults** clears the override map. The
  choices serialize as `groupAgg`, including host-defined ids.
- **Expand / collapse.** Groups start expanded. Collapse state can be paired
  with `useGroupCollapseUrlState`; `groupBy` and `groupAgg` use the table's
  ordinary URL state.
- **Selection.** When row checkboxes are enabled, each group header exposes a
  tri-state checkbox over its leaf rows.

## Aggregate without writing the maths

Every aggregation option — `aggregate()`, `aggregatable` columns, custom
operations and server aggregates — is on the [aggregation](./aggregation.md) page.

The mapper above is a function you write. When the sums are ordinary, declare
them instead and `aggregate()` returns that same mapper:

```tsx
import { aggregate } from "@adapttable/core";
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

<DataTable
  features={[
    groupingPanel("role", {
      groupAggregates: aggregate({ budget: "sum", team: "count" }, { columns }),
    }),
  ]}
  summaryRow={aggregate({ budget: "sum" }, { columns })}
  columns={columns}
  // …
/>;
```

Built in: `sum`, `avg`, `count`, `min`, `max`. Pass your own function for
anything else — it receives the values found for that column and returns the
cell:

```tsx
const distinct = (values) => new Set(values).size;
groupingPanel("team", { groupAggregates: aggregate({ team: distinct }) });
```

Passing `columns` lets values resolve through a column's `sortValue`, exactly
as sorting and grouping do, so a formatted cell still aggregates on its
underlying number. Add `format` to shape the result for display:

```tsx
aggregate(
  { budget: "sum" },
  { columns, format: (v) => (typeof v === "number" ? money.format(v) : v) }
);
```

Two behaviours worth knowing, because they are choices rather than accidents:
a missing value is skipped rather than counted as zero, so `count` reports the
values a column actually has; and while `sum` of nothing is `0`, `avg`, `min`
and `max` of nothing are `undefined` — an average of no numbers is
unanswerable, not zero.

## Reorder inside a group or move between groups

Compose `groupingPanel` and the kit's `rowReorder` feature together. A reorder
that stays inside one leaf group calls the ordinary handler with positions
scoped to that group. Crossing a boundary — by drag, arrows, mobile controls,
or **Move to group…** — calls
`onGroupMove(row, fromGroup, toGroup, position)`, passed in the second argument
of `rowReorder`:

```tsx
import { groupingPanel } from "@adapttable/mantine/grouping-panel";
import { rowReorder } from "@adapttable/mantine/row-reorder";

features={[
  groupingPanel("team"),
  rowReorder(onRowReorder, { onGroupMove, movePolicy: "confirm" }),
]}
```

`RowGroupRef.levels` gives the host every grouping column and raw destination
value. `movePolicy` is `"never"` by default, `"confirm"` for a kit-native
confirmation, or `"auto"` for an immediate host write. See
[row reordering](./row-reordering.md) for the complete contract.

## Options

`groupingPanel(groupBy?, extras?)` — omit the first argument for an initially
empty user-configurable panel, or pass a column key / ordered list. Companion
options go in the second argument:

| Field / prop                | Type                                                            | Default | Description                                                                                       |
| --------------------------- | --------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------- |
| `groupBy` (1st arg)         | `string \| readonly string[]`                                   | `[]`    | Initial ordered grouping keys. The panel, composed column menu, URL, and source can change them.  |
| `onGroupByChange`           | `(groupBy: readonly string[]) => void`                          | —       | Controlled change channel; falls back to `source.setGroupBy`.                                     |
| `groupAggregates`           | `(rows: readonly TRow[]) => Partial<Record<string, ReactNode>>` | —       | Per-group cells — **same signature as `summaryRow`**. Omit for headers without subtotals.         |
| `groupSort`                 | `GroupSort<TRow>`                                               | —       | `"label"`, `"label-desc"`, `"count"`, `"count-desc"`, or `(a, b) => number` over `GroupNode`s.    |
| `groupFilter`               | `(group: GroupNode<TRow>) => boolean`                           | —       | Keep only the groups this accepts — each group with its `value`, `label`, `level` and `leafRows`. |
| `groupFooters`              | `boolean`                                                       | `false` | Close every group with a footer row carrying its aggregates.                                      |
| `groupPageSize`             | `number`                                                        | —       | Top-level groups shown before a "Show more groups" row.                                           |
| `groupRowPageSize`          | `number`                                                        | —       | Rows shown per group before a "Show more in this group" row.                                      |
| `onGroupLoadMore`           | `(groupKey: string) => void`                                    | —       | Server tier: fetch the rest of a group.                                                           |
| `collapsedGroupIds`         | `readonly string[]`                                             | —       | Controlled collapsed group keys (ephemeral — not URL-synced).                                     |
| `onCollapsedGroupIdsChange` | `(ids: string[]) => void`                                       | —       | Controlled collapse channel; uncontrolled mode uses internal state.                               |

`labels` on `DataTable` overrides group headers, panel controls, menu actions,
aggregation names, and announcement templates.

## Grouped tables are a full-set view

With grouping active the table renders **every filtered row** (grouped),
and the chrome agrees with the screen: the footer count describes the
rendered set, header select-all covers all rendered rows, page-scope CSV
export contains exactly what you see, and the rows-per-page control hides
(page size has no effect). Ungroup to return to normal pagination.

## Headless grouping

The grouping model is exported so custom tables can render the same flat
structure the adapters do, at one level or nested:

| Export                                         | Purpose                                                                                        |
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| `buildGroupedFlatModel` / `GroupedFlatEntry`   | Partition leaf rows into a flat list — group header, then its leaves (omitted when collapsed). |
| `groupValueKey`                                | Stable, type-tagged string key for a group bucket (`5` and `"5"` never share one).             |
| `useGroupCollapse` / `GroupCollapseState`      | Ephemeral collapse state — groups default to expanded; not URL-synced.                         |
| `GroupAggregatesFn`                            | The `(rows) => Partial<Record<string, DisplayValue>>` mapper shared with `summaryRow`.         |
| `formatGroupLabel`                             | The header label for a bucket value (localized blank-value fallback included).                 |
| `groupSelectionState` / `HeaderSelectionState` | Tri-state for a group checkbox over its leaf ids — the same enum the header select-all uses.   |
| `windowGroupedEntries`                         | Slice a flat grouped model to a virtual window (see [Virtualization](./virtualization.md)).    |

The panel state is headless too. `GroupAggregateOverride` is an
`AggregateOperationId` (a built-in name or a custom operation id) or `"none"`,
and
`GroupAggregateOverrides` maps column keys to those choices.
`serializeGroupAggregateOverrides` / `parseGroupAggregateOverrides` implement
the `groupAgg` codec; `serializeAggregationDerivedKey` is the cache key for
the same effective operations. `withGroupAggregateOverrides` overlays choices on a
client mapper, and `withQueryAggregateOverrides` overlays them on server
aggregate requests. `GroupingPanel` on `@adapttable/<kit>/grouping-panel` is the
kit-wired panel for a host that places it itself; `useGroupPaging` (on
`@adapttable/react`) holds the "show more" state. Adapter authors build the
panel with `GroupingPanelChrome` and its required slots.

## Notes

- Bucketing uses the column's `groupValue`, then its `sortValue`, then a path
  lookup on the column's data path — its `i18n` entry for the active
  `locale`, else its key — never the JSX `accessor`.
- Works on desktop rows and mobile cards, LTR and RTL, with and without the
  `virtualize()` feature (virtual windows count collapsed groups as one row).
- The grouping panel configures row grouping and per-group aggregates.
  Pivoting remains a separate model — see [Pivot](./pivot.md).
- Ant Design maps group headers onto its high-level `Table` via custom row
  rendering; every other kit renders native group header rows/cards.

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/) —
rows are grouped by team with a budget subtotal per group.

---

# React table aggregation — aggregate(), summaryRow & group totals

▶ **See it working:** [group totals, a footer grand total and pinned summaries in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/aggregation/) — filter the table and every total recomputes from the rows that remain. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

Aggregation turns rows into numbers the table shows beside them: a grand
total in the footer, a subtotal on every group, a reader's own choice of
Sum or Average under a column. All of it is opt-in. A table with no
`summaryRow`, no aggregating column and no grouping feature computes
nothing and renders no summary chrome.

There are three places a total can live, and each has one owner:

| Where                               | Declared with                                   | Computed from                       |
| ----------------------------------- | ----------------------------------------------- | ----------------------------------- |
| The table footer                    | `summaryRow` on `DataTable`                     | the rows the table is showing       |
| Every group's header and footer     | a column's `aggregatable`, or `groupAggregates` | each group's leaf rows              |
| Sticky rows above or below the list | `pinnedSummaryRows({ top, bottom })`            | nothing — the objects are host data |

## Example

```tsx
import { type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { groupingPanel } from "@adapttable/mantine/grouping-panel";
import { aggregate } from "@adapttable/react";

interface Deal {
  id: string;
  owner: string;
  region: string;
  amount: number;
  closedOn: string; // ISO date
}

const deals: Deal[] = [
  {
    id: "1",
    owner: "Aisha",
    region: "EMEA",
    amount: 42_000,
    closedOn: "2026-03-14",
  },
  {
    id: "2",
    owner: "Jonas",
    region: "EMEA",
    amount: 18_500,
    closedOn: "2026-05-02",
  },
  {
    id: "3",
    owner: "Mei",
    region: "APAC",
    amount: 61_000,
    closedOn: "2026-04-21",
  },
  {
    id: "4",
    owner: "Tom",
    region: "APAC",
    amount: 9_800,
    closedOn: "2026-06-30",
  },
];

const money = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const columns: ColumnDef<Deal>[] = [
  { key: "owner", sortable: true },
  { key: "region", sortable: true },
  {
    key: "amount",
    accessor: (row) => money.format(row.amount),
    sortValue: (row) => row.amount,
    filter: "numberRange",
    // Group cells start as Sum; readers may switch to any listed operation.
    aggregatable: {
      default: "sum",
      operations: ["sum", "avg", "min", "max", "count"],
    },
    // A count stays a count; every other number reads as money.
    formatAggregate: (value, { aggregation }) =>
      typeof value === "number" && aggregation !== "count"
        ? money.format(value)
        : String(value ?? ""),
  },
  // `true` reads the declared filter type: a date column offers Min, Max, Count.
  {
    key: "closedOn",
    header: "Closed",
    filter: "dateRange",
    aggregatable: true,
  },
];

export function Deals() {
  return (
    <DataTable
      data={deals}
      columns={columns}
      rowKey={(row) => row.id}
      summaryRow={aggregate(
        { amount: "sum" },
        {
          columns,
          format: (value) =>
            typeof value === "number" ? money.format(value) : value,
        }
      )}
      features={[groupingPanel("region", { groupFooters: true })]}
    />
  );
}
```

Each region opens with a Sum of `amount` in the amount column and closes
with a footer carrying the same number. The table footer holds the grand
total. From the grouping panel a reader can switch the amount to Average,
add Closed with Min or Max, or remove either.

## How it works

- **One mapper shape.** `summaryRow` and `groupAggregates` both take
  `(rows) => cells`: rows in, a record of cells keyed by column out. A key
  the record leaves out renders an empty cell. Write that function by hand,
  or let `aggregate()` build it.
- **`summaryRow` totals what the table shows.** On a paged table that is
  the current page. With grouping active the table renders every filtered
  row, and the footer totals all of them. It renders as a footer row
  aligned under its columns; `renderFooter` on a column replaces one of
  its cells and receives the mapper's value as `value`.
- **Group cells come from the columns.** A column's `aggregatable.default`
  calculates on every group with no mapper at all, on `grouping()` and
  `groupingPanel()` alike. `groupAggregates` in the grouping extras adds
  host-computed cells; reader choices overlay both.
- **Each subtotal sits under its column.** Group headers and footers place
  an aggregate in the cell of the column it totals. On a mobile card the
  same numbers are captioned by their column instead. The footer summary
  closes the card list as one card of its own.
- **Missing is not zero.** `undefined` and `null` are skipped before an
  aggregator runs, so `count` counts values a column actually has. `sum` of
  nothing is `0`; `avg`, `min` and `max` of nothing are `undefined`.

## Build a mapper with `aggregate()`

`aggregate(spec, options?)` returns the mapper from a declaration. The
spec maps a column key to a built-in name — `sum`, `avg`, `count`, `min`,
`max` — or to your own function, which receives the values found for that
column and returns the cell:

```tsx
const distinct = (values: readonly unknown[]) => new Set(values).size;

const amountSum = aggregate({ amount: "sum" }, { columns });
const summary = aggregate({ owner: distinct, amount: "avg" }, { columns });

<DataTable
  data={deals}
  columns={columns}
  rowKey={(row) => row.id}
  summaryRow={amountSum}
  features={[
    groupingPanel("region", { groupAggregates: amountSum, groupFooters: true }),
  ]}
/>;
```

Pass `columns` and values resolve through each column's `sortValue`, the
way sorting and grouping resolve them, so a money-formatted cell still
aggregates on its number. Without `columns`, values come from the key's
data path. `format(value, key)` shapes the result when it is computed.

`@adapttable/react` exports `aggregate` typed for React cells, so it drops
into `summaryRow` directly. `@adapttable/core` exports the same helper with
a framework-neutral cell type, for code that does not render React.

A mapper built by `aggregate()` also tells the table what it computes. The
grouping panel lists those columns under their operation names. A
hand-written mapper names no operation: its cells are listed as **Set by
the app**, or as a **Custom** item a reader may replace when the column is
`aggregatable`.

`min` and `max` compare numbers, `Date` values and strict ISO date,
datetime and time strings (`YYYY-MM-DD`, `YYYY-MM-DDTHH:mm:ssZ`, `HH:mm`).
Locale-formatted dates are skipped. The winning original value is what the
cell receives: a number, a `Date`, or that ISO string.

## Let readers choose: `aggregatable`

A column's `aggregatable` says whether a reader may aggregate it and with
which operations. It takes `false` (the default), `true`, or an
`AggregatableConfig`:

- `true` offers what suits the column's **declared** filter or editor
  type: all five for `number` / `numberRange`, Min / Max / Count for
  `date` / `dateRange` / `datetime` / `time`, and Count otherwise. It never
  samples row values.
- `{ operations, default? }` lists exactly what to offer. `default` must be
  one of `operations`; without it the column is on offer but inactive until
  a reader adds it. An empty `operations` list offers nothing.
- `false` or omitted refuses reader changes. A `groupAggregates` cell or a
  server aggregate on that column still shows, read-only.

`groupable: false` is a separate switch: it stops a column being grouped
by. Grouping a column does not remove its aggregation offer.

### Formatting with `formatAggregate`

`formatAggregate(value, context)` on a column decides how its aggregate
reads. `context.columnKey` names the column and `context.aggregation` the
operation that produced the value, when the table knows it — the reader's
choice, a column default, an `aggregate()` declaration, or the operation a
server was asked for. A hand-written mapper declares nothing, so
`aggregation` is absent for its cells.

It runs where a group cell is drawn — header, footer and mobile group card
— so the value the table holds, compares and exports stays the one the
aggregate returned. `summaryRow` cells do not pass through it; format those
with `aggregate({ … }, { format })`.

With both set on a group mapper, `format` runs first and `formatAggregate`
receives its result. Leave group aggregates raw and let the column own
presentation, or write `formatAggregate` to pass non-numbers through.

### Custom operations

An operation can be your own `CustomAggregateOperation` — an `id`, a
`label`, and optionally `calculate` and `description`:

```tsx
const median = (values: readonly unknown[]) => {
  const sorted = values
    .filter((value): value is number => typeof value === "number")
    .sort((a, b) => a - b);
  if (sorted.length === 0) return undefined;
  const mid = Math.floor(sorted.length / 2);
  return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
};

{
  key: "amount",
  aggregatable: {
    default: "median",
    operations: [
      "sum",
      "avg",
      { id: "median", label: "Median", calculate: median },
      {
        id: "p90",
        label: "90th percentile",
        description: "The amount 90% of deals in the group stay under.",
      },
    ],
  },
}
```

- `id` is what state, the URL and a server request carry, so keep it
  stable. It cannot reuse a built-in name, `none` or `custom`; an invalid,
  duplicate or unknown entry is dropped with a development warning.
- `label` is what the reader sees. Built-in names are localized through
  `labels`; a custom label is yours to translate.
- `calculate` runs in the browser. Leave it out for an operation only the
  backend can answer — the table then offers it only when the source lists
  it in `supports.aggregateOperations`.
- `description` says what the operation means to anything reading the
  table rather than looking at it, such as an agent.

## Per-group aggregates and the grouping panel

`groupingPanel()` from `@adapttable/<kit>/grouping-panel` lists every
active aggregate as its own item — column name, operation selector, remove
— with **Add aggregation column**, and **Restore defaults** once the
reader has moved away from what the developer declared. Compose
`columnMenu()` as well and, while grouping is active, each column's menu
offers the same choice. Both surfaces read one `AggregationModel`, so a
column offers the same operations wherever a reader meets it. Every change
is announced through a polite live region.

Plain `grouping()` from `@adapttable/<kit>/grouping` has no panel, but it
still calculates column defaults, `groupAggregates` and choices restored
from the URL. Building, nesting and reordering group levels, group
footers, group sorting and paging are on the
[row grouping](./row-grouping.md) page.

### Reader choices: `GroupAggregateOverride`

A reader's choice for one column is a `GroupAggregateOverride`: an
operation id, or `"none"` (`AGGREGATE_SUPPRESSED`) when they removed an
aggregate the developer declared. `GroupAggregateOverrides` maps column
keys to those choices. They overlay the developer's setup and never
replace it — an untouched table has no overrides and stays at its
defaults, which still calculate.

`resolveEffectiveAggregation` from `@adapttable/core` decides what one
column shows, calculates and requests, in this order:

1. a valid reader override, or a permitted suppression (`override`,
   `suppressed`);
2. the column's `aggregatable.default`, when the source can execute it
   (`default`);
3. what the host's mapper or original server request already computes
   (`host`);
4. otherwise nothing (`inactive`).

A stale or disallowed id from any entry point — the panel, a URL, a saved
view — fails step 1 and falls through. `"none"` only removes a cell the
reader is allowed to control; a read-only host aggregate stays.

## Pinned totals

A total that is not computed from the visible rows — a target, a prior
period, a figure your backend returns — belongs in `pinnedSummaryRows`
from `@adapttable/<kit>/pinned-summary-rows`. Those rows stick above or
below the scroll window and never enter sort, filter, grouping, pagination
or selection. See [pinned summary rows](./pinned-summary-rows.md).

## Aggregates on the server

A server tier computes the aggregates itself. Declare the capability and
list the aggregates to request:

```tsx
import { DataTable, useQuerySource } from "@adapttable/mantine";
import { groupingPanel } from "@adapttable/mantine/grouping-panel";

const source = useQuerySource<Deal, Params, Page>({
  usePaginatedQuery: useDealsQuery,
  selectPage: (page) => ({ rows: page.rows, total: page.total }),
  supports: {
    grouping: true,
    aggregates: true,
    aggregateOperations: ["sum", "avg", "min", "max", "count", "p90"],
  },
  aggregates: [{ key: "amount", fn: "sum" }],
  columns,
});

<DataTable
  source={source}
  columns={columns}
  rowKey={(row) => row.id}
  features={[groupingPanel("region")]}
/>;
```

- `query.aggregates` arrives as `[{ key, fn }]` and is sent only when the
  source declares `supports.aggregates`. With grouping active the server
  computes them per group and returns them on each `QueryGroupRow`'s
  `aggregates`; without grouping, over the whole result set.
- Reader choices and column defaults layer over the `aggregates` you
  declared before the request is sent. `columns` lets the source refuse an
  operation a column does not allow before it reaches the server.
- `supports.aggregateOperations` is the allowlist of ids the backend can
  compute. Without it, `aggregates: true` means the five built-ins; a local
  `calculate` is not proof the server knows an id.
- `formatAggregate` is told the operation the response on screen was
  computed with, not one still in flight. `useServerData` takes the same
  `supports`, `aggregates` and `columns`; a controlled `mode="server"` table
  echoes `responseKey` so the table can tie a response to its request. See
  [server tiers](./row-grouping.md#server-tiers).
- The table has no slot for a whole-set answer. Read it from your response
  and render it with `summaryRow` or `pinnedSummaryRows`.

## Options

`aggregate(spec, options?)` — `AggregateSpec` maps a column key to a
built-in name, a name registered with `registerAggregator`, or an
`Aggregator` function. `AggregateOptions<TRow>`:

| Option    | Type                                                                           | Default | Description                                                                           |
| --------- | ------------------------------------------------------------------------------ | ------- | ------------------------------------------------------------------------------------- |
| `columns` | `readonly ColumnMetadata<TRow>[]`                                              | —       | Resolve values through each column's `sortValue`; without it, the key's data path.    |
| `format`  | `(value: DisplayValue \| undefined, key: string) => DisplayValue \| undefined` | —       | Shape the computed value. Runs when the value is computed.                            |
| `host`    | `FeatureHostState`                                                             | —       | The table the mapper runs in, for a call made outside one. The table binds it itself. |

Column fields:

| Field             | Type                                                    | Default | Description                                                                                    |
| ----------------- | ------------------------------------------------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `aggregatable`    | `boolean \| AggregatableConfig`                         | `false` | What a reader may aggregate this column with. `true` reads the declared filter or editor type. |
| `formatAggregate` | `(value, context: AggregateFormatContext) => ReactNode` | —       | How a group aggregate of this column reads. Presentation only.                                 |
| `renderFooter`    | `(ctx: ColumnFooterContext<TRow>) => ReactNode`         | —       | Replace this column's `summaryRow` cell; `ctx.value` is the mapper's value.                    |

`AggregatableConfig`:

| Field        | Type                            | Default | Description                                                                        |
| ------------ | ------------------------------- | ------- | ---------------------------------------------------------------------------------- |
| `operations` | `readonly AggregateOperation[]` | —       | Built-in names and `{ id, label, calculate?, description? }` operations, in order. |
| `default`    | `string`                        | —       | The operation the column starts with; must be one of `operations`.                 |

Related props and options:

| Where                               | Field                          | Type                                                            | Description                                                  |
| ----------------------------------- | ------------------------------ | --------------------------------------------------------------- | ------------------------------------------------------------ |
| `DataTable`                         | `summaryRow`                   | `(rows: readonly TRow[]) => Partial<Record<string, ReactNode>>` | Footer cells for the rows the table shows.                   |
| `grouping` / `groupingPanel` extras | `groupAggregates`              | the same mapper shape                                           | Host-computed cells for each group.                          |
| `grouping` / `groupingPanel` extras | `groupFooters`                 | `boolean`                                                       | Close every group with a footer row carrying its aggregates. |
| `useQuerySource` / `useServerData`  | `aggregates`                   | `readonly QueryAggregate[]`                                     | Aggregates to request as `query.aggregates`.                 |
| `useQuerySource` / `useServerData`  | `supports.aggregates`          | `boolean`                                                       | The endpoint computes `query.aggregates`.                    |
| `useQuerySource` / `useServerData`  | `supports.aggregateOperations` | `readonly string[]`                                             | Operation ids the endpoint can compute.                      |

## URL state and saved views

Reader choices serialize as `groupAgg`, beside `groupBy`:
`?groupBy=region&groupAgg=amount:avg,closedOn:max`. `none` records a
removed developer default (`groupAgg=amount:none`); removing an aggregate
the reader added deletes its entry, and **Restore defaults** clears the
parameter. Column keys and operation ids are percent-encoded, so a custom
id containing a colon, comma or percent sign round-trips. With a `urlKey`
the parameter is prefixed like every other (`deals.groupAgg`). Saved Views
store the same state. `PARAM_GROUP_AGGREGATES` is the parameter name, and
`serializeGroupAggregateOverrides` / `parseGroupAggregateOverrides` are the
codec. See [URL state](./url-state.md) and [saved views](./saved-views.md).

`summaryRow`, `groupAggregates` and pinned summaries are code, not state:
nothing of them is written to the URL.

## Notes

- **RTL.** Aggregate cells follow their columns, so they mirror with the
  table. The grouping panel's operation labels and announcements ship in
  every bundled `@adapttable/i18n` locale. See [i18n and RTL](./i18n-rtl.md).
- **Keyboard and screen readers.** The panel's operation selector and
  remove button are kit-native controls, and every add, change, removal
  and restore is announced.
- **Agents.** `@adapttable/ai` changes the same reader choices through
  `view.setAggregations`, under the same rules. See
  [@adapttable/ai](./ai.md).
- **Pivot tables** aggregate across two axes with their own measures. See
  [pivot](./pivot.md).

Related: [row grouping](./row-grouping.md) ·
[pinned summary rows](./pinned-summary-rows.md) ·
[server queries](./server-queries.md) · [columns](./columns.md)

---

# React pivot table — rows, columns and measures

Grouping answers "what is the total per team". A pivot answers "what is the
total per team **per quarter**" — and that second dimension has to become
columns that were never in the data.

`@adapttable/core/pivot` is a separate entry, so a table that never pivots
never downloads the engine. It costs 2.1 KB gzipped to the tables that import
it and nothing to the rest, which the bundle budget checks on every build.

▶ **See it working:** [pivot in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/pivot/) — the same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

Three entries share the work: `@adapttable/core/pivot` holds the engine and the
codecs; `@adapttable/react/pivot` re-exports them and adds `pivotTableModel`,
`usePivotUrlState` and `PIVOT_ROW_COLUMN_KEY`; `@adapttable/<kit>/pivot` adds
the kit's `PivotPanel` to the core re-export.

## The shape

```tsx
import { pivot } from "@adapttable/core/pivot";

const result = pivot(sales, {
  rows: ["region", "team"], // down the side, outermost first
  columns: ["quarter"], // across the top
  measures: [{ key: "amount", agg: "sum" }],
});
```

`pivot` is the whole calculation and none of the rendering. It returns:

| Field          | What it is                                                            |
| -------------- | --------------------------------------------------------------------- |
| `columnLeaves` | The rendered columns, left to right — the order every row's cells use |
| `columnTree`   | The header tree, each node carrying the `span` its header cell needs  |
| `rows`         | Every line of the body, in render order                               |
| `rowDepth`     | How many dimensions are down the side                                 |

Each line has a `kind` — `"leaf"`, `"subtotal"` or `"grandTotal"` — a `depth`
for indentation, a `label`, a `count` of the source rows it covers, and one
cell per entry of `columnLeaves`.

## Rendering it with your kit

`pivot` returns data, and a pivot has three shapes an ordinary table does not:
header cells that span, a row-header area down the side, and lines that are
totals rather than data. `pivotTableModel` maps them onto mechanisms the table
already has, so the result goes straight into the `DataTable` you are already
using:

```tsx
import { DataTable } from "@adapttable/mantine";
import { pivot, pivotTableModel } from "@adapttable/react/pivot";

const result = pivot(sales, config, { collapsed });
const model = pivotTableModel(result, { fields, labels });

<DataTable
  data={model.rows}
  columns={model.columns}
  rowKey={model.rowKey}
  summaryRow={model.summaryRow}
/>;
```

The table is your kit's, with its own header groups, footer and sticky header —
nothing here draws a `<table>`:

| What the engine returns | What it becomes                                         |
| ----------------------- | ------------------------------------------------------- |
| `columnTree`            | `column.group`, one header row per level, spans and all |
| `columnLeaves`          | One column each, keyed `pivot-0`, `pivot-1`, …          |
| `rows`                  | The table's rows, keyed by the engine's own line key    |
| The grand-total line    | `summaryRow` — the column-aligned footer                |
| The grand-total column  | Its own header group, captioned from `labels`           |

The row-header column is keyed `PIVOT_ROW_COLUMN_KEY` (`"pivot-row"`) and each
measure column carries its `PivotColumnLeaf` in `column.meta.pivotLeaf`, so a
host can find the measure and the column path behind any cell.

Options are all optional: `fields` captions the measures (the same list the
panel takes), `labels` localizes the grand-total captions, `rowHeader` names the
corner cell — pass your row dimensions' captions, or take the localized "Rows" —
and `indent` is the pixels per nesting level down the side.

**The fold control is yours.** Core ships no user-facing controls, so
`renderRowHeader` hands you each body line and you render what it needs — a
subtotal line's `kind` says it is foldable and its `key` is the collapse key:

```tsx
pivotTableModel(result, {
  renderRowHeader: (row) =>
    row.kind === "subtotal" ? (
      <Button variant="subtle" onClick={() => toggle(row.key)}>
        {row.label}
      </Button>
    ) : (
      row.label
    ),
});
```

Rows that are totals are marked for styling: every row-header cell carries
`data-adapttable-part="pivot-row-header"` with a `data-pivot-kind` of `leaf`,
`subtotal` or `grandTotal`, and the row itself is yours through
`rowAppearance({ rowClassName })` from `@adapttable/<kit>/row-appearance`.

The configuration panel fills its container: Rows and Columns share the
available width, and Measures spans the next row. Narrow containers stack all
three zones. The structural `pivot-zones` and `pivot-zone-layout` parts expose
this layout without replacing each kit's native configuration controls.
A group above a single measure follows that column's alignment, so its caption,
measure label and numeric values share an edge. Groups spanning several measures
remain centered unless the host specifies a group alignment.

## Measures

A measure is a column key and an aggregation:

```tsx
measures: [
  { key: "amount", agg: "sum" },
  { key: "amount", agg: "count", label: "Deals" },
];
```

`sum`, `avg`, `count`, `min` and `max` are built in; anything else is a
function over the values found, the same `Aggregator` shape the summary row
and group aggregates take:

```tsx
measures: [{ key: "amount", agg: (values) => `${values.length} sales` }];
```

Every measure repeats under every column path, so two measures under three
quarters is six columns — the header tree's spans already account for it.

Values resolve through a column's `sortValue` when you pass `columns`, exactly
as sorting, grouping and the summary row do. A pivot that read raw fields
would disagree with the footer of the same table:

```tsx
pivot(sales, config, { columns });
```

The same options take `aggregators`, a `ReadonlyMap<string, Aggregator>` that
resolves a measure whose `agg` is a string naming a registered aggregator
rather than a built-in, and `format`,
`(value, measure) => DisplayValue | undefined`, which shapes each computed
cell.

## Subtotals, totals and collapsing

Subtotals are on by default: every level above the innermost gets a line, and
those lines come **before** their children rather than after. That is what
makes collapsing work — a collapsed group still shows its own line with its
own totals instead of vanishing.

```tsx
const result = pivot(sales, config, { collapsed: new Set(["EU"]) });
```

A subtotal line's `key` is its collapse key. Collapsing changes what is shown
and never what is computed: the grand total is the same number either way. Those
keys are what [the URL carries](#in-the-url), so a folded group travels with the
link.

Turn either off with `subtotals: false` / `grandTotals: false`. The grand-total
**column** only appears when something splits the columns — without column
dimensions it would repeat the only column there is.

## What it does not throw away

A row whose dimension value is missing gets its own bucket, labelled `—`
(`PIVOT_BLANK`), rather than being dropped. Rows falling into no bucket and
quietly disappearing is how a pivot table ends up lying about a total.

A value that is not summable is absent, not zero — a missing budget is not a
$0 budget, so a cell with nothing to add up reads as empty.

## Building the configuration

The panel a user drags fields around in is a separate concern from the pivot
itself, and the part of it that is not a widget lives here too:

```tsx
import {
  assignField,
  availableFields,
  moveField,
  removeField,
  setMeasureAgg,
  EMPTY_PIVOT_CONFIG,
} from "@adapttable/core/pivot";

const [config, setConfig] = useState(EMPTY_PIVOT_CONFIG);

setConfig(assignField(config, "team", "rows")); // put Team on the rows axis
setConfig(moveField(config, "rows", 1, -1)); // one step out — the keyboard path
setConfig(setMeasureAgg(config, 0, "avg"));
```

Every operation returns a new configuration and none of them can produce an
invalid one: placing a dimension on one axis takes it off the other rather than
pivoting the same field twice, an index past the end appends, and a step past
either end is a no-op rather than a wrap. Measures are the exception to "one
field, one axis" — summing and counting the same column in one pivot is an
ordinary thing to want, so `assignField` adds a measure rather than moving one.

`availableFields(fields, config)` is what the panel's unused list shows, and
`isPivotReady(config)` is false until something fills the cells — a pivot with
no measure is a half-built configuration, not an error.

## The configuration panel

`PivotPanelChrome` from `@adapttable/react/adapter` is the panel itself: three
zones, the fields in each, and the controls that move them. Structure, part
names, ordering and labels live in core; every visible control is a required
slot the adapter fills with its own kit's component, so a Mantine panel is
built from Mantine buttons and an antd panel from antd buttons.

Every React adapter ships it pre-wired as
`PivotPanel`, from the kit root or `@adapttable/<kit>/pivot`, so a host imports
one component rather than assembling slots:

```tsx
import { PivotPanel } from "@adapttable/mantine";

<PivotPanel fields={fields} config={config} onChange={setConfig} />;
```

Underneath, `PivotPanel` is `PivotPanelChrome` with that kit's slots filled in:

```tsx
<PivotPanelChrome
  fields={fields}
  config={config}
  onChange={setConfig}
  slots={slots}
/>
```

Its `PivotPanelChromeProps` take the fields, the configuration and a change
handler — the panel never holds the configuration itself. `PivotPanelSlots`
names the five pieces a kit supplies, each with its own props type:
`PivotPanelSurfaceProps` for the body, `PivotZoneProps` for a titled zone,
`PivotFieldProps` for one field and its move/remove controls, `PivotAddProps`
for the control that adds a field, and `PivotAggProps` for a measure's
aggregation chooser.

Every pivot UI in every spreadsheet is drag-and-drop, and every one of them is
unusable without a mouse. This panel is keyboard-first instead: each field
carries buttons that move it one step, so the whole thing is drivable with Tab
and Enter. A kit that wants dragging can add it on top — nothing here forbids
it and nothing depends on it. The move controls are withheld at each end
rather than disabled, because a field at the top has nowhere up to go.

Drop it into a [side panel](./customization.md) as that panel's content, and
the pivot configuration lives beside the table rather than over it.

## Pivoting on the server

A pivot over ten million rows is not a browser's job. When the server can do
it, send the configuration and translate the answer:

```tsx
import { serverPivotResult } from "@adapttable/core/pivot";

const result = serverPivotResult(page, { config });
```

`serverPivotResult` is a translator, not a second engine. The server decides
the arithmetic and the ordering; core rebuilds the column tree, the spans and
the leaf ordering from the paths the server named, so the result is the _same_
`PivotResult` the local engine returns and every adapter renders one thing.

The wire format is small on purpose — a `QueryPivotPage`:

```ts
{
  // column-dimension paths, outermost value first, in display order.
  // One entry per PATH, not per rendered column: the measures multiply them.
  columns: [["EU", "Q1"], ["EU", "Q2"], ["US", "Q1"]],
  rows: [
    { path: ["EU"], cells: [30, 20, 0], totals: [50], subtotal: true },
    { path: ["EU", "Alpha"], cells: [30, 20, 0], totals: [50], count: 12 },
  ],
  total: { path: [], cells: [30, 20, 10], totals: [60] },
}
```

`count`, `subtotal`, `totals` and `total` are optional: a server that can pivot
but not count, or that has no subtotals, should not have to send empty fields to
say so. A cell the server omits is an empty cell, never a zero — the same rule
the local engine follows for a value that will not add up.

The grand-total **column** follows the local rule: it is there when
`grandTotals` is on, which is the default, and something splits the columns. Its
values are each line's `totals`, one per measure, because that column is
arithmetic rather than shape — summing sums is not how an average or a minimum
totals, so core does not guess it. Send `totals` and the column carries numbers;
omit it and the column is empty, like any other cell nobody sent; ask for
`grandTotals: false` and there is no column at all.

## In the URL

A pivot is the most expensive table state there is to rebuild by hand — two
axes, an order on each, and a measure list — which makes it the state most
worth putting in a link:

```tsx
import { PivotPanel } from "@adapttable/mantine";
import { pivot, usePivotUrlState } from "@adapttable/react/pivot";

const { config, onConfigChange, collapsed, onCollapsedChange } =
  usePivotUrlState();

const result = pivot(rows, config, { collapsed });

<PivotPanel fields={fields} config={config} onChange={onConfigChange} />;
```

The parameter is compact and readable rather than JSON in a query string:

```
?pivot=rows:region,team;cols:quarter;sum:amount;count:amount;sub:0;hide:EU/Alpha
```

Everything a reader can change travels: the two axes, the measures, the
subtotal and grand-total switches (`sub:0`, `grand:0`) and which groups are
folded (`hide:`). A link that carried the axes alone would reopen showing
numbers its sender had switched off, or lines they had folded away.

Only departures are written. Both switches default to on, so a parameter says
so by staying silent — which is also what makes the encoding backward
compatible: a link or a saved view from before those fields existed says
nothing about them and reads back exactly as it always did. A folded path is
percent-encoded per dimension value, so a team called `A/B` cannot split a path
in the wrong place, and `collapsed` hands straight to the engine.

`serializePivot` and `deserializePivot` are exported for saved views and
anywhere else a `PivotConfig` has to be stored; `serializePivotState` and
`deserializePivotState` are the same encoding including the folded set, as a
`PivotUrlState` (`config` and `collapsed`). The round trip is tested, not
assumed, and a hand-edited value degrades to a simpler pivot instead of
throwing — a URL is user input.

All four are also on [`@adapttable/core/query`](./server-queries.md#decoding-a-parameter-yourself),
the React-free entry, so a route handler can read the parameter a shared link
carries without importing the engine or React.

An empty pivot writes no parameter — folds included, since there is nothing to
fold — and `urlKey` namespaces it so two tables can share one URL.

A custom aggregator has no URL form, so a measure carrying one keeps working
in memory and is left out of the link. Writing `sum` instead would quietly
change what the link computes.

Related: [row grouping](./row-grouping.md) ·
[aggregation](./row-grouping.md#aggregate-without-writing-the-maths) · [API reference](./api.md)

---

# React table formulas — spreadsheet columns computed from your rows

▶ **See it working:** [type a formula and watch the column appear in the live demo](https://adapttable.orwamahmoud.com/react/demo/mantine/formulas/) — a real table you can click, not a recording.

A column whose value is `=[Unit Price] * Quantity`, typed by the user rather
than written by you.

`@adapttable/core/formula` is a separate entry, so a table with no computed
columns never downloads a parser. It costs 2.8 KB gzipped to the tables that
import it and nothing to the rest, which the bundle budget checks on every
build.

## The shape

```tsx
import { buildFormulaColumns } from "@adapttable/react/formula";

const {
  columns: computed,
  errors,
  cycles,
} = buildFormulaColumns<Row>([
  { key: "total", header: "Total", formula: "=[Unit Price] * Quantity" },
  { key: "withTax", header: "With tax", formula: "=total * 1.2" },
]);

<DataTable
  data={rows}
  columns={[...columns, ...computed]}
  rowKey={(r) => r.id}
/>;
```

`@adapttable/react/formula` returns kit-ready `ColumnDef`s. The same builder
on `@adapttable/core/formula` returns `ColumnMetadata` for React-free code.
Each spec may also carry `format: (value: FormulaValue) => string` to turn a
value into display text.

A formula may reference a data field or another formula column by name.
Bracket a name that contains spaces — `[Unit Price]` — which is why the
brackets exist rather than an escaping rule.

`errors` names the formulas that would not parse, with the parser's message;
`cycles` names the keys that reference each other in a loop. Both are reported
rather than thrown, because a formula bar has to show something useful while
someone is still typing, and half a formula is the normal state of one being
written.

## A formula is parsed, never evaluated

The rule the parser exists to keep: **a formula is text, and it is parsed.**
It never reaches `eval`, `new Function`, or anything else that would run it as
JavaScript.

A user-typed formula is untrusted input in exactly the way a URL is. A table
that evaluates one has handed the page to whoever typed it — and in a
[saved view](./saved-views.md) that can be shared, to whoever sent the link.

The grammar is deliberately small, because a formula language grows one "just
add" at a time until it is a programming language nobody can secure:

```
expression → comparison
comparison → concat ( ("=" | "<>" | "<" | "<=" | ">" | ">=") concat )?
concat     → sum ( "&" sum )*
sum        → product ( ("+" | "-") product )*
product    → unary ( ("*" | "/") unary )*
unary      → "-" unary | primary
primary    → number | string | reference | call | "(" expression ")"
```

`&` concatenates, as it does in a spreadsheet, and it binds **below** `+` and
`-`: `="a" & 2 + 3` is `"a5"`, because the arithmetic finishes before the join.
Comparisons bind loosest of all, so `="a" & "b" = "ab"` compares two whole
strings and is `TRUE`.

## Not supported, knowingly

`^` (exponent) and scientific notation (`1e5`) are outside the grammar. Both
are **reported**, never reinterpreted: `parseFormula` answers `ok: false` with
the character it stopped on, `buildFormulaColumns` names the column in
`errors`, and that column's cells read `#ERROR!`. Write `x * x` for a square.

The absence is deliberate — a formula language grows one "just add" at a time —
and an absence that says so beats one that quietly reads `1e5` as `1`.

## Values are tagged, and errors are values

A value is `{ kind: "number" | "text" | "boolean" | "blank" | "error" }`
rather than a bare union of primitives. That costs a `.kind` at every use and
buys two things.

An error stops being a string. With sentinel strings, data that genuinely
contains the text `#REF!` is indistinguishable from a cell that failed —
`isFormulaError` can tell them apart because one is tagged and the other is
not.

And a formula that cannot produce a number produces an error value rather than
throwing: `#DIV/0!`, `#VALUE!`, `#NAME?`, `#CYCLE!`, `#ERROR!`. They propagate
the way a spreadsheet's do — an error inside `SUM(a, b)` comes out of the
`SUM` instead of being counted as zero, so a wrong number never gets quietly
totalled. `COALESCE` is the one function that deliberately does not propagate,
because answering "what should I use when this is missing" is its whole job.

A field the engine has no kind for — an object, a function — is `#VALUE!`
rather than its stringification. `[object Object]` in a cell is not a
rendering of the data; it is a rendering of nobody having decided. A field that
is `NaN`, `Infinity` or an invalid date is `#VALUE!` too, and so is any result
too large to be a number — an overflowing product, sum or `ROUND` — so a
formula's answer is always a finite number or an error.

## Functions

| Function                                      | Result                                                                          |
| --------------------------------------------- | ------------------------------------------------------------------------------- |
| `SUM(values…)`                                | Sum after numeric coercion; non-numeric text is skipped.                        |
| `AVG(values…)`                                | Mean after numeric coercion; no numbers returns `#DIV/0!`.                      |
| `MIN(values…)` / `MAX(values…)`               | Smallest or largest value after numeric coercion; no numbers returns `0`.       |
| `ABS(value)`                                  | Absolute numeric value.                                                         |
| `ROUND(value, places)`                        | Number rounded to the requested decimal places.                                 |
| `POWER(base, exponent)`                       | `base` raised to `exponent`; a non-real or non-finite result returns `#VALUE!`. |
| `SQRT(value)`                                 | Square root; a negative value returns `#VALUE!`.                                |
| `IF(test, yes, no)`                           | One branch selected by spreadsheet truthiness.                                  |
| `AND(values…)` / `OR(values…)` / `NOT(value)` | Boolean logic.                                                                  |
| `CONCAT(values…)`                             | Values joined as text.                                                          |
| `LEN(value)`                                  | Length of the displayed text.                                                   |
| `UPPER(value)` / `LOWER(value)`               | Text converted to upper or lower case.                                          |
| `COALESCE(values…)`                           | First value that is neither blank nor an error.                                 |

For compound growth, write `=POWER(1 + rate, years)`. `POWER` and `SQRT` are
functions only: they do not expand the grammar or reinterpret the unsupported
`^` operator.

## Sorting and export

A formula column sorts by its **value**, never by the text in the cell. A
number orders numerically however it is formatted, so `$1,240.00` cannot land
before `$90.00`; `=UPPER(name)` orders alphabetically; a boolean puts `FALSE`
before `TRUE`.

A blank and an error have no place in an ordering, so both group at the **end**
of the column — in either direction, the way a spreadsheet leaves an error —
and rows tied there keep the order they already had. What matters is not the
order among broken cells but that they collect somewhere predictable instead of
being counted as zero and scattered among real values.

Export receives the same value a spreadsheet would. Formatting is never applied
to an error: showing `#DIV/0!` as `$#DIV/0!` would hide which cell went wrong.

## Share and save formulas

A typed formula is the one piece of table state nobody can rebuild from memory,
because the table never offered it — someone wrote it. So it travels in the URL
with sort, filters and the pivot:

```tsx
import {
  buildFormulaColumns,
  useFormulaUrlState,
} from "@adapttable/react/formula";

const { formulas, onFormulasChange } = useFormulaUrlState({
  urlKey: "people",
  defaultFormulas: [
    { key: "total", header: "Total", formula: "=[Unit Price] * Quantity" },
  ],
});
const {
  columns: computed,
  errors,
  cycles,
} = buildFormulaColumns<Row>(formulas);
```

The parameter is `formula=total:%3D%5BUnit%20Price%5D%20*%20Quantity:Total`
(`people.formula=` with the `urlKey` above) — one `key:formula[:header]` entry
per column, `;`-separated, every field percent-encoded so a formula may contain
the delimiters. One URL carries at most 24 formula columns, and a `format`
function stays in memory rather than in the link. Writes are debounced
(`FORMULA_URL_WRITE_DEBOUNCE_MS`, on `@adapttable/react/formula`), so a bar that writes while someone types
does not out-run Safari's `replaceState` limit; reads stay instant.

[Saved views](./saved-views.md) capture the parameter with the rest of the
table's state, so "Margin analysis" is a view like any other. A view saved
before formula columns existed carries none, and applying it clears them — the
same thing a pre-pivot view does to a live pivot.

`serializeFormulaColumns` and `deserializeFormulaColumns` are the encoding on
its own, exported from `@adapttable/core/formula` and from the React-free
`@adapttable/core/query` — the entry a route handler, loader or plain Node
service imports to read a shared link. **Reading never evaluates.** The codec
hands back the text; a hand-edited entry it cannot make sense of is dropped,
and a formula that will not parse arrives as the text it is, because half a
formula is the normal state of one being written.

## Building a formula bar

`FORMULA_FUNCTIONS` lists the function names the engine knows, for an
autocomplete. `parseFormula` returns a tree or the reason it could not, and
`formulaRefs` names the columns a formula depends on. `evaluateFormula`,
`toFormulaValue`, `formulaDisplay`, `formulaSortValue`, `isFormulaError`,
`FORMULA_ERRORS`, `FORMULA_BLANK` and the `formulaNumber` / `formulaText` /
`formulaBoolean` / `formulaError` constructors are the engine's pieces on their
own.

Related: [pivot tables](./pivot.md) · [row grouping](./row-grouping.md) ·
[API reference](./api.md)

---

# React table tree data — hierarchical rows with expand/collapse

Some data is a hierarchy before anyone asks a question of it: a folder holds
files, a task has subtasks, an account rolls up to a parent. Compose `tree()`
from `@adapttable/<kit>/tree` with `getChildren` (nested data) or `getParentId`
(a flat list with a parent column) and the table renders that hierarchy — one
chevron per parent, one indent step per level, in every adapter that ships the
subpath.

▶ **See it working:** [tree data in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/tree/) — the same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

```tsx
import { DataTable } from "@adapttable/mantine";
import { tree } from "@adapttable/mantine/tree";

<DataTable
  data={FILES}
  columns={columns}
  rowKey={(row) => row.id}
  features={[tree({ getChildren: (row) => row.children })]}
/>;
```

Without the `tree()` feature the table renders a flat list. The shape comes from
the data: `tree()` only says how to read it.

## A tree is not a grouping

They look alike and behave nothing alike, so AdaptTable ships them as two
models rather than one with a flag:

|                            | `grouping()` / `groupingPanel()` | `tree({ getChildren \| getParentId })` |
| -------------------------- | -------------------------------- | -------------------------------------- |
| Where the shape comes from | a value the reader picked        | the rows themselves                    |
| Re-sorting a column        | regroups                         | leaves the hierarchy alone             |
| Depth                      | the grouping keys                | whatever the data has                  |
| A parent row               | a synthetic header               | a real row with real cells             |

Sharing one implementation would mean either grouping that cannot express a
real hierarchy, or a tree that reshuffles when someone sorts a column. The body
draws one of them at a time: with grouping armed, a table that also declares a
tree shows the groups, and their rows carry no tree chevrons. Row pinning is
refused under either.

## Which column carries the chevron

The first visible column, unless `treeColumn` names another:

```tsx
<DataTable {...props} features={[tree({ getChildren, treeColumn: "name" })]} />
```

The chevron and the indent go in that column's cell, wrapping its content — so
the name steps in with its depth, not just the chevron. A leaf gets a spacer the
same width as a chevron, so a folder's children line up under its name rather
than under its disclosure control.

## Holding the open set

Uncontrolled by default — the table remembers which nodes are open. Pass
`expandedIds` to own it, and `onExpandedIdsChange` to hear about clicks:

```tsx
const [expandedIds, setExpandedIds] = useState<string[]>(["src"]);

<DataTable
  {...props}
  features={[
    tree({ getChildren, expandedIds, onExpandedIdsChange: setExpandedIds }),
  ]}
/>;
```

A tree starts folded, so the state is the set that is **open** — an empty array
is a closed tree, not an expanded one.

## A flat list with a parent column

The commonest shape out of a database is one table with a `parentId`. Read it
with `getParentId` and the table assembles the hierarchy itself:

```tsx
<DataTable
  data={rows} // [{ id: "1" }, { id: "2", parentId: "1" }]
  columns={columns}
  rowKey={(row) => row.id}
  features={[tree({ getParentId: (row) => row.parentId })]}
/>
```

Rows whose parent is not in the data are roots — so a filtered page still
renders rather than vanishing into a parent that never arrived.

## Children fetched when a branch is opened

A hierarchy of any size cannot arrive whole. Say which rows have children the
browser has not fetched, and what to do when one is opened:

```tsx
<DataTable
  data={rows}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    tree({
      getChildren: (row) => row.children,
      hasChildren: (row) => row.childCount > 0,
      onLoadChildren: async (row) => {
        const children = await fetchChildren(row.id);
        setRows((current) => withChildren(current, row.id, children));
      },
    }),
  ]}
/>
```

`hasChildren` is what draws a chevron on a node with nothing under it yet.
`onLoadChildren` fires once per node — the second click while a request is out
does not ask again — and resolves when the children are in the data the table
reads; the table re-walks the hierarchy itself and needs nothing back.

The node **opens immediately** and shows it is working: its chevron carries
`data-loading` and `aria-busy` until the rows land, so nobody is left clicking a
control that appears to do nothing. A rejection clears the flag, records the node
in `failedIds` and closes it again, so the next click retries the fetch.

Headless: `useLazyChildren` holds the state (`LazyChildrenState`, options
`UseLazyChildrenOptions`, with `onLoadFailed` called on a rejection) and the table's tree bundle exposes `loadingIds` and
`failedIds`.

## The whole tree on the server

A browser holding one page cannot know what is under a branch it has never seen,
so a large hierarchy belongs to the side that has the data. Declare the
capability and hand the source the open set:

```tsx
import { DataTable, useQuerySource } from "@adapttable/mantine";
import { tree } from "@adapttable/mantine/tree";

const [expandedIds, setExpandedIds] = useState<string[]>([]);

const source = useQuerySource<Node, Params, Page>({
  usePaginatedQuery: useFilesQuery,
  supports: { tree: true },
  expandedIds,
});

<DataTable
  source={source}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    tree({
      getParentId: (row) => row.parentId,
      hasChildren: (row) => row.childCount > 0,
      expandedIds,
      onExpandedIdsChange: setExpandedIds,
    }),
  ]}
/>;
```

The query carries `expandedIds` — the ids the reader has open — and the response
returns the rows of the tree that are visible: the roots, plus the children of
every open node, each with its `parentId`. The table assembles the hierarchy from
what arrived, so no row model changes between tiers.

Without `supports: { tree: true }` the field is never sent, and development says
so once rather than letting a server quietly ignore it. The same is true of
`useServerData`, which takes `expandedIds` in the same place.

## Mobile

Cards keep the hierarchy. Each card steps in by its depth (logical margin, so
it steps from the right in Arabic and Hebrew) and carries the same chevron,
which folds the same nodes. A phone gets a tree, not a flattened list.

## A real table under a row

The full contract is on the [nested tables](./nested-tables.md) page.

▶ **See it working:** [nested tables in Mantine](https://adapttable.orwamahmoud.com/react/demo/mantine/nested-tables/) — open a person onto their orders; the inner table is the same component. The same page exists for MUI, Chakra, antd, Radix, Base UI, shadcn and Tailwind.

`rowDetail()` gives a blank panel, and a table built by hand in one has none
of the sorting, filtering, selection, keyboard navigation or accessibility the
outer table has. `nestedTable()` from `@adapttable/<kit>/nested-table` puts the
same component in there instead:

```tsx
import { DataTable } from "@adapttable/mantine";
import { nestedTable } from "@adapttable/mantine/nested-table";

<DataTable
  data={people}
  columns={columns}
  rowKey={(row) => row.id}
  features={[
    nestedTable((row) => ({
      label: `Orders for ${row.name}`,
      table: (defaults) => (
        <DataTable
          {...defaults}
          data={row.orders}
          columns={orderColumns}
          rowKey={(order) => order.id}
        />
      ),
    })),
  ]}
/>;
```

The child rows keep their own type — the closure holds it, so nothing is erased
at a prop boundary — and the row gets the same expand chevron `rowDetail()`
gives it.

`defaults` carries what a table inside a row cannot do without: `urlSync: false`
(two tables writing `?page=` fight over one URL, and the loser silently resets
while the reader is using it), `searchable: false` (a second search box inside a
row reads as chrome, not as a feature — pass `searchable` after the spread to
keep it), the parent's `density` and `labels`, and `tableLabel` (the `label` you
gave, "Row details" when omitted). Spread them first and override anything
after.

Return `undefined` for a row with no nested table. With `rowDetail(render)`
composed beside it, those rows fall back to that panel, so master/detail and
nested tables can live in one table. The region carries
`data-adapttable-part="nested-table"` and is announced with the label you gave
it. The second argument, `nestedTable(nested, defaultExpandedRowIds)`, opens
those rows on the first render so the inner table is visible without a click.

## Reorder siblings or change parent

Compose the kit's `rowReorder` feature with `tree`. Before/after drops reorder
rows that already share a parent; the ordinary reorder handler receives
sibling positions. A middle drop, **Move under…**, or **Move to top level**
uses `onTreeMove(row, fromParent, toParent, position)`, passed in the second
argument of `rowReorder`:

```tsx
import { rowReorder } from "@adapttable/mantine/row-reorder";
import { tree } from "@adapttable/mantine/tree";

features={[
  tree({ getParentId: (row) => row.parentId }),
  rowReorder(onRowReorder, { onTreeMove, movePolicy: "confirm" }),
]}
```

Set `movePolicy` to `"auto"`, `"confirm"`, or `"never"` (the default).
Confirmation is kit-native and restores focus to its menu trigger; a host can
instead provide async `confirmMove`. The same controls appear on mobile cards
and follow logical direction in RTL.

The tree model carries each node's `parentId`, `siblingIndex`, and complete
`descendantIds`. Re-parenting is rejected before the host callback when the
target is the node itself or a descendant, so pointer and menu paths cannot
create a cycle. See [row reordering](./row-reordering.md) for the full write
contract and sorted-state rule.

## Headless pieces

Everything above is composed from exports you can use directly when you render
your own body:

- `buildTreeEntries(options: BuildTreeEntriesOptions<TRow>)` — flattens a
  hierarchy into `TreeEntry<TRow>[]` in reading order. A collapsed node
  contributes itself and nothing beneath it. Each entry carries `level`,
  `hasChildren`, `expanded`, `path`, `descendantIds` (the whole subtree, folded
  or not, so ticking a folder can tick what is in it) and `loading`.
- `TreeShape<TRow>` — the three ways a host declares the hierarchy:
  `getChildren`, `getParentId`, and `hasChildren` for children that have not
  been fetched yet.
- `useTreeExpansion(options)` → `TreeExpansionState` — the open set, controlled
  or not, with `toggle`, `expand`, `collapse`, `isExpanded`.
- `filterTreeRows(options)` — keeps matching rows **and every ancestor that
  leads to one**, so a filtered tree still has a path to what it found.
- `treeColumnKey(columns, declared?)` — which column carries the chevron.
- `treeIndentStyle(level)` — the cell's indent (`paddingInlineStart`).
- `treeCardStyle(level)` — the card's indent (`marginInlineStart`).
- `bodyRowEntries(rows, tree)` → `BodyRowEntry<TRow>[]` — the rows a body
  renders: the tree's visible entries when a tree is armed, the windowed rows
  otherwise. One `.map` either way.
- `TreeCell` (on the antd, Base UI, Chakra, Mantine, MUI, Radix and unstyled
  adapters, props `TreeCellProps<TRow>`) — wraps the
  tree column's cell in its chevron and its indent, and passes every
  other column through untouched. The indent goes on the wrapper around the
  content, so a name moves with its depth rather than sitting at the same margin
  as its parent's. Layout is `TreeCellChrome` on `@adapttable/react/adapter`.
- `TreeToggle` (on the same adapters, props `TreeToggleProps<TRow>`) — the
  chevron itself: `aria-expanded`, a localized accessible name, and an
  equal-width spacer on a leaf. Layout is `TreeToggleChrome`.

## Styling hooks

Every adapter stamps `data-adapttable-part` on each piece: `tree-cell` (the
indented wrapper), `tree-toggle` (the chevron), and `tree-spacer` (a leaf's
placeholder). A node whose children are loading carries `data-loading` and
`aria-busy` on its toggle.

---

# React table column management — rename, show, hide, reorder, pin & resize

▶ **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 features are already wired in `src/App.tsx` (`columnMenu()` + `resizableColumns()`); edit it in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [rename, pin, resize and reorder columns in the live demo](https://adapttable.orwamahmoud.com/react/demo/mantine/columns/) — a real table you can edit and drag, not a recording.

Let users rename, show/hide, reorder, pin, and resize columns — compose one factory per
capability from `@adapttable/<kit>/<feature>`, with the resulting layout
persistable to the URL or localStorage. The import is the switch: `<DataTable>`
has no `enableColumnMenu` or `resizableColumns` prop. See
[feature composition](./features.md). Every adapter shares the same engine from
`@adapttable/core`.

## Example

```tsx
import { useColumnLayoutStorageState } from "@adapttable/react";
import { type ColumnDef, DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { columnMenu } from "@adapttable/mantine/column-menu";
import { resizableColumns } from "@adapttable/mantine/resizable-columns";
import { rowActions } from "@adapttable/mantine/row-actions";
import { useState } from "react";

interface Person {
  id: string;
  name: string;
  department: { name: string };
  city: string;
  salary: number;
  hiredAt: string;
}

const people: Person[] = [
  {
    id: "1",
    name: "Amira Hassan",
    department: { name: "Engineering" },
    city: "Dubai",
    salary: 96000,
    hiredAt: "2021-03-15",
  },
  {
    id: "2",
    name: "Tom Becker",
    department: { name: "Design" },
    city: "Berlin",
    salary: 78000,
    hiredAt: "2022-11-01",
  },
  {
    id: "3",
    name: "Lina Park",
    department: { name: "Engineering" },
    city: "Seoul",
    salary: 105000,
    hiredAt: "2019-07-20",
  },
];

const initialColumns: ColumnDef<Person>[] = [
  { key: "name", width: 200, renameable: true },
  { key: "department.name", header: "Department", width: 160 },
  { key: "city", width: 140 },
  { key: "salary", align: "end", width: 140 },
  { key: "hiredAt", width: 140 },
];

export function People() {
  const [columns, setColumns] = useState(initialColumns);
  // Persist the user's layout to localStorage; swap for useColumnLayoutUrlState
  // (from @adapttable/react) to make it part of shareable links instead.
  const { layout, onLayoutChange } = useColumnLayoutStorageState({
    storageKey: "people-table-columns",
    defaultColumnLayout: { pinned: { name: "start" } },
  });

  return (
    <DataTable
      data={people}
      columns={columns}
      rowKey={(r) => r.id}
      features={[
        columnMenu(), // Columns menu: rename, show/hide, reorder, pin
        resizableColumns(), // drag or arrow-key resize handles
        rowActions([
          { key: "edit", label: "Edit", onClick: (r) => console.log(r) },
        ]),
      ]}
      maxHeight={420} // scroll box so pinned columns visibly stick
      columnLayout={layout}
      onColumnLayoutChange={onLayoutChange}
      onColumnRename={(key, name) => {
        // The table updates its layout immediately. Persist the accepted
        // domain name in the host just like an edited cell value.
        setColumns((current) =>
          current.map((column) =>
            column.key === key ? { ...column, header: name } : column
          )
        );
      }}
    />
  );
}
```

## How it works

- `columnMenu()` renders the built-in Columns menu: a search box, bulk show/hide/unpin, a visibility toggle per column, drag- or arrow-key reorder on each row's grip, a pin control, a per-column submenu (sort, pin, hide, auto-size, filter, rename, reset one), auto-size-all, and reset-all. Hiding a column never reorders the rest. `lockPosition` / `lockVisibility` / `lockWidth` / `lockPin` on a `ColumnDef` gray out the matching controls.
- Set `renameable: true` on a column and provide `onColumnRename(key, name)` to offer **Rename column**. The kit-native inline form keeps a visible label, trims on save, rejects a blank name, commits with Enter, cancels with Escape, restores focus, and announces the completed rename to screen readers. The key never changes.
- Semantic-table adapters also put a small, named rename control beside the
  header caption for a direct path. Ant Design keeps the menu path because its
  native sorted title and sticky header clone own that hit target; keyboard
  users retain the same complete rename form through the Columns menu.
- The layout applies the accepted name immediately to desktop headers, mobile labels, the Columns menu, exports and accessible column names. The callback lets the host persist the domain name and update its `columns` definition; doing so also updates any column-derived filter label. An explicitly named standalone filter keeps its own label. Reset restores the declaration captured when that override began; a controlled echo of the custom name does not redefine the reset target. After the host writes the reset name back — or later changes the schema label — the next rename uses the current declared name, not a historical override.
- Pinning is logical (`"start"` / `"end"`), so a `"start"` pin sticks to the inline-start edge — the right edge under `dir="rtl"`. It needs a horizontal scroll context to visibly stick — set `maxHeight`, or let the table exceed its container width.
- `resizableColumns()` adds a handle to every header: drag it, or focus it and press ←/→ (16 px per step, 60 px minimum). Direction-aware, so it widens the right way in RTL.
- The row-actions column is first-class under the reserved key `"actions"` (`ACTIONS_COLUMN_KEY`): the menu lists it with a visibility toggle and an end-pin toggle — `hidden: ["actions"]` hides it, `pinned: { actions: "end" }` pins it to the end on its own, no data-column pin required. It never reorders or resizes; it always trails.
- The layout state is `{ hidden, order, pinned, widths, names?, collapsedGroups? }` (`ColumnLayoutState`), keyed by column key. Uncontrolled by default; seed it with `defaultColumnLayout`, or own it with `columnLayout` + `onColumnLayoutChange` — the same controlled/uncontrolled split as a form input. `names` and `collapsedGroups` are omitted when they carry no override.
- Two ready-made persistence hooks feed the controlled mode: `useColumnLayoutUrlState({ urlKey })` keeps the layout in the query string (`colHide` / `colPin` / `colOrder` / `colW` / `colName` / `colGroupCollapse` — shareable links and Saved Views), and `useColumnLayoutStorageState({ storageKey })` keeps it in localStorage (user preference).

## Options

| Factory / prop                                              | Type                                  | Default | Description                                                                                                                    |
| ----------------------------------------------------------- | ------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `columnMenu()`                                              | —                                     | off     | Factory from `@adapttable/<kit>/column-menu`; composes the Columns menu (show/hide, pin, reorder).                             |
| `resizableColumns()`                                        | —                                     | off     | Factory from `@adapttable/<kit>/resizable-columns`; drag/keyboard column-resize handles.                                       |
| `fitColumns()`                                              | —                                     | off     | Factory from `@adapttable/<kit>/fit-columns`; columns share the container width.                                               |
| `collapsibleColumnGroups()`                                 | —                                     | off     | Factory from `@adapttable/<kit>/column-groups`; group headers gain a collapse toggle. See [column groups](./column-groups.md). |
| `columnSelectionCheckbox()`                                 | —                                     | off     | Factory from `@adapttable/<kit>/column-selection`; a checkbox in each header selects that column. Needs `cellNavigation()`.    |
| `lockPosition` / `lockVisibility` / `lockWidth` / `lockPin` | `boolean` (per `ColumnDef`)           | `false` | Gray out the reorder grip, show/hide toggle, resize and auto-size, or pin control for that column.                             |
| `defaultColumnLayout`                                       | `Partial<ColumnLayoutState>`          | —       | Initial layout for the uncontrolled mode.                                                                                      |
| `columnLayout`                                              | `ColumnLayoutState`                   | —       | Controlled layout (hidden/order/pinned/widths/names/collapsedGroups).                                                          |
| `onColumnLayoutChange`                                      | `(next: ColumnLayoutState) => void`   | —       | Change handler for the controlled layout.                                                                                      |
| `renameable`                                                | `boolean`                             | `false` | Per-column opt-in for the kit-native rename action.                                                                            |
| `onColumnRename`                                            | `(key: string, name: string) => void` | —       | Host persistence callback; required before rename controls are offered.                                                        |
| `maxHeight`                                                 | `number`                              | —       | Fixed-height scroll box (px); enables sideways scroll + visible pinning.                                                       |

Custom per-column menu actions register through `host.registerColumnMenuAction`
in a feature's `setup` — see [feature composition](./features.md).

## Notes

- Give pinned columns numeric pixel `width`s: sticky insets are summed from them, and a relative width (`"20%"`) falls back to a 150 px estimate, so adjacent pins may not stack exactly.
- A hidden pinned column reads back as unpinned until it is shown again — only visible columns have a cell to stick.
- `useColumnLayoutUrlState` debounces URL writes by 150 ms (a resize drag commits per animation frame; unthrottled `replaceState` trips Safari's rate limit). Reads stay instant.
- `useColumnLayoutStorageState` removes its stored entry when the layout returns to the exact default, so defaults can evolve in later releases; it is SSR-safe (memory-only without a browser).
- Layouts are keyed by column `key` — renaming a key orphans any persisted layout for it (the column reappears with default placement).
- `defaultColumnLayout` only applies in uncontrolled mode; once you pass `columnLayout`, the controlled value is the single source of truth (the persistence hooks take their own `defaultColumnLayout`).
- **SSR-safe persistence**: a stored layout hydrates in an effect AFTER the
  first client render, so server and client markup always match — expect one
  paint with the default layout before the stored one applies. Blocked
  storage (Safari private mode, sandboxed webviews) is tolerated silently.

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/).

## Widths, bounds and shares

A column can say how wide it is three ways, and they compose:

```tsx
const columns = [
  { key: "id", header: "ID", width: 80 }, // exactly this
  { key: "name", header: "Name", minWidth: 160 }, // never narrower
  { key: "note", header: "Note", flex: 2 }, // twice the leftover space
];
```

`minWidth` and `maxWidth` are bounds the column keeps whatever else happens — a
resize will not cross them, and neither will the fitting mode. `flex` asks for a
share of whatever space is left over.

### Filling the container

By default a table takes the width its columns need and scrolls when that is
more than the container. Compose `fitColumns()` to reverse it: the columns
share the container instead.

```tsx
import { fitColumns } from "@adapttable/mantine/fit-columns";

<DataTable
  data={rows}
  columns={columns}
  rowKey={rowKey}
  features={[fitColumns()]}
/>;
```

Columns with a `width` keep it, columns with a `flex` take that share, and
everything else divides what remains equally. A width the **user** dragged wins
over all of it — they said what they wanted.

Underneath it is CSS the browser already knows: a fixed table layout with
percentage widths shares space proportionally, and the bounds clamp it. The Ant
Design adapter renders through antd's own `<Table>`, which sets its own layout
mode; the per-column widths, bounds and shares still apply there.

## Sizing a column to its content

Double-click a resize handle and that column takes the width of its widest
rendered cell. The Columns menu's **Size columns to content** does the same for
every column at once.

Measurement comes from the DOM, not the data: a cell renders a badge, an avatar
and a name, and the only honest answer to "how wide is this column" is what the
browser laid out. It measures the **rendered** rows — the page, or the window
under virtualization — which is the set the reader is looking at, and it reads
each cell's content width, so a column that is currently clipping its text is
sized to fit it rather than to its clipped width. Running it again does not
keep adding width: once a cell already fits, the box is not treated as new
content.

The result is an ordinary width in the column layout: it persists, serializes to
the URL and to saved views, and a later drag overrides it exactly as it
overrides any other width. A column with nothing measurable on screen is left
alone rather than collapsed.

Both actions are localizable — `labels.autoSizeColumns` and
`labels.autoSizeColumn` — in every bundled locale.

Headless: `measureColumnWidth(root, key)` and `autoSizeColumns(root, keys,
setWidth)`.

---

# React table saved views — shareable named layouts

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — a real AdaptTable you can edit in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

Let users capture the table's current state under a name and re-apply it
later. Compose `savedViews()` from `@adapttable/<kit>/saved-views` and a
ready-made menu mounts in the toolbar; a headless hook backs custom UIs. The
import and `features` entry are the switch; there is no `savedViews` table prop. See
[feature composition](./features.md).

A view captures **every parameter the table keeps in its URL**: search, the
find-in-table query, the multi-sort chain, page and page size, the simple
filters and the advanced filter tree, ordered grouping keys (`groupBy`),
session aggregation overrides (`groupAgg`), formula columns, and the
[pivot configuration](./pivot.md) — plus, when their URL hooks are wired, the
whole column layout (order, hidden, pinned, widths, user-renamed column names,
collapsed column groups), pinned rows, density and which groups are
collapsed. The parts that take longest to
rebuild by hand are exactly the parts worth capturing.

## Managing the list

`useSavedViews` returns the list plus the operations a management UI needs:

```tsx
import { useSavedViews } from "@adapttable/react";

const { views, save, apply, remove, rename, move, setDefault, defaultView } =
  useSavedViews({ storageKey: "people-views" });
```

`rename(from, to)` keeps the view's place in the list, and refuses a name that
is already taken — silently merging two views is how a rename loses one.
`move(name, -1 | 1)` steps a view through the list and stops at either end
rather than wrapping. `setDefault(name)` marks the view the table opens with;
naming the same view again clears it, and only one view can ever hold it.
Rename, move, set-default and delete all refuse a view this reader does not own,
so the panel's disabled controls and the hook agree.

Every operation is a no-op on a name that is not there, so a management UI
cannot get the list into a state the table will not accept.

## Views outliving the table that saved them

A saved view outlives the code that saved it — that is the point of saving one
— so each carries the schema version it was written at, and the table upgrades
what it reads.

```tsx
const views = useSavedViews({
  storageKey: "people-views",
  migrate: (view, from) => {
    if (from < 2) {
      // A column this table renamed since that view was saved.
      return {
        ...view,
        search: view.search.replace("colHide=email", "colHide=contact"),
      };
    }
    return view;
  },
});
```

`migrate` runs only for views behind `SAVED_VIEW_VERSION`, and is told which
version each came from. Views saved before versioning existed have no number
and are treated as version 1, which is what they are.

Returning `null` drops a view. That is a real answer: a view whose columns no
longer exist restores a table nobody asked for, and applying it silently is
worse than losing it. A migration that throws costs that view alone — one bad
entry in storage should not take the whole list with it.

Loading happens on mount and when `storageKey` changes. A `store` or a
`migrate` written inline changes identity on every render, so neither can be
allowed to trigger a reload; call `reload()` when you want the list read again.

## Keeping views on a server

`localStorage` is the zero-config default: a table that passes nothing keeps
working offline with no server at all. Pass a `store` and it takes over
completely — two sources of truth for one list is how a view comes back after
being deleted.

```tsx
const views = useSavedViews({
  storageKey: "people-views",
  visibility: "team", // what `save` marks a new view as
  store: {
    list: () => fetch("/api/views").then((r) => r.json()),
    save: (view) =>
      fetch("/api/views", { method: "POST", body: JSON.stringify(view) }),
    remove: (name) => fetch(`/api/views/${name}`, { method: "DELETE" }),
    reorder: (names) =>
      fetch("/api/views/order", {
        method: "PUT",
        body: JSON.stringify(names),
      }),
  },
});
```

The store is asked for **one view at a time**, never the whole list: sending
the list back would overwrite whatever other people changed between the load
and the save. A store that cannot be reached leaves the list empty rather than
throwing into a render — the table still works, the views simply are not there.

One gesture can be more than one write. Switching the default saves **two**
views — the one that gains the flag and the one that loses it — because a
cleared flag that never reached the store comes back set, and then two views
claim to be the default.

### Order

`reorder` is where the list's order lives. Order belongs to the list rather than
to any one view, so it has nowhere to go through `save`, and it travels as names
so that persisting an order carries no view contents with it and cannot overwrite
someone else's edit. It arrives after the same operation's own `save` and
`remove` writes have landed, so a renamed view's new name is already known by
the time its place in the list turns up.

`reorder` is **optional**, and a store without it keeps working: `list`, `save`
and `remove` carry saving, renaming, deleting and the default.
What such a store cannot do is remember an order — `move` reorders the list on
screen for that session, and the next `list()` decides the order again, as does
where a renamed view lands. Implement `reorder` when reordering has to survive a
reload.

### Views you may not change

A view carries `visibility` (`"private"` or `"team"`) and `readOnly`. A team
view someone else owns arrives read-only, and both halves honour it: the panel
shows a **Read-only** badge with its rename, reorder, set-default and delete
controls disabled, and the hook refuses those operations too. Applying it stays
enabled, since that is the point of a shared view.

Disabled rather than absent is deliberate. An absent control reads as "no such
feature"; a disabled one reads as "not yours". A control that silently does
nothing is a bug the user gets blamed for.

## The management panel

The saved-views _menu_ answers "switch to a view". Keeping the list in order is
a different job, and putting both in one dropdown makes the common one harder —
so management is a panel:

```tsx
import { SavedViewsPanelChrome } from "@adapttable/react/adapter";

<SavedViewsPanelChrome
  views={views}
  onApply={apply}
  onRename={rename}
  onMove={move}
  onSetDefault={setDefault}
  onRemove={remove}
  slots={slots}
/>;
```

`SavedViewsPanelSlots` names the four kit-supplied pieces —
`SavedViewsPanelSurfaceProps` (the titled card), `SavedViewsPanelRowProps` (one
view and its controls), `SavedViewsPanelInputProps` (the inline rename box) and
`SavedViewsPanelEmptyProps`. `SavedViewsPanelChromeProps` is what the panel
itself takes.

Every adapter ships it pre-wired as `SavedViewsPanel`:

```tsx
import { SavedViewsPanel } from "@adapttable/mantine";

<SavedViewsPanel
  views={views}
  onApply={apply}
  onRename={rename}
  onMove={move}
  onSetDefault={setDefault}
  onRemove={remove}
  footer={<span>Upgraded on load: Legacy view (v1)</span>}
/>;
```

`footer` puts anything of yours inside the card, under the list — a note about
where the views came from, a link to your own docs. Outside the card a line
like that reads as a caption belonging to whatever follows it on the page.

The panel is a card with a title, and each view is one row inside it. The row
has a single primary action: **applying a view is clicking its name**, which is
the widest target on the row and the thing a reader wants nine times out of
ten. Rename, move up, move down, set-default and delete are icons in a compact
cluster at the end of the line, each with its own localized accessible name.
`SavedViewRowControl` describes one of them and `SavedViewControlKey` names
which — an adapter maps over `controls` rather than hand-writing five buttons,
so no kit can render four of them or put them in a different order.

Reordering is buttons rather than drag, because a list you can only reorder by
dragging is a list some people cannot reorder. The move a row cannot make is
disabled rather than removed, so the row does not jump as the list is
reordered. Renaming is an inline input rather than a dialog: the name is
already on screen, and Escape abandons the edit without changing anything.

The card names four parts for styling and testing: `saved-views-panel` and
`saved-views-title` on the card, `saved-view-row` on each view, and
`saved-views-footer` on your note.

## Example

```tsx
// Needs your kit's provider once at the root (e.g. <MantineProvider>).
import { DataTable } from "@adapttable/mantine"; // or mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { columnMenu } from "@adapttable/mantine/column-menu";
import { savedViews } from "@adapttable/mantine/saved-views";

interface Person {
  id: string;
  name: string;
  department: { name: string };
  status: string;
  salary: number;
}

const data: Person[] = [
  {
    id: "1",
    name: "Amira Haddad",
    department: { name: "Engineering" },
    status: "active",
    salary: 98000,
  },
  {
    id: "2",
    name: "Jonas Weber",
    department: { name: "Design" },
    status: "onleave",
    salary: 76000,
  },
  {
    id: "3",
    name: "Priya Nair",
    department: { name: "Engineering" },
    status: "active",
    salary: 112000,
  },
  {
    id: "4",
    name: "Sam Ortiz",
    department: { name: "Sales" },
    status: "left",
    salary: 64000,
  },
];

export function PeopleTable() {
  return (
    <DataTable
      data={data}
      rowKey={(r) => r.id}
      columns={[
        { key: "name", sortable: true },
        {
          key: "department.name",
          header: "Department",
          filter: { type: "select", options: "auto" },
        },
        { key: "status", filter: { type: "multiSelect", options: "auto" } },
        { key: "salary", filter: "numberRange", sortable: true },
      ]}
      features={[columnMenu(), savedViews({ storageKey: "people-views" })]}
    />
  );
}
```

## How it works

- Composing `savedViews({ storageKey })` renders the kit's built-in Saved-views
  menu in the toolbar next to the Columns button: click a name to apply it, the
  trailing ✕ to delete it, or type a name and Save to capture the current state.
- A view stores the table-scoped query string — search, sort, page and
  page-size, every `f_*` filter param, and the URL-persisted column layout.
  Only this table's params are captured; saving under an existing name
  replaces it.
- Applying first drops this table's current params, then lays the view's
  over — other tables sharing the URL are untouched, and anything the view
  doesn't mention returns to its default.
- The list persists as JSON under `storageKey` (localStorage by default).
- `urlAdapter` and `urlKey` default to the table's own, so usually only
  `storageKey` is needed.

## Options

`savedViews(options)` takes `UseSavedViewsOptions` (the same options as the
headless hook):

| Prop         | Type                  | Default                                | Description                                                      |
| ------------ | --------------------- | -------------------------------------- | ---------------------------------------------------------------- |
| `storageKey` | `string`              | — (required)                           | Storage key for the view list, e.g. `"people-table-views"`.      |
| `storage`    | `LayoutStorage`       | `localStorage` (memory-only under SSR) | Storage backend — supply your own to persist elsewhere.          |
| `store`      | `SavedViewsStore`     | —                                      | Server-side list; replaces `storage` entirely.                   |
| `visibility` | `"private" \| "team"` | `"private"`                            | What `save` marks a new view as.                                 |
| `migrate`    | `SavedViewMigration`  | —                                      | Upgrade a view behind `SAVED_VIEW_VERSION`, or drop it (`null`). |
| `urlAdapter` | `UrlStateAdapter`     | the table's `urlAdapter`               | The table's URL-state backend.                                   |
| `urlKey`     | `string`              | the table's `urlKey`                   | The table's URL namespace — must match the table's `urlKey`.     |
| `urlSync`    | `boolean`             | `true`                                 | `false` captures and applies against in-memory state.            |

The hook returns `UseSavedViewsResult`: `views`, `save`, `apply`, `remove`,
`rename`, `move`, `setDefault`, `defaultView` and `reload`. Each entry is a
`SavedView` — `name`, `search` (the table-scoped query string), `isDefault`,
`visibility` (`SavedViewVisibility`), `version` and `readOnly`. A
`SavedViewsStore` implements `list`, `save`, `remove` and an optional
`reorder`; `SAVED_VIEW_VERSION` is the schema views are written at (`2`).

## Notes

- **SSR-safe**: stored views hydrate in an effect after mount (no
  hydration mismatch); blocked storage is tolerated.
- With `urlSync={false}` the menu captures and applies the table's
  IN-MEMORY state — the address bar never changes and foreign query
  params are never touched (`apply` writes only params the table owns).

- For custom UIs, use the headless hook and wire any menu into the `toolbar`
  slot (each adapter also exports its `SavedViewsMenu` component to pair with
  it):

  ```tsx
  import { useSavedViews } from "@adapttable/react";

  const views = useSavedViews({ storageKey: "people-views", urlKey: "people" });
  // views.views, views.save("Active EU"), views.apply("Active EU"),
  // views.remove("Active EU")
  ```

- Column layout, density, pinned rows and collapsed groups are part of a
  view only when they live in the URL (wire `useColumnLayoutUrlState`,
  `useDensityUrlState`, `useRowPinningUrlState`, `useGroupCollapseUrlState`);
  the localStorage-backed layout from `useColumnLayoutStorageState` is not
  captured.
- Views are local to the browser by default. Pass `storage` to persist them
  elsewhere; a full or denied storage degrades gracefully — the in-memory
  list keeps working for the session.
- Multiple tables on one page: give each table its own `urlKey` (so params
  are namespaced, `left.q`, `left.f_status`, …) and a distinct `storageKey`.
  Each menu captures and applies only its own namespace.
- A view stores state, not rows — applying one re-runs the usual
  search/filter/sort pipeline (or re-fires `onQueryChange` on the server
  tier).
- The menu's `savedViews` / `saveView` / `viewName` / `deleteView` labels are
  overridable via `labels` and localized by the `@adapttable/i18n` presets.

See it live in the [demo](https://adapttable.orwamahmoud.com/react/demo/).

---

# React table virtualization — rows, columns and mobile cards

▶ **Try it live:** [open a Mantine starter in StackBlitz](https://stackblitz.com/github/orwa-mahmoud/adapttable/tree/main/starters/mantine?file=src%2FApp.tsx) — a real AdaptTable you can edit in the browser, no install. [Other UI kits →](./getting-started.md#try-it-in-stackblitz)

▶ **See it working:** [scroll 50,000 rows in the live demo](https://adapttable.orwamahmoud.com/react/demo/mantine/scale/) — a real table you can scroll, not a recording.

Long lists opt into row/card windowing with one feature: `virtualize()` from
`@adapttable/<kit>/virtualize`. Fifty
thousand rows render as a handful of DOM nodes, on the page or inside a
fixed-height box.

## Example

```tsx
import { DataTable } from "@adapttable/mantine"; // or @adapttable/mui, chakra, antd, radix, base-ui, shadcn, unstyled
import { virtualize } from "@adapttable/mantine/virtualize";

interface Reading {
  id: string;
  sensor: string;
  value: number;
}

const data: Reading[] = Array.from({ length: 50_000 }, (_, i) => ({
  id: String(i + 1),
  sensor: `Sensor ${(i % 40) + 1}`,
  value: Math.round(Math.sin(i) * 1000) / 10,
}));

export function Readings() {
  return (
    <DataTable
      data={data}
      columns={[{ key: "sensor", sortable: true }, { key: "value" }]}
      rowKey={(r) => r.id}
      paginationMode="infinite"
      features={[virtualize()]}
      maxHeight={380}
      estimateRowSize={56}
      estimateCardSize={140}
    />
  );
}
```

The estimates default to **56 px** rows and **132 px** cards — pass your
real measured sizes (like the `140` above) when your cells differ.

## How it works

- `virtualize()` is opt-in: a table that does not compose it never loads
  `@tanstack/react-virtual`. It applies in **infinite (non-paged) mode** — paged tables already cap the row count, so they never virtualize.
- **Window mode** (no `maxHeight`): the virtual window tracks the page scroll.
  The list's offset from the top of the document is measured automatically,
  so a table below page chrome does not open with a blank gap. Pass
  `virtualScrollMargin` only to override that measurement.
- **Element mode** (any `maxHeight` box): the same feature virtualizes inside the
  scroll box instead — the box is the scroller and the window tracks it.
  Mobile cards attach it to the named scroll region around the card list
  (desktop rows attach it to the table assembly), so `maxHeight` + `virtualize()`
  never mounts every card. The bounded mobile region is keyboard-focusable;
  the inner list keeps its list semantics and absolute card positions.
- `rowAppearance({ rowHeight })` overrides the estimate when set — a function is per row, so
  a variable-height table still windows. See [row styling and heights](./row-styling.md).
- Rows/cards are measured after render; `estimateRowSize` (desktop rows) and
  `estimateCardSize` (mobile cards) seed the math, and `virtualOverscan` rows
  are rendered beyond the visible window to keep scrolling smooth.
- Inside a `maxHeight` box the page-level **Load more** button and
  infinite-scroll sentinel are suppressed: the box never grows, so the virtual
  window extends itself at the box's scroll end instead.
- Ant Design maps `virtualize()` to antd's **native** virtual table mode on
  desktop; on mobile the cards window through the shared engine, just like
  every other adapter, and the page-level sentinel keeps loading more.
  `virtualize({ virtualizeColumns: true })` has no effect there: antd owns its scroller, so there is
  no measured box for the horizontal window, and every column stays in the DOM.

## Options

`virtualize(options?)` takes `true` (the default), `false`, or an options
object with `virtualizeColumns` and the size knobs below. The size knobs are
accepted on `DataTable` as props too.

| Option                | Type      | Default | Description                                                                                                     |
| --------------------- | --------- | ------- | --------------------------------------------------------------------------------------------------------------- |
| `virtualizeColumns`   | `boolean` | `false` | Window the rendered columns on very wide tables. Applies with `maxHeight` or pinned columns; not in Ant Design. |
| `maxHeight`           | `number`  | —       | `DataTable` prop. Fixed-height scroll box (px); switches to element-mode windowing.                             |
| `estimateRowSize`     | `number`  | `56`    | Desktop row-height estimate in px.                                                                              |
| `estimateCardSize`    | `number`  | `132`   | Mobile card-height estimate in px.                                                                              |
| `virtualOverscan`     | `number`  | `8`     | Extra rows/cards rendered before and after the visible window.                                                  |
| `virtualScrollMargin` | `number`  | —       | Override for the measured window-mode list offset.                                                              |

## Benchmark

Virtualization renders only the rows in view, so cost is bounded by the
viewport — not the dataset. A measured A/B on the scale demo (Mantine adapter,
the **same 10,000-row dataset fully loaded**, the showcase production build,
headless Chromium 151 at 1280×900 on an Apple M1 Max), recorded in
[`scripts/bench-runs/2026-09-23.json`](https://github.com/orwa-mahmoud/adapttable/blob/main/scripts/bench-runs/2026-09-23.json):

|                                  | Rows in the DOM | Retained JS heap |
| :------------------------------- | --------------: | ---------------: |
| **Virtualized** (`virtualize()`) |          **10** |        **11 MB** |
| Plain table — same 10,000 rows   |          10,000 |           202 MB |

Windowing mounts **1,000× fewer rows** (10 vs 10,000 — a viewport's worth
plus overscan) and holds **191 MB less memory, about 95% less** — while the
plain table blocks the main thread rendering ten thousand `<tr>`s.

The two arms differ in kind, not just degree: the plain table's memory sits in
10,000 mounted rows, which cannot be released while they are on screen. The
virtualized one mounts 10 and keeps the rest as a plain array, so what it costs
is your data, not your table.

And it stays flat: the rendered row count holds at **10 whether the dataset is
1,000 or 100,000 rows**. Only your own data array grows — never the table's DOM:

| Rows in the dataset |  1,000 | 10,000 | 50,000 | 100,000 |
| ------------------: | -----: | -----: | -----: | ------: |
|     Rows in the DOM | **10** | **10** | **10** |  **10** |

Reproduce both with
[`scripts/bench.mjs`](https://github.com/orwa-mahmoud/adapttable/blob/main/scripts/bench.mjs)
— it serves this demo itself, drives it through the whole scenario set (wide
tables, grouping, pinned columns, sorted data) and prints the DOM rows, cells,
heap and time-to-interactive for each:

```bash
node scripts/bench.mjs                   # every scenario
node scripts/bench.mjs --smoke           # the fast subset CI runs
node scripts/bench.mjs --record          # also saves the run to scripts/bench-runs/
```

`--record` writes the run — every scenario, the machine and the browser build it
ran on — to a dated JSON file in
[`scripts/bench-runs/`](https://github.com/orwa-mahmoud/adapttable/tree/main/scripts/bench-runs).

With nothing on the port it builds

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.