# React Tablekit: the complete documentation

> A React data table with a framework-agnostic core. Pagination, search, filters, sorting, grouping, expansion, selection and faceting each run in the browser or on your server. Every part is a replaceable slot, every interaction is overridable, and every visual value is a CSS variable.

Version 1.0.2. Every page of https://react-tablekit.vercel.app/react-tablekit/ in reading order, generated from the same sources in the same build as the site. Each live example is written out as its source and each reference page as its tables. The examples import a few site modules (paths beginning `@/`); they are at the end, under "Shared example code".

Every page begins with its title as a level-one heading and its URL, so an answer taken from this file can name the page it came from.

## Contents

- **Getting started**: Overview, Installation, Quickstart, AI context, Requirements and compatibility, FAQ, Support, Versions
- **Features**: All features, Columns, Sorting, Global search, Column filters, Faceting, Pagination, Row selection, Expansion, Client and server, Tree data, Grouping, Column pinning, Column sizing, Column ordering, Column visibility, Column footers, Row pinning, Row numbers, Row appearance, Sticky header and footer, Surface, Density, Virtualization, Responsive, Toolbar, Loading and empty states, Keyboard navigation, Export, Persistence, Headless, Localization
- **Demos**: Demos, Account list, Asset list, Reading history, Asset picker, Playground, Theme editor
- **Reference**: API reference, DataTableProps, ColumnDef, TableInstance, TableState, Hooks, Utilities, TableSlots, Handlers, Theme tokens, Localization keys, Icons
- **Customization**: How to customize, Theming, Design tokens, Overriding structure, Slots, Rows and cells, Cell building blocks, Handler middleware, Recipes
- **Guides**: Guides, Server data, Best practices, Performance, Testing, Accessibility, Security
- **Integrations**: Integrations, React Query, React Router, Next.js, Tailwind CSS, i18next
- **Migration**: Migration
- **Discover more**: Discover more, Roadmap, Changelog

---

# Overview

> A headless-core React data table where every feature works on the client and on the server.

URL: https://react-tablekit.vercel.app/react-tablekit/

## Introduction

react-tablekit renders tabular data in React. The engine is framework-agnostic and renders
nothing on its own; the components on top of it are a default you can replace part by part.

Its organising idea is that **where the work happens should not change your code**. Pagination,
global search, column filters, sorting, grouping, expansion, selection and faceting each run
either in the browser or on your server, chosen per feature. Starting client-side and moving to
the server later is a prop, not a rewrite.

What it is not: a spreadsheet, a chart library, or a data-fetching library. It has no opinion
about how rows reach it: `fetch`, React Query, a router loader or an array in memory all work.

## Why react-tablekit

- **Client and server are the same code.** Each feature picks its side; the table handles the
  debouncing, aborting, race conditions and out-of-range pages that make server mode tedious.
- **Nothing is sealed.** Every part is a slot, every interaction is middleware you can wrap, and
  every visual value is a CSS variable.
- **Headless underneath.** `useDataTable` is the whole engine with no markup; `createTable` runs
  without React at all.
- **Accessible by default.** Correct table semantics, the right ARIA role for the features you
  enable, the data-grid keyboard pattern and polite live announcements.
- **Zero runtime dependencies.** Only React as a peer. ESM and CJS with types for both, tree
  shakeable and server-render safe.

## Start now

- [Installation](/react-tablekit/getting-started/installation/): install the package and the stylesheet.
- [Quickstart](/react-tablekit/getting-started/quickstart/): a complete first table, nothing elided.
- [All features](/react-tablekit/all-features/): every capability, each with a live demo.
- [Client and server](/react-tablekit/client-and-server/): the defining feature, in one page.
- [Customization](/react-tablekit/customization/): the nine levels, from a token to the core.
- [API reference](/react-tablekit/api/): generated from the source, so it cannot drift.

## Licence

MIT, with no paid tier and no feature gating. Everything documented on this site is in the
package you install.

---

# Installation

> Install the package, import the stylesheet and render your first table.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/installation/

## Prerequisites

- **React 18.2 or newer** (React 18 and 19 are both supported), with `react-dom` at the same version.
- **TypeScript 5.4 or newer** if you use TypeScript. The package ships its own types; nothing extra to install.
- A bundler that understands ES modules: Vite, Next.js, Remix, Rspack, webpack 5 or Parcel.

## Install

```sh
pnpm add @kingtech/react-tablekit
```

```sh
npm install @kingtech/react-tablekit
```

```sh
yarn add @kingtech/react-tablekit
```

## Import the stylesheet

Import it once, wherever your application starts.

```ts
import '@kingtech/react-tablekit/styles.css';
```

The styles live inside `@layer tablekit`, so any rule you write outside that layer wins without `!important`.

## Verify

Render the table from the [quickstart](/react-tablekit/getting-started/quickstart/). You should see a table with sortable headers, a search box above it and pagination below it. If the table renders but has no borders or spacing, the stylesheet import is missing.

## Next steps

- [Quickstart](/react-tablekit/getting-started/quickstart/): a complete, copy-pasteable first table.
- [Columns](/react-tablekit/columns/): how a column is defined.
- [Client and server](/react-tablekit/client-and-server/): moving the work to your API.

---

# Quickstart

> A complete first table: data, columns and the features that are on by default.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/quickstart/

## Prerequisites

The package installed and `@kingtech/react-tablekit/styles.css` imported; see [Installation](/react-tablekit/getting-started/installation/).

## A complete table

This is the whole file. Nothing is elided.

**Example: A minimal table** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/columns/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(120);

/** The minimal table: `data` + `columns` (plus an accessible name and stable row ids). */
export default function BasicExample() {
  return (
    <DataTable aria-label="People" data={data} columns={peopleColumns} getRowId={(p) => p.id} />
  );
}
```

```tsx
import '@kingtech/react-tablekit/styles.css';
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';

interface Person {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  age: number;
}

const people: Person[] = [
  { id: '1', firstName: 'Ada', lastName: 'Lovelace', email: 'ada@example.com', age: 36 },
  { id: '2', firstName: 'Grace', lastName: 'Hopper', email: 'grace@example.com', age: 45 },
];

const col = createColumnHelper<Person>();

const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, { id: 'name', header: 'Name' }),
  col.accessor('email', { header: 'Email' }),
  col.accessor('age', { header: 'Age', type: 'number' }),
];

export function People() {
  return (
    <DataTable
      aria-label="People"
      data={people}
      columns={columns}
      getRowId={(person) => person.id}
    />
  );
}
```

## Verify

You should now see a table with three columns. Click a header to sort it, type in the search box to filter, and use the toolbar to hide a column. None of that needed configuration: sorting, global search, column filters, column visibility, density and pagination are on by default.

## What you did not have to write

`getRowId` is the only prop that looks like bookkeeping, and it earns its place: stable row identity is what lets selection survive a refetch, expansion survive a sort, and "select all matching" work across server pages.

## Next steps

- [All features](/react-tablekit/all-features/): everything the table can do.
- [Sorting](/react-tablekit/sorting/): comparators, multi-sort and server sorting.
- [Client and server](/react-tablekit/client-and-server/): one prop moves the work to your API.

---

# AI context

> The whole documentation as one Markdown file, with the source of every example, for coding agents.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/ai-context/

A coding agent writes better table code from this documentation than from what it remembers about
data tables in general. So the site also publishes itself as Markdown: one file with every page and
every example, an index of the pages, and each page on its own.

## Prerequisites

- A coding agent that can read files in your repository or fetch a URL: Claude Code, Cursor,
  GitHub Copilot, Codex or similar.
- `@kingtech/react-tablekit` installed, or about to be. See [Installation](/react-tablekit/getting-started/installation/).

## Installation

Save the full file next to your code, where the agent can read it:

```sh
curl -o docs/react-tablekit.md https://react-tablekit.vercel.app/llms-full.md
```

Then tell the agent it is there. One line in the instructions file your tool reads at the start of
every session is enough: `CLAUDE.md`, `AGENTS.md`, a rule in `.cursor/rules/`, or
`.github/copilot-instructions.md`.

```md
For anything involving data tables, read docs/react-tablekit.md first, and prefer it to what you remember.
```

The file describes one version of the package. When you upgrade, download it again.

## What is in the file

Every page of this documentation in reading order: the getting-started pages, every feature page,
the demos, the guides and the whole API reference. Where the site shows a running example, the file
has its source; where the site shows a reference table, the file has the table. The examples import
a few site modules (sample data, shared columns and the mocked API they fetch from), and those are
included once at the end, so every example is complete.

Each page starts with its title and its URL, so an agent can say which page an answer came from.
The file is generated from the same Markdown as the site, in the same build, so it cannot say
anything the site does not.

## Which file to use

| File                            | What it holds                                          | Use it when                               |
| ------------------------------- | ------------------------------------------------------ | ----------------------------------------- |
| [llms-full.md](/llms-full.md)   | Every page and the source of every example             | the agent should know the whole library   |
| [llms-full.txt](/llms-full.txt) | The same file, under the name tools look for           | a tool asks for a documentation URL       |
| [llms.txt](/llms.txt)           | An index: one line per page, each linking its Markdown | the agent should fetch only what it needs |
| A page's URL + `index.md`       | That one page, with its examples                       | you are working on a single feature       |

The full file is large, but it fits whole in the context window of a current frontier model. If
your tool's window is smaller, give it `llms.txt` and let it fetch the pages it needs. Any page
works on its own the same way: [the sorting page](/react-tablekit/sorting/index.md) is
`/react-tablekit/sorting/` with `index.md` added.

## Minimal working example

With the file in `docs/`, a request like this has everything it needs to be answered from the
documentation rather than from guesswork:

```text
Using docs/react-tablekit.md, add an orders table to OrdersPage. Load it from GET /api/orders with
server-side paging and sorting, filter by status (open, paid, refunded), and let the user export
the current page as CSV.
```

## Verify

Ask the agent something only the documentation answers: for example, which CSS variable sets the
background of striped rows. An agent reading the file answers `--tk-row-striped-bg` and can name
the page it came from, Theme tokens. One that is not reading it guesses.

## Next steps

- [Installation](/react-tablekit/getting-started/installation/): the package, its peers and the stylesheet.
- [Quickstart](/react-tablekit/getting-started/quickstart/): a complete first table.
- [API reference](/react-tablekit/api/): generated from the TypeScript declarations.

---

# Requirements and compatibility

> Supported React versions, browsers, bundlers and rendering environments.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/requirements/

## React

| Requirement | Version                       |
| ----------- | ----------------------------- |
| `react`     | >= 18.2 (18 and 19 supported) |
| `react-dom` | >= 18.2                       |

Both are peer dependencies. The package itself has **zero runtime dependencies**.

## Browsers

The last two versions of evergreen browsers, and Safari 15.4 or newer. The baseline features are `@layer` and `inset`; `:has()` is used only as an enhancement.

## Bundlers and frameworks

The package ships ESM and CJS with type definitions for both, and declares `sideEffects` so bundlers can tree-shake it. The React entry carries a `"use client"` banner, so it works in the Next.js App Router without extra wrapping. See [Next.js](/react-tablekit/integrations/next-js/).

## Server rendering

The table renders on the server: rows, `role="grid"` and the cards layout are all present in the server HTML. Because there is no viewport to measure, responsive values need a declared breakpoint; see `ssrBreakpoint` in [Responsive](/react-tablekit/responsive/).

## Bundle size

| Entry                                                  | min+gzip |
| ------------------------------------------------------ | -------- |
| `import { DataTable } from '@kingtech/react-tablekit'` | 56.5 kB  |
| Full import                                            | 60.1 kB  |
| `@kingtech/react-tablekit/core`                        | 20.8 kB  |
| `@kingtech/react-tablekit/styles.css`                  | 6.1 kB   |

`DataTable` is batteries-included, so the first two numbers are close together. If you need a small bundle, use [`@kingtech/react-tablekit/core`](/react-tablekit/headless/) with your own markup.

---

# FAQ

> Short answers about licensing, scope, styling and how this library compares.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/faq/

## Is it free?

Yes. MIT licensed, with no paid tier, no feature gating and no licence key. You can use it commercially.

## Does it depend on a component framework?

No. There is no Material UI, no styled-components, no CSS-in-JS runtime. Styling is plain CSS with custom properties inside `@layer tablekit`.

## Can I use my own design system?

Yes, at three levels: override CSS variables, pass your own class names, or replace whole parts with your own components. See [How to customize](/react-tablekit/customization/).

## Does it do server-side data?

Every data feature does, and each one independently. See [Client and server](/react-tablekit/client-and-server/).

## Can I use it without the built-in markup?

Yes. `useDataTable` gives you the engine and renders nothing; `createTable` runs without React at all. See [Headless](/react-tablekit/headless/).

## Is inline editing supported?

Not in 1.0. It is specified and planned; see the [roadmap](/react-tablekit/discover-more/roadmap/).

## How large is it?

56.5 kB min+gzip for a basic import; 20.8 kB for the headless core. Full numbers in [Requirements](/react-tablekit/getting-started/requirements/).

---

# Support

> Where to ask a question, report a bug and request a feature.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/support/

## Reporting a bug

[Open an issue on GitHub](https://github.com/kiralygyula92/react-tablekit/issues). A reproduction is worth more than a description: the fastest route is a link to a [playground](/react-tablekit/demos/playground/) configuration that shows the problem, since the whole configuration lives in the URL.

Please include the version, the React version and whether the table is in client or server mode.

## Asking a question

Use GitHub issues for questions too. There is no paid support channel and no guaranteed response time; this is a free, MIT-licensed project.

## Requesting a feature

Check the [roadmap](/react-tablekit/discover-more/roadmap/) first: several capabilities are specified but deliberately deferred past 1.0. If it is not there, open an issue describing the problem you are solving rather than the API you imagine.

## Security

Report a suspected vulnerability privately through
[GitHub's security advisories](https://github.com/kiralygyula92/react-tablekit/security/advisories)
rather than a public issue. See the [security guide](/react-tablekit/guides/security/) for the threat model.

---

# Versions

> Which versions are supported, and what a minor release is allowed to change.

URL: https://react-tablekit.vercel.app/react-tablekit/getting-started/versions/

## Current version

**1.0.0**: the first stable release.

| Version | Status    | Documentation |
| ------- | --------- | ------------- |
| v1      | Supported | This site     |

## What the public API covers

More than the exported names. All of the following are public, and changing or removing any of them is a **major** release:

- every name and signature in the [API reference](/react-tablekit/api/);
- the `tk-*` class names, so a stylesheet that targets them keeps working;
- the `--tk-*` CSS variables;
- the `data-*` attributes the table puts on its elements;
- slot names, handler names and localization keys.

## What a minor release may change

Token default _values_. A minor may retune a colour or a spacing step. If you depend on an exact value, set it yourself rather than relying on the default.

The `classic` preset is the exception: its values are frozen, because its whole purpose is to look exactly the way it looks.

## Deprecations

A deprecated API is marked `@deprecated` in its TSDoc, warns once per session in development builds, and is removed in the next major release at the earliest.

## Pre-releases

Pre-release versions are published under the `next` dist-tag, so `pnpm add @kingtech/react-tablekit` always installs a stable version.

---

# All features

> Every capability the table ships with, grouped the way the sidebar groups them.

URL: https://react-tablekit.vercel.app/react-tablekit/all-features/

Each capability below is one page with a runnable demo, its variations, the ways it can be
customised, its known limitations and links to the generated reference.

**What is in scope:** rendering, querying and interacting with tabular data, in the browser or
against a server. **What is deliberately out:** fetching (bring your own), charting, pivot tables
and spreadsheet-style editing. Inline editing is specified and planned; see the
[roadmap](/react-tablekit/discover-more/roadmap/).

## Core features

- [Columns](https://react-tablekit.vercel.app/react-tablekit/columns/): Accessors, headers, types and formatting: the column definition is where most of a table is decided.
- [Sorting](https://react-tablekit.vercel.app/react-tablekit/sorting/): Click to sort, shift-click to add a column, and comparators that understand text, dates and your own domain order.
- [Global search](https://react-tablekit.vercel.app/react-tablekit/global-search/): One input that matches across every searchable column, with debouncing, a minimum length and optional match highlighting.
- [Column filters](https://react-tablekit.vercel.app/react-tablekit/column-filters/): Per-column filters in a panel, a popover or a filter row, with a control chosen from the column's type and values drawn from the data.
- [Faceting](https://react-tablekit.vercel.app/react-tablekit/faceting/): Filter options and counts computed from the data: from the loaded rows in client mode, from your API in server mode.
- [Pagination](https://react-tablekit.vercel.app/react-tablekit/pagination/): Six presentations of the same state (numbered, compact, simple, load-more, infinite or none), including cursor-based paging.
- [Row selection](https://react-tablekit.vercel.app/react-tablekit/row-selection/): Checkboxes or radios, range selection with shift-click, a selection bar for bulk actions, and select-all that survives server paging.
- [Expansion](https://react-tablekit.vercel.app/react-tablekit/expansion/): Detail panels that open under a row, lazily rendered, animated, and pinned to the viewport width so a wide table cannot scroll them away.
- [Client and server](https://react-tablekit.vercel.app/react-tablekit/client-and-server/): The same table and the same props, with the work done in the browser, on your API, or split feature by feature.

## Advanced features

- [Tree data](https://react-tablekit.vercel.app/react-tablekit/tree-data/): Hierarchical rows with indentation, expand toggles and children that can be loaded from the server when a branch is opened.
- [Grouping](https://react-tablekit.vercel.app/react-tablekit/grouping/): Group rows by one column or several, with aggregates in the group row and totals in the footer.

## Display & layout

- [Column pinning](https://react-tablekit.vercel.app/react-tablekit/column-pinning/): Freeze columns to either edge, stacked and responsive, with a shadow that appears only when content actually scrolls underneath.
- [Column sizing](https://react-tablekit.vercel.app/react-tablekit/column-sizing/): Percentage or pixel widths, a keyboard-operable resize handle, and double-click autosizing that measures the rendered rows.
- [Column ordering](https://react-tablekit.vercel.app/react-tablekit/column-ordering/): Drag headers to reorder, or move them from the columns menu with the keyboard. Pinned groups and locked positions are respected.
- [Column visibility](https://react-tablekit.vercel.app/react-tablekit/column-visibility/): A columns menu for the reader, a per-column menu for actions, and responsive hiding that needs no state at all.
- [Column footers](https://react-tablekit.vercel.app/react-tablekit/column-footers/): A footer row with per-column totals, computed over the current page or over everything the filters left.
- [Row pinning](https://react-tablekit.vercel.app/react-tablekit/row-pinning/): Keep chosen rows at the top or the bottom while the rest of the table sorts, filters and pages away beneath them.
- [Row numbers](https://react-tablekit.vercel.app/react-tablekit/row-numbers/): A numbered column that counts through the whole result set, or restarts on every page.
- [Row appearance](https://react-tablekit.vercel.app/react-tablekit/row-appearance/): Striping, hover and per-row classes, styles and props: the way a row shows what it is without a custom renderer.
- [Sticky header and footer](https://react-tablekit.vercel.app/react-tablekit/sticky-header-and-footer/): Keep the header and the totals in view while the rows scroll, in both axes when columns are pinned.
- [Surface](https://react-tablekit.vercel.app/react-tablekit/surface/): The panel the table sits on: a bordered card with its pagination attached, or nothing at all, with corners you choose.
- [Density](https://react-tablekit.vercel.app/react-tablekit/density/): Three row heights the reader can switch between (compact, standard and comfortable), driven entirely by tokens.
- [Virtualization](https://react-tablekit.vercel.app/react-tablekit/virtualization/): Render the rows on screen instead of all of them: a hundred thousand rows with the same markup, the same keyboard model and the same counts for screen readers.
- [Responsive](https://react-tablekit.vercel.app/react-tablekit/responsive/): Breakpoint-aware props, a card layout for small screens, and a server-rendered first paint that does not jump.

## Interaction

- [Toolbar](https://react-tablekit.vercel.app/react-tablekit/toolbar/): The strip above the table (search, filters, columns, density, export), either as one component or as parts you place yourself.
- [Loading and empty states](https://react-tablekit.vercel.app/react-tablekit/loading-and-empty-states/): Skeletons, a delayed overlay, an empty state that knows why it is empty, and errors that keep the rest of the table usable.
- [Keyboard navigation](https://react-tablekit.vercel.app/react-tablekit/keyboard-navigation/): The WAI-ARIA data grid pattern: arrow keys between cells, one tab stop for the whole table, and Enter to reach interactive content.

## Automation

- [Export](https://react-tablekit.vercel.app/react-tablekit/export/): CSV of the page, the selection or everything matching, chunked through your API when the result is larger than the browser holds.
- [Persistence](https://react-tablekit.vercel.app/react-tablekit/persistence/): Put table state in the URL, in storage, or in both, so a link reproduces a view and a reload does not lose one.

## Developer tools

- [Headless](https://react-tablekit.vercel.app/react-tablekit/headless/): The engine without the markup: every feature, none of the table. Use it for cards, lists, charts, or a table you draw yourself.
- [Localization](https://react-tablekit.vercel.app/react-tablekit/localization/): Every string the table can show, in one object, with number, date and currency formatting that follows the same locale.

---

# Columns

> Accessors, headers, types and formatting: the column definition is where most of a table is decided.

URL: https://react-tablekit.vercel.app/react-tablekit/columns/

## Basics

**Example: Columns** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/columns/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(120);

/** The minimal table: `data` + `columns` (plus an accessible name and stable row ids). */
export default function BasicExample() {
  return (
    <DataTable aria-label="People" data={data} columns={peopleColumns} getRowId={(p) => p.id} />
  );
}
```

A column says where its value comes from and what it looks like. `createColumnHelper<Row>()`
gives you an `accessor` that keeps the value type attached, so `cell` and `sortingFn` are typed
against the real field rather than `unknown`.

```tsx
const col = createColumnHelper<Person>();

const columns = [
  col.accessor('firstName', { header: 'First name' }),
  col.accessor((p) => p.firstName + ' ' + p.lastName, { id: 'name', header: 'Name' }),
  col.display({ id: 'actions', header: '', cell: ({ row }) => <RowMenu id={row.id} /> }),
];
```

An **accessor column** reads a value and can therefore sort, filter, group and export. A
**display column** has no value: it renders something, and the table leaves it out of sorting,
filtering and CSV. A **group column** has `columns` of its own and produces a second header row.

### Column types

**Example: Column types** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/columns/demo-types)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(50);
const col = createColumnHelper<DemoPerson>();
const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});
const date = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' });

/**
 * `type` is the one setting that moves several defaults at once: alignment, the comparator,
 * the filter control and the formatter. Everything it picks can still be overridden per column.
 */
const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    type: 'text',
    getSearchValue: (p) => `${p.firstName} ${p.lastName} ${p.email}`,
  }),
  // Numbers align right and sort numerically without being told to.
  col.accessor('age', { header: 'Age', type: 'number' }),
  col.accessor('salary', {
    header: 'Salary',
    type: 'number',
    format: (v) => money.format(v),
  }),
  // Dates sort chronologically; `format` decides only what is displayed, searched and exported.
  col.accessor('joined', {
    header: 'Joined',
    type: 'date',
    format: (v) => date.format(new Date(v)),
  }),
  col.accessor('remote', {
    header: 'Remote',
    type: 'boolean',
    align: 'center',
    format: (v) => (v ? 'Yes' : 'No'),
  }),
  // `city` is absent for roughly one row in six; that is what the fallback is for.
  col.accessor('city', {
    header: 'City',
    renderFallbackValue: <span className="site-muted">unknown</span>,
  }),
  // A display column has no value of its own: it is computed, so it cannot be sorted or filtered.
  col.display({
    id: 'tenure',
    header: 'Tenure',
    align: 'right',
    cell: ({ row }) => {
      const years = Math.max(
        0,
        new Date().getFullYear() - new Date(row.original.joined).getFullYear(),
      );
      return years === 0 ? 'First year' : `${String(years)} yr`;
    },
  }),
];

/** Column types, formatting, alignment and fallback values. */
export default function ColumnTypesExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableColumnFilters
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

`type` sets the defaults a column would otherwise repeat: alignment, formatting, the comparator
and the filter control. `'number'` right-aligns and filters by range, `'date'` formats through
the locale and filters by date, `'boolean'` renders a yes/no and filters tri-state.

```tsx
col.accessor('total', { type: 'currency', meta: { currency: 'EUR' } });
```

Anything you set explicitly still wins; the type only supplies what you left out.

### Formatting a value

`format(value, row)` changes what is displayed while sorting, filtering, searching and export
keep the underlying value, which is why a formatted date still sorts chronologically. Use
`cell` when you need markup rather than text.

```tsx
col.accessor('createdAt', { type: 'date', format: (v) => relativeTime(v) });
col.accessor('status', { cell: ({ getValue }) => <StatusChip value={getValue()} /> });
```

For empty values, `renderFallbackValue` replaces `null`, `undefined` and the empty string in one
place instead of a guard in every cell.

### Widths and truncation

`width`, `minWidth` and `maxWidth` are CSS widths and accept percentages; `size`, `minSize` and
`maxSize` are the pixel model used once [column resizing](/react-tablekit/column-sizing/) is on.
`truncate` clips with an ellipsis and keeps the full text as a tooltip, and `noWrap` stops a long
cell wrapping to a second line.

## Customization

`columnDefaults` applies a partial definition to every column, so a table-wide convention is
written once:

```tsx
<DataTable columnDefaults={{ enableHiding: false, truncate: true }} columns={columns} />
```

`defineColumns` is the same list without the helper's binding, useful when the columns are
assembled somewhere the row type is not in scope yet. Header and cell rendering are replaceable
through [slots](/react-tablekit/customization/slots/).

## Limitations

- **A column's `id` must be unique and stable.** An accessor function column has no natural id,
  so one must be given; changing ids between renders resets sizing, ordering and visibility.
- **Display columns never appear in CSV.** They have no value to export. If a computed column
  should be exported, make it an accessor column with `accessorFn` and an `exportValue`.
- **Group columns cannot be sorted or filtered themselves.** They are header containers; their
  leaves carry the behaviour.

## API

- [`ColumnDef`](/react-tablekit/api/column-def/): the whole definition.
- [`DataTableProps`](/react-tablekit/api/data-table-props/): `columns`, `columnDefaults`,
  `defaultColumn`, `displayColumnDefs`, `renderFallbackValue`.
- [Utilities](/react-tablekit/api/utilities/): `createColumnHelper`, `defineColumns`.

---

# Sorting

> Click to sort, shift-click to add a column, and comparators that understand text, dates and your own domain order.

URL: https://react-tablekit.vercel.app/react-tablekit/sorting/

## Basics

**Example: Sorting** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/sorting/demo-basics)

```tsx
import { createColumnHelper, DataTable, type SortingFn } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(60);
const col = createColumnHelper<DemoPerson>();

/** Seniority is an order, not an alphabet: "active" before "invited" before "suspended". */
const STATUS_ORDER: Record<DemoPerson['status'], number> = {
  active: 0,
  invited: 1,
  suspended: 2,
};

const byStatus: SortingFn<DemoPerson> = (rowA, rowB) =>
  STATUS_ORDER[rowA.original.status] - STATUS_ORDER[rowB.original.status];

const columns = [
  // `alphanumeric` is the natural comparator: "Room 2" sorts before "Room 10".
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    sortingFn: 'alphanumeric',
  }),
  // Locale-aware, so "Ávila" lands next to "Avila" rather than after "Z".
  col.accessor('city', {
    header: 'City',
    sortingFn: 'text',
    // Rows with no city go last in both directions, instead of bubbling to the top.
    sortUndefined: 'last',
  }),
  col.accessor('department', { header: 'Department' }),
  // A custom comparator for a domain order no built-in can know.
  col.accessor('status', { header: 'Status', sortingFn: byStatus }),
  // Money and dates are almost always most-interesting-first on the first click.
  col.accessor('salary', { header: 'Salary', type: 'number', sortDescFirst: true }),
  col.accessor('joined', { header: 'Joined', type: 'date', sortDescFirst: true }),
  col.accessor('age', { header: 'Age', type: 'number' }),
];

/**
 * Client sorting. Click a header to sort, click again to reverse, and a third time to
 * clear it. Hold Shift and click to add a second and third column; the badge on each header
 * shows its priority, and the whole thing is one comparison chain.
 */
export default function ClientSortingExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableSorting
      enableMultiSort
      initialState={{
        sorting: [{ id: 'status', desc: false }],
        pagination: { pageIndex: 0, pageSize: 8 },
      }}
    />
  );
}
```

Sorting is on by default. A click cycles ascending → descending → unsorted; holding **Shift**
adds a column to the sort instead of replacing it, and each header shows its position in the
chain.

### Choosing a comparator

The column `type` already picks a sensible comparator, so most tables need nothing here.
Override it with `sortingFn` when the default ordering is wrong for your data:

```tsx
col.accessor('name', { sortingFn: 'alphanumeric' }); // "Room 2" before "Room 10"
col.accessor('city', { sortingFn: 'text' }); // locale-aware: "Ávila" next to "Avila"
col.accessor('status', {
  sortingFn: (a, b) => RANK[a.original.status] - RANK[b.original.status],
});
```

The built-ins are `alphanumeric`, `alphanumericCaseSensitive`, `text`, `textCaseSensitive`,
`datetime`, `basic` and `boolean`.

### Missing values and direction

`sortUndefined` decides where empty cells go. `'last'` keeps them out of the way in both
directions, which is usually what people expect. `sortDescFirst` makes the first click
descending, which suits money and dates, and `invertSorting` suits ranks where lower is better.

When the value you sort by differs from the value you display (a formatted date, a chip label),
give the column a `sortValue`.

### Sorting on the server

**Example: Server sorting** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/sorting/demo-server)

```tsx
import { createColumnHelper, createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { RequestLog, useRequestLog } from '@/demo-support/requestLog';

const col = createColumnHelper<DemoPerson>();

/** The server sorts by its own field names, mapped with `sortServerKey`. */
const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    sortServerKey: 'last_name',
  }),
  col.accessor('email', { header: 'Email', enableSorting: false }),
  col.accessor('department', { header: 'Department', sortServerKey: 'department_code' }),
  col.accessor('age', { header: 'Age', type: 'number', sortServerKey: 'age_years' }),
  col.accessor('joined', { header: 'Joined', type: 'date', sortServerKey: 'joined_at' }),
];

const people = generatePeople(120);
const source = createLocalDataSource(people, {
  columns,
  getRowId: (p) => p.id,
  latencyMs: [150, 350],
});

/**
 * Manual (server) sorting: the table emits `sorting` using each column's `sortServerKey`, and the
 * simulated server maps those keys back to its data. Shift+click adds a second sort column.
 */
export default function ServerSortingExample() {
  const { dataSource, log, clear } = useRequestLog(source);
  return (
    <div className="example-stack">
      <DataTable
        aria-label="People (server sorting)"
        columns={columns}
        dataSource={dataSource}
        getRowId={(p) => p.id}
        manualSorting
        manualPagination
        enableMultiSort
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
      <RequestLog log={log} clear={clear} />
    </div>
  );
}
```

In server mode the sort list is sent rather than applied. Map column ids to your API's field
names with `sortServerKey`; nothing else changes.

```tsx
col.accessor('name', { sortServerKey: 'LastName' });
```

## Customization

The sort indicator, the clickable header and the header cell itself are all slots. Replacing the
indicator alone is the common case:

```tsx
<DataTable slots={{ SortIcon: ({ direction }) => <MyChevron direction={direction} /> }} />
```

To change what a click _does_ rather than how it looks, wrap the `onSortToggle` handler; it can
observe the intent, change it, or cancel it by not calling `next`. See
[Handler middleware](/react-tablekit/customization/handlers/).

## Limitations

- **Multi-sort priority cannot be reordered by dragging.** A column joins the chain in the order
  it was shift-clicked. To reorder, clear the sort and rebuild it, or set `state.sorting`
  yourself.
- **Server mode sorts whatever the server returns.** If you enable client sorting alongside
  server pagination, only the fetched page is ordered, and the table warns about this in
  development unless you pass `acknowledgePageLocalSorting`. See
  [Client and server](/react-tablekit/client-and-server/).
- **Grouped rows sort within their group**, never across groups; the group order comes from the
  grouping column. See [Grouping](/react-tablekit/grouping/).

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableSorting`, `enableMultiSort`,
  `enableSortingRemoval`, `sortingMode`, `onSortingChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `sortingFn`, `sortValue`, `sortDescFirst`,
  `sortUndefined`, `invertSorting`, `sortServerKey`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setSorting`, `resetSorting`.
- [Utilities](/react-tablekit/api/utilities/): the `sortingFns` registry.

---

# Global search

> One input that matches across every searchable column, with debouncing, a minimum length and optional match highlighting.

URL: https://react-tablekit.vercel.app/react-tablekit/global-search/

## Basics

**Example: Global search** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/global-search/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const people = generatePeople(60);
const others = generatePeople(40, 7);

/**
 * Client-side global search: debounce, minimum length, match highlighting and
 * diacritic-insensitive matching. Two tables share the `mod+k` hotkey to show that it is scoped
 * to the most recently focused or hovered table, never both at once.
 */
export default function GlobalSearchExample() {
  return (
    <div className="example-stack">
      <section className="example-panel">
        <h2>Employees</h2>
        <p className="site-muted">
          Type <code>zoe</code> to match “Zoë”, or <code>garcia</code> to match “García”: matching
          folds case and diacritics. Matches are highlighted, the query is debounced by 200ms and
          ignored below 2 characters. Press <kbd>Ctrl</kbd>+<kbd>K</kbd> (<kbd>⌘</kbd>+<kbd>K</kbd>)
          while the pointer or focus is inside this table.
        </p>
        <DataTable
          aria-label="Employees"
          data={people}
          columns={peopleColumns}
          getRowId={(p) => p.id}
          highlightSearchMatches
          searchMinLength={2}
          searchDebounceMs={200}
          searchHotkey="mod+k"
          initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
        />
      </section>
      <section className="example-panel">
        <h2>Contractors</h2>
        <p className="site-muted">
          The same hotkey, a different table. Whichever table you last touched wins, so the two
          never both react.
        </p>
        <DataTable
          aria-label="Contractors"
          data={others}
          columns={peopleColumns}
          getRowId={(p) => p.id}
          highlightSearchMatches
          searchMinLength={2}
          searchDebounceMs={200}
          searchHotkey="mod+k"
          globalFilterMatch="any-word"
          initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
        />
      </section>
    </div>
  );
}
```

The toolbar search box matches every column that has not opted out. Matching ignores case and
diacritics, so `zurich` finds `Zürich`. Escape clears the box while it has focus.

Two props keep it from firing on every keystroke: `searchDebounceMs` waits for typing to stop,
and `searchMinLength` ignores queries shorter than a threshold. Together they are the difference
between one request and eight.

### What a word matches

By default a multi-word query is an AND across words: `ada london` matches a row containing both,
in any column and any order. `globalFilterMatch` changes that to `'any-word'`, or to `'phrase'`
for a literal substring.

A column decides what text it contributes. `getSearchValue` wins, then `format`, then the raw
value stringified, so a formatted date is searched as it is displayed. Setting
`enableGlobalFilter: false` removes a column from search entirely, which is worth doing for ids
nobody types.

### Highlighting and the hotkey

`highlightSearchMatches` wraps hits in a `mark` element inside text cells. `searchHotkey` (for
example `'mod+k'`) focuses this table's box, and `showSearchHotkeyHint` prints the
platform-correct chip next to it. With two tables on a page the hotkey goes to the one most
recently focused or hovered, and it is ignored while you are typing in an unrelated field.

### Searching on the server

In server mode the query travels as `search` in the `TableQuery` and the server decides what it
means. Nothing about the control changes. See [Client and
server](/react-tablekit/client-and-server/).

## Customization

The box is a slot, and it is also a composable part: `<DataTable.Search table={table} />` renders
it anywhere on the page (a page header, a dialog title bar) while the table keeps owning the
state.

```tsx
<DataTable.Root table={table}>
  <PageHeader>
    <DataTable.Search table={table} />
  </PageHeader>
  <DataTable.Table />
</DataTable.Root>
```

`searchPlaceholder` changes the prompt; every other string comes from
[localization](/react-tablekit/localization/).

## Limitations

- **Search filters, it does not rank.** Rows are kept or dropped, never scored or reordered. For
  fuzzy ranking use a `fuzzy` [column filter](/react-tablekit/column-filters/), or search on the
  server.
- **Highlighting only applies to the default cell renderer.** A custom `cell` renders whatever
  you return, so the table cannot mark inside it.
- **`searchMinLength` also gates the server request**, which means a two-character query shows
  the unfiltered data rather than an empty table. That is deliberate, and it is why the threshold
  defaults low.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableGlobalFilter`,
  `searchDebounceMs`, `searchMinLength`, `searchMode`, `searchPlaceholder`, `searchHotkey`,
  `showSearchHotkeyHint`, `globalFilterFn`, `globalFilterMatch`, `highlightSearchMatches`,
  `onGlobalFilterChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `enableGlobalFilter`, `getSearchValue`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setGlobalFilter`, `resetGlobalFilter`.

---

# Column filters

> Per-column filters in a panel, a popover or a filter row, with a control chosen from the column's type and values drawn from the data.

URL: https://react-tablekit.vercel.app/react-tablekit/column-filters/

## Basics

**Example: Filter panel** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-filters/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(150);

/**
 * The default filter panel: a "Filters" button with an active count, one control per
 * filterable column (text, select, multi-select, range, date range, boolean), active filter chips
 * with per-chip removal, and facet counts taken from the data.
 */
export default function FiltersPanelExample() {
  return (
    <DataTable
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      filterDisplayMode="panel"
      showActiveFilterChips
      showFacetCounts
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

Each filterable column gets a control, and the control comes from the column: a `number` column
filters by range, a `date` column by date, a column with `filterOptions` by selection. You can
override it with `filterVariant`.

The **Filters** button in the toolbar carries a badge with the number of active filters and
toggles the panel. Active filters also appear as removable chips under the toolbar when
`showActiveFilterChips` is on, which is what makes "why is this table empty?" answerable at a
glance.

### Where the controls live

**Example: Filter row** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-filters/demo-row-mode)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(150);

/**
 * `filterDisplayMode="row"`: a second header row with an inline control under each
 * filterable column. Tab order follows the columns, so keyboard users can filter without leaving
 * the header.
 */
export default function FiltersRowExample() {
  return (
    <DataTable
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      filterDisplayMode="row"
      showActiveFilterChips
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

`filterDisplayMode` decides the surface, not the behaviour:

| Mode        | What the reader sees                                           |
| ----------- | -------------------------------------------------------------- |
| `'panel'`   | A collapsible grid of controls under the toolbar. The default. |
| `'popover'` | The same grid, anchored to the Filters button.                 |
| `'row'`     | A second header row with one input per column.                 |
| `'none'`    | No built-in UI; drive `state.columnFilters` yourself.          |

**Example: Filter popover and column menu** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-filters/demo-popover-mode)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(150);

/**
 * `filterDisplayMode="popover"` plus `enableColumnActions`: the same controls in a
 * popover anchored to the Filters button, and a "Filter…" entry in each column's actions menu that
 * opens just that column's filter.
 */
export default function FiltersPopoverExample() {
  return (
    <DataTable
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      filterDisplayMode="popover"
      enableColumnActions
      enableHiding
      showActiveFilterChips
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

Independently of the mode, `enableColumnActions` puts a **Filter…** entry in each header menu
that opens just that column's filter.

### Applying instantly or on demand

`filterApplyMode: 'manual'` adds an Apply button, so a reader can set three filters before the
table moves. Combined with `filterDebounceMs` on text inputs, it is how a server-mode table
avoids a request per keystroke.

### Choosing the comparison

`filterFn` picks the comparison and `filterOperators` decides which ones the reader may choose
from the operator dropdown. The built-ins cover text (`includesString`, `startsWith`,
`endsWith`, `equalsString`, `fuzzy`), numbers (`equals`, `inNumberRange`), dates (`dateEquals`,
`before`, `after`, `inDateRange`), arrays (`arrIncludes`, `arrIncludesAll`, `arrIncludesSome`)
and emptiness (`empty`, `notEmpty`).

```tsx
col.accessor('city', { filterVariant: 'multiSelect', filterFn: 'arrIncludesSome' });
col.accessor('price', { type: 'number', filterVariant: 'rangeSlider' });
```

### Filtering on the server

Filters serialize into the `filters` array of the `TableQuery`, using `filterServerKey` when your
API names a field differently. Option lists come from `dataSource.fetchFacets` instead of the
loaded rows; see [Faceting](/react-tablekit/faceting/).

## Customization

`renderFilter` replaces one column's control while the table keeps owning the value:

```tsx
col.accessor('assignee', {
  filterVariant: 'custom',
  renderFilter: ({ value, setValue }) => <UserPicker value={value} onChange={setValue} />,
});
```

The panel, the chips row and the Filters button are all [slots](/react-tablekit/customization/slots/),
and `<DataTable.FilterPanel />` places the panel anywhere on the page.

## Limitations

- **A column has one active filter at a time.** Two conditions on the same column ("after March
  and before June") belong in a `range` or `dateRange` variant, or in a custom `filterFn`.
- **Filters combine with AND.** There is no OR builder across columns; express that on the
  server, or with a single custom filter over a computed column.
- **`'row'` mode has no operator dropdown.** It is one input per column by design; use `'panel'`
  or the column menu when operators matter.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableColumnFilters`,
  `enableFilters`, `filterDisplayMode`, `filterApplyMode`, `filterDebounceMs`, `filterMode`,
  `filterFns`, `showActiveFilterChips`, `datePresets`, `onColumnFiltersChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `enableColumnFilter`, `filterVariant`,
  `filterFn`, `filterOperators`, `filterOptions`, `filterPlaceholder`, `filterServerKey`,
  `renderFilter`, `multiSelectDisplay`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setColumnFilters`,
  `resetColumnFilters`, `clearAllFilters`, `getActiveFilterCount`.

---

# Faceting

> Filter options and counts computed from the data: from the loaded rows in client mode, from your API in server mode.

URL: https://react-tablekit.vercel.app/react-tablekit/faceting/

## Basics

**Example: Server-side facets** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/faceting/demo-basics)

```tsx
import { createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';
import { RequestLog, useRequestLog } from '@/demo-support/requestLog';

const people = generatePeople(400);
const source = createLocalDataSource(people, {
  columns: peopleColumns,
  getRowId: (p) => p.id,
  latencyMs: [200, 450],
});

/**
 * Server filtering and faceting: filters and the search are serialized into the query,
 * and the option lists plus their counts are loaded lazily through `dataSource.fetchFacets`, so
 * the browser never sees the full dataset.
 */
export default function FiltersServerExample() {
  const { dataSource, log, clear } = useRequestLog(source);
  return (
    <div className="example-stack">
      <DataTable
        aria-label="People (server filtering)"
        columns={peopleColumns}
        dataSource={dataSource}
        getRowId={(p) => p.id}
        dataMode="server"
        showActiveFilterChips
        showFacetCounts
        searchDebounceMs={300}
        searchMinLength={2}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
      <RequestLog log={log} clear={clear} />
    </div>
  );
}
```

A facet is the answer to "what can I filter this column by, and how many rows would each choice
leave?" When a column has no `filterOptions`, the table works them out: the distinct values for a
select or multi-select, and the minimum and maximum for a range or slider.

Counts are shown next to each option, as in `Berlin (142)`, and they respect the _other_ active
filters, so the numbers describe what you would actually get.

### Client and server

In client mode the facets come from `column.getFacetedUniqueValues()` and
`column.getFacetedMinMaxValues()` over the currently loaded rows, which is exact because the
browser has all of them.

In server mode the browser has one page, so counting locally would be wrong. The table asks the
data source instead:

```tsx
const dataSource: DataSource<Person> = {
  ...createRestDataSource<Person, PeoplePage>({ url: '/api/people', mapQuery, mapResult }),
  fetchFacets: (columnId, query, { signal }) => api.facets(columnId, query, signal),
};
```

`fetchFacets` is a method of the data source rather than an option of `createRestDataSource`,
so it is added beside the REST source's own `fetch`. It is asked for one column at a time.

Facets are requested lazily, the first time a filter control opens, and cached per query, so
opening the same dropdown twice costs one round trip. `manualFaceting` and `facetingMode` force
the choice when the default inference is not what you want, and `facets` supplies them outright.

### Turning it off

`enableFacetedValues: false` stops the computation for tables where the option lists are static
and known. Then `filterOptions` on the column is the only source, and no counts are shown.
`showFacetCounts: false` keeps the options but drops the numbers.

## Customization

`filterOptions` accepts a static array, a function, or an async function; the control shows a
loading state while it resolves. Options carry their own presentation, which is what makes a
status filter look like the status column:

```tsx
col.accessor('status', {
  filterVariant: 'select',
  filterOptions: [
    { value: 'active', label: 'Active', color: '#e6f4ea' },
    { value: 'archived', label: 'Archived', color: '#f1f3f4' },
  ],
});
```

Those colours are reused by the [filter chips](/react-tablekit/column-filters/).

## Limitations

- **Client facets only see loaded rows.** With server pagination the browser holds one page, so
  faceting must come from the server or the counts will describe the page rather than the data
  set.
- **Counts reflect the other filters, not this one.** A multi-select shows what each option would
  add, which is the standard behaviour but is not the same as the count after applying it.
- **There is no facet for a display column.** A facet needs a value; compute it with an
  `accessorFn` if you need to filter on something derived.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableFacetedValues`,
  `facetingMode`, `manualFaceting`, `facets`, `showFacetCounts`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `filterOptions`, `filterVariant`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getColumn(id).getFacetedUniqueValues`,
  `getFacetedMinMaxValues`, `getServerFacets`, `loadFacets`.
- [Utilities](/react-tablekit/api/utilities/): `createRestDataSource`.

---

# Pagination

> Six presentations of the same state (numbered, compact, simple, load-more, infinite or none), including cursor-based paging.

URL: https://react-tablekit.vercel.app/react-tablekit/pagination/

## Basics

**Example: Pagination variants** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/pagination/demo-basics)

```tsx
import { useId, useState } from 'react';
import {
  DataTable,
  getPageItems,
  type PageItem,
  type PaginationVariant,
} from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(235);
const columns = peopleColumns.slice(0, 4);
const VARIANTS: PaginationVariant[] = ['numbered', 'compact', 'simple', 'loadMore'];

const render = (items: PageItem[]) =>
  items
    .map((i) => (i.type === 'page' ? (i.selected ? `[${i.index + 1}]` : String(i.index + 1)) : '…'))
    .join(' ');

/** Algorithm visualizer: classic (boundary + window rule) vs stable (constant width). */
function Visualizer() {
  const [pageCount, setPageCount] = useState(24);
  const [pageIndex, setPageIndex] = useState(0);
  const pageId = useId();
  const countId = useId();
  const classic = getPageItems({ pageIndex, pageCount });
  const stable = getPageItems({ pageIndex, pageCount, algorithm: 'stable' });
  return (
    <section className="example-panel" aria-labelledby={`${pageId}-title`}>
      <h2 id={`${pageId}-title`}>Page-item algorithms</h2>
      <div className="example-controls">
        <label htmlFor={countId}>
          Pages: {pageCount}
          <input
            id={countId}
            type="range"
            min={1}
            max={50}
            value={pageCount}
            onChange={(e) => {
              const n = Number(e.target.value);
              setPageCount(n);
              setPageIndex((i) => Math.min(i, n - 1));
            }}
          />
        </label>
        <label htmlFor={pageId}>
          Current page: {pageIndex + 1}
          <input
            id={pageId}
            type="range"
            min={0}
            max={pageCount - 1}
            value={pageIndex}
            onChange={(e) => setPageIndex(Number(e.target.value))}
          />
        </label>
      </div>
      <dl className="example-dl">
        <dt>classic ({classic.length} items)</dt>
        <dd>
          <code data-testid="classic-items">{render(classic)}</code>
        </dd>
        <dt>stable ({stable.length} items)</dt>
        <dd>
          <code data-testid="stable-items">{render(stable)}</code>
        </dd>
      </dl>
    </section>
  );
}

/** Every pagination variant side by side, plus the page-size selector and row range. */
export default function PaginationVariantsExample() {
  return (
    <div className="example-stack">
      {VARIANTS.map((variant) => (
        <section key={variant} className="example-panel" aria-label={`${variant} pagination`}>
          <h2>{variant}</h2>
          <DataTable
            aria-label={`People (${variant} pagination)`}
            data={data}
            columns={columns}
            getRowId={(p) => p.id}
            toolbar={false}
            initialState={{ pagination: { pageIndex: 0, pageSize: 5 } }}
            pagination={{
              variant,
              pageSizeOptions: [5, 10, 25],
              showRowRange: true,
              showFirstLast: variant === 'compact',
            }}
          />
        </section>
      ))}
      <Visualizer />
    </div>
  );
}
```

Pagination is one piece of state (page index and page size) and several ways to show it.
`paginationType` picks the presentation:

| Variant    | Shape                                                                      |
| ---------- | -------------------------------------------------------------------------- |
| `numbered` | Previous, page buttons, Next. The desktop default.                         |
| `compact`  | First/previous/next/last icons with a tight page list. The mobile default. |
| `simple`   | `11–20 of 235` between two arrows. Required for cursor paging.             |
| `loadMore` | A button that appends the next page to the rows already shown.             |
| `infinite` | The next page loads when the end of the list comes into view.              |
| `none`     | No control; the state still applies.                                       |

The variant accepts a responsive value, so the classic default is literally
`{ base: 'compact', md: 'numbered' }`: small screens get icons, wide screens get page numbers.

### Rows per page

`pageSizeOptions` fills the "Rows per page" select. Changing the size keeps the first visible row
on screen rather than throwing the reader back to page 1. `showRowRange` prints the range and
total, and when the total is unknown (a server that does not count) it says `11–20 of many`
instead of inventing a number.

### Which page buttons appear

`getPageItems` is the pure function behind the number row, and you can call it yourself. The
default `classic` algorithm never hides exactly one page behind an ellipsis: if the gap is a
single number, it shows the number. The `stable` algorithm always renders the same number of
slots, so the bar never changes width as you page through.

### Cursor paging

**Example: Cursor pagination** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/pagination/demo-cursor)

```tsx
import { createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';
import { RequestLog, useRequestLog } from '@/demo-support/requestLog';

const people = generatePeople(500);
// The local data source answers with `nextCursor` / `prevCursor`, like a cursor API.
const source = createLocalDataSource(people, {
  columns: peopleColumns,
  getRowId: (p) => p.id,
  latencyMs: [150, 350],
});

/**
 * Cursor pagination: the server returns opaque cursors instead of a page count, so the
 * numbered variant is unavailable and the table shows “of many”. Next and Previous follow the
 * cursors the server sent, and the request log shows them.
 */
export default function CursorPaginationExample() {
  const { dataSource, log, clear } = useRequestLog(source);
  return (
    <div className="example-stack">
      <DataTable<DemoPerson>
        aria-label="People"
        columns={peopleColumns}
        dataSource={dataSource}
        getRowId={(p) => p.id}
        paginationType="cursor"
        pagination={{ variant: 'simple', showRowRange: true }}
        initialState={{ pagination: { pageIndex: 0, pageSize: 10, cursor: null } }}
      />
      <RequestLog log={log} clear={clear} />
    </div>
  );
}
```

When the API hands back a cursor rather than a page number, the table follows it: Next is enabled
while the server says there is more, and Previous walks back through the cursors already seen.
Page _numbers_ are not available in this mode, which is why `simple` is the required variant.

### Infinite scrolling

**Example: Infinite scroll** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/pagination/demo-infinite)

```tsx
import { createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const people = generatePeople(5_000);
const source = createLocalDataSource(people, {
  columns: peopleColumns,
  getRowId: (p) => p.id,
  latencyMs: [200, 400],
});

/**
 * Infinite pagination: instead of page buttons, reaching the end of the scroll area
 * loads the next page and appends it, with virtualization keeping the DOM small. A search or a
 * sort starts the list again from the first page.
 */
export default function InfiniteScrollExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      columns={peopleColumns}
      dataSource={source}
      getRowId={(p) => p.id}
      pagination={{ variant: 'infinite' }}
      enableRowVirtualization
      enableStickyHeader
      maxHeight={460}
      estimateRowHeight={44}
      initialState={{ pagination: { pageIndex: 0, pageSize: 50 } }}
    />
  );
}
```

`infinite` observes a sentinel at the end of the rows and fetches the next page when it appears.
It composes with [virtualization](/react-tablekit/virtualization/), so a hundred thousand
accumulated rows still render a screenful at a time.

## Customization

The whole control is a slot, and `<DataTable.Pagination />` places it anywhere: above the table,
in a sticky page footer, beside a summary. `formatters.rowRange` rewrites the range text, and
every label comes from [localization](/react-tablekit/localization/).

`hideOnSinglePage` is on by default: with one page there is nothing to navigate, so the control
disappears and the container takes its bottom border back.

## Limitations

- **Cursor mode has no page count**, so it cannot show page numbers or a "last page" button, and
  `showRowRange` needs a total from the server to print one.
- **`loadMore` and `infinite` accumulate rows.** Sorting or filtering resets the accumulation to
  the first page, because the rows already loaded are no longer the first rows of the result.
- **Page state is not automatically preserved across mounts.** Put `pagination` in
  [`syncState`](/react-tablekit/persistence/) if a reader should come back to the page they left.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enablePagination`,
  `paginationType`, `paginationMode`, `pagination`, `pageCount`, `rowCount`, `manualPagination`,
  `appendPages`, `resetPageOn`, `autoResetPageIndex`, `scrollToTopOnPageChange`,
  `disablePaginationWhileFetching`, `compactPagination`, `onPaginationChange`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setPageIndex`, `setPageSize`,
  `nextPage`, `previousPage`, `firstPage`, `lastPage`, `getPageCount`, `getPageItems`,
  `getCursors`.
- [Utilities](/react-tablekit/api/utilities/): `getPageItems`.

---

# Row selection

> Checkboxes or radios, range selection with shift-click, a selection bar for bulk actions, and select-all that survives server paging.

URL: https://react-tablekit.vercel.app/react-tablekit/row-selection/

## Basics

**Example: Row selection** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/row-selection/demo-basics)

```tsx
import { useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(60);

/**
 * Row selection: multi with a tri-state header checkbox, Shift+click range selection,
 * row-click selection, disabled rows that select-all skips, and a selection bar with bulk actions.
 * Switch to single mode to get radio semantics, as a picker dialog would.
 */
export default function RowSelectionExample() {
  const [multi, setMulti] = useState(true);
  const [log, setLog] = useState<string | null>(null);

  return (
    <div className="example-stack">
      <div className="example-controls">
        <label>
          <span>Mode</span>
          <select
            value={multi ? 'multi' : 'single'}
            onChange={(e) => setMulti(e.target.value === 'multi')}
          >
            <option value="multi">Multi (checkboxes)</option>
            <option value="single">Single (radios)</option>
          </select>
        </label>
        <p className="site-muted">
          Suspended people are disabled: they cannot be selected, and “select all” skips them.
        </p>
      </div>
      <DataTable<DemoPerson>
        key={multi ? 'multi' : 'single'}
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        enableRowSelection={(row) => row.original.status !== 'suspended'}
        enableMultiRowSelection={multi}
        enableRangeSelection={multi}
        selectOnRowClick
        renderBulkActions={({ selectedRows }) => (
          <button
            type="button"
            onClick={() => setLog(`Emailed ${String(selectedRows.length)} people`)}
          >
            Email selected
          </button>
        )}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
      <p className="example-ok" role="status">
        {log ?? ''}
      </p>
    </div>
  );
}
```

`enableRowSelection` inserts a selection column and a selection bar. The header checkbox is
tri-state (all, some, none), and shift-clicking a second row selects the range between them.

Set `enableMultiRowSelection: false` for picker semantics: radios instead of checkboxes, no
header checkbox, one row at a time. `selectOnRowClick` makes the whole row the target, and it
still ignores clicks that land on a button, a link, an input, or anything marked
`data-tk-stop`, so the row menu keeps working.

### Which rows may be selected

`enableRowSelection` also accepts a predicate, and `isRowDisabled` marks rows as unavailable
everywhere at once. Select-all skips disabled rows rather than silently including them.

```tsx
<DataTable enableRowSelection={(row) => row.original.status !== 'archived'} />
```

With [tree data](/react-tablekit/tree-data/), `enableSubRowSelection` cascades a parent's
checkbox to its children and shows the parent as indeterminate when only some are selected.

### The selection bar and bulk actions

The bar appears once something is selected: `3 selected · Select all 235 matching · Clear`, plus
whatever `renderBulkActions` returns. It is the place for Delete, Export and Assign, and it gets
the selection in a form you can send:

```tsx
renderBulkActions={({ selectedRows, selectionQuery }) => (
  <button onClick={() => api.archive(selectionQuery)}>
    Archive {selectedRows.length}
  </button>
)}
```

### Selecting everything on the server

"Select all 235 matching" cannot mean 235 ids when the browser holds ten rows. It is recorded as
the current query plus the rows the reader has since _deselected_, and `getSelectionQuery()`
hands that to you, so a bulk action applies to the filter, not to a page. `selectAllMode`
chooses between the page and the whole result set.

## Customization

The checkbox, the selection column and the selection bar are [slots](/react-tablekit/customization/slots/).
`positionSelectionColumn` moves the column, and `<DataTable.SelectionBar />` renders the bar
outside the table, at the bottom of a dialog, for instance.

Selected rows carry `aria-selected` and `data-selected`, and their background is the
`--tk-row-selected-bg` [token](/react-tablekit/customization/tokens/); pinned cells inherit it,
so a selected row looks selected all the way across.

## Limitations

- **Selection is keyed by row id.** Without a stable `getRowId` the selection resets whenever the
  data is refetched, because the rows are new objects.
- **`selectionQuery` is an exclusion model, not a list of ids.** Your API has to accept a query
  with exclusions; if it only accepts ids, keep `selectAllMode` on the page.
- **Cross-page selection is not remembered in client mode.** Paging keeps the selection because
  the rows stay loaded; refetching does not.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableRowSelection`,
  `enableMultiRowSelection`, `enableSubRowSelection`, `enableRangeSelection`, `selectOnRowClick`,
  `selectAllMode`, `showSelectionBar`, `renderBulkActions`, `getRowCanSelect`, `isRowDisabled`,
  `positionSelectionColumn`, `clearSelectionOnQueryChange`, `autoResetSelection`,
  `onRowSelectionChange`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setRowSelection`, `resetRowSelection`,
  `toggleAllRowsSelected`, `toggleAllPageRowsSelected`, `selectAllMatching`, `selectRange`,
  `getSelectedRowModel`, `getSelectedRowIds`, `getSelectedCount`, `getSelectionQuery`.
- [`Row`](/react-tablekit/api/table-instance/): `toggleSelected`, `getIsSelected`, `getCanSelect`.

---

# Expansion

> Detail panels that open under a row, lazily rendered, animated, and pinned to the viewport width so a wide table cannot scroll them away.

URL: https://react-tablekit.vercel.app/react-tablekit/expansion/

## Basics

**Example: Detail panels** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/expansion/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Collapsible row content: `renderDetailPanel` adds a full-width row under the expanded
 * one, with an expand toggle column. The panel is sticky-left, so it stays put while a wide table
 * scrolls sideways.
 */
export default function DetailPanelsExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      renderDetailPanel={({ row }) => (
        <dl className="example-dl">
          <dt>Email</dt>
          <dd>
            <code>{row.original.email}</code>
          </dd>
          <dt>City</dt>
          <dd>{row.original.city ?? 'Unknown'}</dd>
          <dt>Joined</dt>
          <dd>{row.original.joined}</dd>
          <dt>Remote</dt>
          <dd>{row.original.remote ? 'Yes' : 'No'}</dd>
        </dl>
      )}
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

`renderDetailPanel` puts a full-width row under the row it belongs to, and an expand column with
a chevron appears automatically.

```tsx
<DataTable
  renderDetailPanel={({ row }) => <OrderLines orderId={row.id} />}
  columns={columns}
  data={orders}
/>
```

The panel is not rendered until it is opened, so a hundred collapsed rows cost nothing. Pass
`detailPanelProps.keepMounted` when the content is expensive to rebuild and should survive a
collapse.

### Loading panel content

A panel usually needs data the table never fetched. `useDetailPanelData` loads it per row and
caches it by row id, so re-opening is instant:

```tsx
function Panel({ row }) {
  const { data, loading, error } = useDetailPanelData(row, () => api.lines(row.id));
  if (loading) return <Spinner />;
  if (error) return <ErrorText error={error} />;
  return <OrderLines lines={data} />;
}
```

### One at a time

`expandMode: 'single'` collapses the previous panel when another opens, like an accordion.
`expandOnRowClick` makes the whole row the toggle, with the same exclusion for buttons and links
that [row selection](/react-tablekit/row-selection/) uses. `enableExpandAll` puts an expand-all
toggle in the header.

### Rows with children

Sub-rows use the same expansion state and the same toggle. That is a capability of its own; see
[Tree data](/react-tablekit/tree-data/).

## Customization

The panel is sticky to the left at the container's width, so scrolling a wide table sideways does
not drag the panel out of view. `detailPanelProps.fullWidth: false` opts out and lets the panel
scroll with the columns.

The toggle is a [slot](/react-tablekit/customization/slots/), and `positionExpandColumn` moves
the column to the other end. The open/close animation is a CSS grid transition that
`prefers-reduced-motion` disables.

## Limitations

- **A row has one panel.** Two kinds of detail (history and comments) belong in tabs inside the
  panel, not in two panels.
- **A panel is not a cell.** It is a separate row, so it takes no part in sorting, filtering,
  search or CSV export.
- **The expanded state is keyed by row id**, so it resets on refetch unless `getRowId` is stable
  and `autoResetExpanded` is off.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableExpanding`,
  `renderDetailPanel`, `detailPanelProps`, `expandMode`, `expandOnRowClick`, `enableExpandAll`,
  `getRowCanExpand`, `positionExpandColumn`, `paginateExpandedRows`, `autoResetExpanded`,
  `manualExpanding`, `onExpandedChange`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setExpanded`, `resetExpanded`,
  `toggleAllRowsExpanded`, `getIsAllRowsExpanded`, `getExpandedRowModel`.
- [`Row`](/react-tablekit/api/table-instance/): `toggleExpanded`, `getIsExpanded`, `getCanExpand`.
- [Hooks](/react-tablekit/api/hooks/): `useDetailPanelData`.

---

# Client and server

> The same table and the same props, with the work done in the browser, on your API, or split feature by feature.

URL: https://react-tablekit.vercel.app/react-tablekit/client-and-server/

## Basics

**Example: Client and server** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/client-and-server/demo-basics)

```tsx
import { useMemo, useState } from 'react';
import {
  createLocalDataSource,
  DataTable,
  useDataTable,
  type SortingState,
  type TableInstance,
} from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(500, 7);

/**
 * The same dataset twice: the left table computes everything in the browser; the right
 * one sends each query to `createLocalDataSource`, which simulates a server (latency, abort) with
 * the engine's own filter/sort functions. Identical interactions give identical rows.
 */
export default function ClientVsServerExample() {
  const [latency, setLatency] = useState(400);
  const server = useMemo(
    () =>
      createLocalDataSource(data, {
        columns: peopleColumns,
        getRowId: (p) => p.id,
        latencyMs: [latency / 2, latency],
      }),
    [latency],
  );

  const common = {
    columns: peopleColumns,
    getRowId: (p: DemoPerson) => p.id,
    toolbar: false as const,
    searchDebounceMs: 300,
  };
  const client = useDataTable<DemoPerson>({ ...common, data, 'aria-label': 'Client mode' });
  const remote = useDataTable<DemoPerson>({
    ...common,
    dataSource: server,
    'aria-label': 'Server mode',
    loadingDisplay: 'skeleton',
  });

  const [search, setSearch] = useState('');
  const [sort, setSort] = useState('');
  const apply = (fn: (t: TableInstance<DemoPerson>) => void) => {
    fn(client);
    fn(remote);
  };
  const sorting: SortingState = sort
    ? [{ id: sort.replace(/^-/, ''), desc: sort.startsWith('-') }]
    : [];

  const clientIds = client
    .getRowModel()
    .rows.map((r) => r.id)
    .join();
  const serverIds = remote
    .getRowModel()
    .rows.map((r) => r.id)
    .join();
  const status = remote.getDataStatus();
  const identical = !status.fetching && !status.loading && clientIds === serverIds;

  return (
    <div className="example-stack">
      <div className="example-controls" role="group" aria-label="Shared controls">
        <label>
          Search both
          <input
            type="search"
            value={search}
            onChange={(e) => {
              setSearch(e.target.value);
              apply((t) => t.setGlobalFilter(e.target.value));
            }}
          />
        </label>
        <label>
          Sort both
          <select
            value={sort}
            onChange={(e) => {
              setSort(e.target.value);
              const s: SortingState = e.target.value
                ? [{ id: e.target.value.replace(/^-/, ''), desc: e.target.value.startsWith('-') }]
                : [];
              apply((t) => t.setSorting(s));
            }}
          >
            <option value="">Unsorted</option>
            <option value="name">Name ↑</option>
            <option value="-age">Age ↓</option>
            <option value="salary">Salary ↑</option>
          </select>
        </label>
        <button type="button" onClick={() => apply((t) => t.nextPage())}>
          Next page (both)
        </button>
        <button
          type="button"
          onClick={() =>
            apply((t) => {
              t.setSorting(sorting);
              t.setGlobalFilter(search);
              t.setPageIndex(client.getState().pagination.pageIndex);
            })
          }
        >
          Sync controls
        </button>
        <label>
          Server latency: {latency}ms
          <input
            type="range"
            min={0}
            max={2000}
            step={100}
            value={latency}
            onChange={(e) => setLatency(Number(e.target.value))}
          />
        </label>
        <output
          aria-live="polite"
          data-testid="equivalence"
          className={identical ? 'example-ok' : 'example-pending'}
        >
          {identical ? 'Identical rows ✓' : 'Waiting for the server…'}
        </output>
      </div>
      <div className="example-columns">
        <section aria-label="Client mode table">
          <h2>Client mode</h2>
          <DataTable table={client} />
        </section>
        <section aria-label="Server mode table">
          <h2>Server mode (simulated)</h2>
          <DataTable table={remote} />
        </section>
      </div>
    </div>
  );
}
```

A table in client mode holds all the rows and computes everything itself. A table in server mode
holds one page and asks the API. Both are the same component with the same props; what changes is
who does the work.

```tsx
<DataTable data={people} columns={columns} getRowId={(p) => p.id} />

<DataTable dataMode="server" dataSource={dataSource} columns={columns} getRowId={(p) => p.id} />
```

`dataMode="server"` switches pagination, sorting, filtering, search, grouping and faceting to the
server at once. Expansion stays local, because sub-rows can still come from the data you already
have.

### The query

Whichever mode you are in, the table maintains one normalized query: page, sort list, search term,
column filters and grouping. That object is what a server mode table sends, and you can read it
at any time with `table.getQuery()`.

`onQueryChange(query, change)` fires once, after debouncing, whenever something that affects
server data changes, and it tells you _why_ it changed. It is the single place to hang a fetch,
instead of six callbacks racing each other.

```tsx
<DataTable
  dataMode="server"
  onQueryChange={(query) => void refetch(query)}
  data={page.items}
  rowCount={page.total}
/>
```

The per-slice callbacks (`onSortingChange`, `onPaginationChange` and the rest) still fire
immediately, for controlled state.

### A data source instead of an effect

`dataSource` packages the fetch, so the table drives the request itself and gets loading, error,
refetch and facet handling for free:

```tsx
interface PeoplePage {
  content: Person[];
  totalElements: number;
}

const dataSource = createRestDataSource<Person, PeoplePage>({
  url: '/api/people',
  mapQuery: (query) => ({
    page: query.pagination.pageIndex,
    size: query.pagination.pageSize,
    sort: query.sorting.map((s) => s.id + ',' + (s.desc ? 'desc' : 'asc')),
    q: query.globalFilter,
  }),
  mapResult: (body) => ({ rows: body.content, rowCount: body.totalElements }),
});
```

`mapQuery` turns the table's query into the request, and `mapResult` turns the response into
rows and a total. The second type argument is the response's shape, which is what lets
`mapResult` read it.

`keepPreviousData` keeps the old page visible while the next one loads, `dedupeMs` collapses
duplicate requests, and `refetchInterval` and `refetchOnWindowFocus` cover live data.

### Mixing the two

**Example: Hybrid mode** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/client-and-server/demo-hybrid)

```tsx
import { createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';
import { RequestLog, useRequestLog } from '@/demo-support/requestLog';

const people = generatePeople(300);
const source = createLocalDataSource(people, {
  columns: peopleColumns,
  getRowId: (p) => p.id,
  latencyMs: [150, 300],
});

/**
 * Hybrid mode: the server paginates, the browser sorts the page it was given. That sorts
 * only the current page, which is why the engine warns unless you acknowledge it with
 * `acknowledgePageLocalSorting`. Sorting here never triggers a request; watch the log.
 */
export default function HybridModeExample() {
  const { dataSource, log, clear } = useRequestLog(source);
  return (
    <div className="example-stack">
      <DataTable
        aria-label="People (hybrid mode)"
        columns={peopleColumns}
        dataSource={dataSource}
        getRowId={(p) => p.id}
        sortingMode="client"
        acknowledgePageLocalSorting
        initialState={{ pagination: { pageIndex: 0, pageSize: 10 } }}
      />
      <RequestLog log={log} clear={clear} />
    </div>
  );
}
```

Per-feature modes override `dataMode`, so you can page and filter on the server while sorting the
loaded page in the browser:

```tsx
<DataTable dataMode="server" sortingMode="client" acknowledgePageLocalSorting />
```

That combination sorts _the current page only_, which is sometimes exactly what you want and
sometimes a bug. The table warns about it in development until you acknowledge it in the props,
which is also the documentation of the decision for whoever reads the code next.

## Customization

`createLocalDataSource` gives an in-memory array the same interface as a REST source, which makes
tests and prototypes behave like production. `useDataSource` exposes the same machinery outside a
table, and [`useDataTable`](/react-tablekit/headless/) gives you the whole engine with no markup.

## Limitations

- **Server grouping with client pagination is not supported** and throws in development: the
  table cannot slice group rows it did not build.
- **Client faceting under server pagination describes the loaded page**, not the result set. Use
  `fetchFacets`; see [Faceting](/react-tablekit/faceting/).
- **A server that does not return a total** cannot produce page numbers. Pass `rowCount: -1` and
  the table shows `of many` and switches to cursor-style navigation.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `dataMode`, `dataSource`,
  `paginationMode`, `sortingMode`, `filterMode`, `searchMode`, `groupingMode`, `facetingMode`,
  the `manual*` flags, `onQueryChange`, `rowCount`, `pageCount`, `keepPreviousData`, `dedupeMs`,
  `fetchOnMount`, `refetchInterval`, `refetchOnWindowFocus`, `acknowledgePageLocalSorting`,
  `acknowledgePageLocalFiltering`.
- [`TableState`](/react-tablekit/api/table-state/): `TableQuery` and every state slice.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getQuery`, `flushQuery`, `refresh`,
  `invalidate`, `getDataStatus`.
- [Utilities](/react-tablekit/api/utilities/): `createRestDataSource`, `createLocalDataSource`.

---

# Tree data

> Hierarchical rows with indentation, expand toggles and children that can be loaded from the server when a branch is opened.

URL: https://react-tablekit.vercel.app/react-tablekit/tree-data/

## Basics

**Example: Tree data** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/tree-data/demo-basics)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { buildOrgTree, type OrgNode } from '@/demo-support/orgTree';

const col = createColumnHelper<OrgNode>();
const columns = [
  col.accessor('name', { header: 'Name' }),
  col.accessor('role', { header: 'Role' }),
  col.accessor('headcount', { header: 'Headcount', type: 'number' }),
  col.accessor('location', { header: 'Location' }),
];

const data = buildOrgTree();

/**
 * Tree data: `getSubRows` turns nested records into expandable rows with indentation,
 * an expand toggle in the first column, and “expand all” in the toolbar. Filtering keeps parents
 * whose descendants match.
 */
export default function TreeDataExample() {
  return (
    <DataTable<OrgNode>
      aria-label="Organisation"
      data={data}
      columns={columns}
      getRowId={(n) => n.id}
      getSubRows={(n) => n.children}
      enableExpanding
      initialState={{ pagination: { pageIndex: 0, pageSize: 12 } }}
    />
  );
}
```

`getSubRows` turns a flat list of objects into a tree. Every row keeps its depth, the first
visible data column is indented by it, and the expand toggle appears on rows that have children.

```tsx
<DataTable data={departments} getSubRows={(row) => row.children} columns={columns} />
```

Children are ordinary rows: they sort, filter, select and export like any other. Sorting orders
siblings within their parent rather than flattening the tree.

### Loading children when a branch opens

**Example: Lazy server children** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/tree-data/demo-lazy-children)

```tsx
import { createColumnHelper, DataTable, type DataSource } from '@kingtech/react-tablekit';
import { childrenOf, hasChildren, type OrgNode } from '@/demo-support/orgTree';

const col = createColumnHelper<OrgNode>();
const columns = [
  col.accessor('name', { header: 'Name' }),
  col.accessor('role', { header: 'Role' }),
  col.accessor('headcount', { header: 'Headcount', type: 'number' }),
  col.accessor('location', { header: 'Location' }),
];

const delay = (ms: number, signal: AbortSignal) =>
  new Promise<void>((resolve, reject) => {
    const timer = setTimeout(resolve, ms);
    signal.addEventListener('abort', () => {
      clearTimeout(timer);
      reject(new DOMException('Aborted', 'AbortError'));
    });
  });

/** Roots come from `fetch`; each expansion asks the server for that node's children. */
const orgDataSource: DataSource<OrgNode> = {
  async fetch(query, { signal }) {
    await delay(250, signal);
    const roots = childrenOf(null);
    const { pageIndex, pageSize } = query.pagination;
    return {
      rows: roots.slice(pageIndex * pageSize, pageIndex * pageSize + pageSize),
      rowCount: roots.length,
    };
  },
  async fetchChildren(row, _query, { signal }) {
    await delay(400, signal);
    return childrenOf(row.id);
  },
};

/**
 * Lazily loaded tree rows: only the roots are fetched up front, and expanding a row
 * calls `dataSource.fetchChildren` once per row per query, showing a spinner in that row while it
 * loads. `getRowCanExpand` tells the table which rows are worth a toggle.
 */
export default function TreeLazyServerExample() {
  return (
    <DataTable<OrgNode>
      aria-label="Organisation (lazy)"
      columns={columns}
      dataSource={orgDataSource}
      getRowId={(n) => n.id}
      getRowCanExpand={(row) => hasChildren(row.id)}
      enableExpanding
      toolbar={false}
      initialState={{ pagination: { pageIndex: 0, pageSize: 10 } }}
    />
  );
}
```

A tree that is too big to send at once loads a branch at a time.
`dataSource.fetchChildren` is called when a row is expanded; the toggle shows a spinner while it
runs and reports an error inline if it fails.

The table cannot know whether an unloaded row _has_ children, so tell it:

```tsx
<DataTable dataSource={dataSource} getRowCanExpand={(row) => row.original.childCount > 0} />
```

Loaded children are cached per row and invalidated when the query changes, so re-opening a branch
does not refetch and a new filter does not show stale children.

### Filtering a tree

By default a row is matched on its own values. `filterFromLeafRows` keeps a parent whenever any
descendant matches and auto-expands the path to it, the behaviour you want when searching a
file tree. `maxLeafRowFilterDepth` caps how deep that search goes.

### Selection

`enableSubRowSelection` cascades a parent's checkbox down to its children, and shows the parent
as indeterminate when only some are selected. See [Row
selection](/react-tablekit/row-selection/).

## Customization

`treeColumnId` moves the indentation and the toggle to a specific column instead of the first
visible one. The indentation step is the `--tk-tree-indent` token, and the toggle is a
[slot](/react-tablekit/customization/slots/), so a file tree can show folder icons rather than
chevrons.

A selectable tree is announced as a `treegrid`, so assistive technology gets the level and
expanded state without any extra attributes from you.

## Limitations

- **Sorting never reorders across levels.** Children stay inside their parent; that is what makes
  it a tree rather than a list.
- **Pagination counts top-level rows** unless `paginateExpandedRows` is set, so an expanded branch
  does not push its siblings onto the next page.
- **Lazily loaded children cannot be searched before they are loaded.** Client-side search only
  sees rows the browser has; move search to the server for a lazy tree.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `getSubRows`, `getRowCanExpand`,
  `treeColumnId`, `filterFromLeafRows`, `maxLeafRowFilterDepth`, `paginateExpandedRows`,
  `enableSubRowSelection`, `enableExpandAll`, `manualExpanding`.
- [`Row`](/react-tablekit/api/table-instance/): `depth`, `subRows`, `getParentRow`,
  `getLeafRows`, `getCanExpand`, `toggleExpanded`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getRowChildrenStatus`,
  `getExpandedRowModel`.

---

# Grouping

> Group rows by one column or several, with aggregates in the group row and totals in the footer.

URL: https://react-tablekit.vercel.app/react-tablekit/grouping/

## Basics

**Example: Grouping and aggregation** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/grouping/demo-basics)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const col = createColumnHelper<DemoPerson>();
const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

const columns = [
  col.accessor('department', { header: 'Department', enableGrouping: true }),
  col.accessor('city', { header: 'City', enableGrouping: true }),
  col.accessor((p) => `${p.firstName} ${p.lastName}`, { id: 'name', header: 'Name' }),
  col.accessor('age', {
    header: 'Age',
    type: 'number',
    aggregationFn: 'mean',
    footer: 'Average age',
  }),
  col.accessor('salary', {
    header: 'Salary',
    type: 'number',
    format: (v) => money.format(v),
    aggregationFn: 'sum',
    footer: 'Payroll',
  }),
];

const data = generatePeople(200);

/**
 * Grouping and aggregation: group by one or more columns from the column menu, and the
 * group rows show the value, the row count and the aggregates of the other columns. The footer
 * aggregates the whole filtered set, not just the page.
 */
export default function GroupingAggregationExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableGrouping
      enableColumnActions
      enableColumnFooters
      groupedColumnMode="reorder"
      initialState={{
        grouping: ['department'],
        pagination: { pageIndex: 0, pageSize: 25 },
      }}
    />
  );
}
```

`enableGrouping` lets a column collapse its rows into groups. A group row shows the value, the
number of rows under it and a toggle; the other columns show whatever their `aggregationFn`
computes: a sum for an amount, an average for a rating, a count for anything.

```tsx
col.accessor('country', { enableGrouping: true });
col.accessor('total', { type: 'currency', aggregationFn: 'sum' });
```

`state.grouping` is an array, so grouping by country and then by city is a two-element list and
the group rows nest.

### Where a grouped column goes

`groupedColumnMode` decides what happens to the column you grouped by: `'reorder'` moves it to
the front, `'remove'` hides it because the group row already says it, and `false` leaves it where
it is.

A reader can group from the column menu, and, when [column
ordering](/react-tablekit/column-ordering/) is on, by dragging a header into the toolbar drop
zone.

### Aggregating a custom value

`aggregationFn` accepts a name or a function, and `aggregatedCell` renders the result. That is
the difference between a number and `12 of 40 overdue`:

```tsx
col.accessor('status', {
  aggregationFn: (ids, rows) => rows.filter((r) => r.original.overdue).length,
  aggregatedCell: ({ getValue, row }) => getValue() + ' of ' + row.subRows.length + ' overdue',
});
```

### Footers

`enableColumnFooters` renders a `tfoot` with the same aggregates across the whole table.
`footerAggregationScope` chooses between the current page and everything the filters left; see
[Column footers](/react-tablekit/column-footers/).

### Grouping on the server

With `groupingMode: 'server'` the server returns group rows and the table renders them as they
arrive. It does not re-group anything locally.

## Customization

`groupedCell` replaces the group row's leading cell, `getGroupingValue` changes what a row groups
_by_ without changing what it displays (bucketing dates by month, for instance), and the group
toggle is a [slot](/react-tablekit/customization/slots/).

## Limitations

- **Server grouping requires server pagination.** Grouping on the server and paging in the
  browser throws in development, because the table cannot slice groups it did not build.
- **Aggregates ignore hidden rows, not hidden columns.** A hidden column still aggregates; a
  filtered-out row does not count.
- **Grouping by more than two or three columns** is legal and rarely readable. A pivot is a
  different component.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableGrouping`, `groupingMode`,
  `manualGrouping`, `groupedColumnMode`, `aggregationFns`, `enableColumnFooters`,
  `footerAggregationScope`, `onGroupingChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `enableGrouping`, `aggregationFn`,
  `aggregatedCell`, `groupedCell`, `getGroupingValue`, `footer`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setGrouping`, `resetGrouping`,
  `getGroupedRowModel`, `getFooterGroups`.
- [`Column`](/react-tablekit/api/table-instance/): `toggleGrouping`, `getIsGrouped`,
  `getAggregationFn`.

---

# Column pinning

> Freeze columns to either edge, stacked and responsive, with a shadow that appears only when content actually scrolls underneath.

URL: https://react-tablekit.vercel.app/react-tablekit/column-pinning/

## Basics

**Example: Column pinning** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-pinning/demo-basics)

```tsx
import { useState } from 'react';
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(60);
const col = createColumnHelper<DemoPerson>();
const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

/**
 * `pin` on a column is the declarative form; `lockPin` stops the user changing it from the
 * column menu. A pin may also be responsive: here the name stays pinned only from `md` up,
 * because on a phone a pinned column would eat most of the screen.
 */
const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    pin: { base: false, md: 'left' },
    lockPin: true,
  }),
  col.accessor('email', { header: 'Email' }),
  col.accessor('department', { header: 'Department' }),
  col.accessor('city', { header: 'City' }),
  col.accessor('age', { header: 'Age', type: 'number' }),
  col.accessor('joined', { header: 'Joined', type: 'date' }),
  col.accessor('status', { header: 'Status' }),
  col.accessor('remote', { header: 'Remote', type: 'boolean' }),
  col.accessor('salary', {
    header: 'Salary',
    type: 'number',
    format: (v) => money.format(v),
    pin: 'right',
  }),
];

/**
 * Pinned columns stay put while the rest scrolls sideways, and the scroll shadows
 * show which side still has content. Flip the direction to see that pinning follows the
 * writing direction: in RTL the "left" side is rendered on the right.
 */
export default function ColumnPinningExample() {
  const [dir, setDir] = useState<'ltr' | 'rtl'>('ltr');

  return (
    <div className="example-stack">
      <div className="example-controls">
        <button type="button" onClick={() => setDir((d) => (d === 'ltr' ? 'rtl' : 'ltr'))}>
          Direction: {dir.toUpperCase()}
        </button>
      </div>

      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={columns}
        getRowId={(p) => p.id}
        dir={dir}
        enableColumnPinning
        enableColumnActions
        maxHeight={420}
        enableStickyHeader
        initialState={{ pagination: { pageIndex: 0, pageSize: 20 } }}
      />
    </div>
  );
}
```

A pinned column stays put while the rest of the table scrolls sideways. Pin it on the column, or
put it in state, or let the reader do it from the header menu:

```tsx
col.accessor('name', { pin: 'left' });
col.display({ id: 'actions', pin: 'right', static: true });
```

Any number of columns can be pinned on each side. Their offsets stack, so the second left-pinned
column sits exactly after the first however wide it is.

### The edge shadow

The shadow at the boundary is not decoration that is always there: it appears only when content
is actually scrolled underneath, which is what makes it mean something. It is the
`--tk-pinned-shadow` [token](/react-tablekit/customization/tokens/), and the classic preset sets
it to `none`.

### Pinning only on wide screens

`pin` takes a responsive value, so a column can be frozen where there is room and released where
there is not:

```tsx
col.display({ id: 'actions', pin: { base: false, md: 'right' } });
```

`stickyActions` is the shorthand for the common case: an actions column pinned right on desktop.

### Columns a reader may not move

`static: true` locks a column completely: it cannot be unpinned, hidden, reordered, resized or
edited from the menu. It is what the selection, expand and actions columns use. `lockPin` locks
only the pinning.

## Customization

Pinned cells take their background from `--tk-row-bg`, which follows hover, striping and
selection, so a selected row stays selected across the frozen edge. In right-to-left text the
sides swap, because the layout uses logical properties rather than left and right.

## Limitations

- **Pinning needs a horizontal scroll to be visible.** In a table that fits, a pinned column
  looks like any other.
- **A pinned column is still in the tab order where it sits**, not where it appears. Pinning is
  presentation, not structure.
- **Very wide pinned columns can leave no room to scroll.** The table does not enforce a maximum;
  that judgement is yours.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableColumnPinning`,
  `stickyActions`, `rowActionsColumn`, `positionActionsColumn`, `onColumnPinningChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `pin`, `lockPin`, `static`, `lockPosition`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setColumnPinning`,
  `resetColumnPinning`, `getEffectiveColumnPinning`, `getLeftHeaderGroups`,
  `getRightHeaderGroups`.
- [`Column`](/react-tablekit/api/table-instance/): `pin`, `getIsPinned`, `getCanPin`.

---

# Column sizing

> Percentage or pixel widths, a keyboard-operable resize handle, and double-click autosizing that measures the rendered rows.

URL: https://react-tablekit.vercel.app/react-tablekit/column-sizing/

## Basics

**Example: Column sizing** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-sizing/demo-basics)

```tsx
import { useState } from 'react';
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(40);
const col = createColumnHelper<DemoPerson>();

/**
 * Two ways to size a column, mixed deliberately:
 *
 * - `width` is CSS, so a percentage stays proportional as the table grows;
 * - `size` is a pixel number and is what dragging the resize handle changes,
 *   bounded by `minSize` / `maxSize`.
 */
const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name (30%)',
    width: '30%',
    minSize: 120,
  }),
  col.accessor('email', { header: 'Email (size 260)', size: 260, minSize: 140, maxSize: 420 }),
  col.accessor('department', { header: 'Department', size: 160 }),
  col.accessor('city', { header: 'City', size: 120 }),
  // A fixed, unresizable column: the handle is not rendered at all.
  col.accessor('age', { header: 'Age', type: 'number', size: 80, enableResizing: false }),
];

/**
 * Column sizing. Drag a header's trailing edge, or focus it and press ←/→ (Shift for
 * a 50px step). `'onChange'` resizes live while dragging; `'onEnd'` waits for the release,
 * which is the better choice for very wide tables. Double-click a handle (or use "Autosize"
 * in the ⋮ menu) to fit the column to its content.
 */
export default function ColumnSizingExample() {
  const [mode, setMode] = useState<'onChange' | 'onEnd'>('onChange');

  return (
    <div className="example-stack">
      <div className="example-controls">
        <label className="site-field">
          <span>Resize mode</span>
          <select value={mode} onChange={(e) => setMode(e.target.value as 'onChange' | 'onEnd')}>
            <option value="onChange">onChange (live)</option>
            <option value="onEnd">onEnd (on release)</option>
          </select>
        </label>
      </div>

      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={columns}
        getRowId={(p) => p.id}
        enableColumnResizing
        columnResizeMode={mode}
        enableColumnActions
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
    </div>
  );
}
```

There are two width models, and which one is in play depends on whether the reader can resize.

Without resizing, widths are CSS: `width`, `minWidth` and `maxWidth` accept percentages or
pixels, and they are applied through a `colgroup` so the header and the body always agree.

```tsx
col.accessor('name', { width: '30%', minWidth: 160 });
```

With `enableColumnResizing`, the table switches to pixels (`size`, `minSize`, `maxSize`) and
converts any percentage on first measurement. A dragged column needs a number to drag from.

### The resize handle

The handle on the header's trailing edge is a separator with a value, which means the keyboard
works: arrow keys move it 10 pixels and Shift moves it 50. Double-clicking autosizes the column
to the content of the rendered rows.

`columnResizeMode: 'onChange'` resizes live as you drag; `'onEnd'` shows a guide line and applies
the width when you let go, which is the calmer choice for a table with expensive cells.

### Letting a column take the slack

`grow` distributes leftover width. `truncate` and `noWrap` decide what a narrow column does with
text that does not fit: clip with a tooltip, or refuse to wrap.

### Remembering the widths

Column sizing is a state slice, so adding `columnSizing` to
[`syncState`](/react-tablekit/persistence/) is all it takes for a reader's layout to survive a
reload.

## Customization

`enableResizing: false` exempts one column, and `static: true` exempts it from resizing,
reordering, hiding and pinning at once. `autosizeColumn` and `autosizeAllColumns` run the same
measurement the double-click uses, so a toolbar button can offer "fit to content".

## Limitations

- **Autosizing measures rendered rows only.** On a paginated table it fits the current page,
  because the other rows are not in the DOM to measure.
- **Percentages and resizing do not mix.** Turning resizing on converts them once; after that the
  table is in pixels.
- **`minSize` wins over the available width.** A table whose minimums exceed its container will
  scroll rather than shrink below them.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableColumnResizing`,
  `columnResizeMode`, `columnResizeDirection`, `tableLayout`, `minWidth`, `maxWidth`,
  `onColumnSizingChange`, `onColumnSizingInfoChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `width`, `minWidth`, `maxWidth`, `size`,
  `minSize`, `maxSize`, `grow`, `enableResizing`, `truncate`, `noWrap`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setColumnSizing`, `resetColumnSizing`,
  `autosizeColumn`, `autosizeAllColumns`, `getTotalSize`.
- [`Column`](/react-tablekit/api/table-instance/): `getSize`, `resetSize`, `getIsResizing`.

---

# Column ordering

> Drag headers to reorder, or move them from the columns menu with the keyboard. Pinned groups and locked positions are respected.

URL: https://react-tablekit.vercel.app/react-tablekit/column-ordering/

## Basics

**Example: Column ordering** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-ordering/demo-basics)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(40);
const col = createColumnHelper<DemoPerson>();

const columns = [
  // The row's identity: it must stay first and must not be hideable, or the table
  // becomes a grid of anonymous values.
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    lockPosition: 'first',
    enableHiding: false,
    enableOrdering: false,
  }),
  col.accessor('email', { header: 'Email' }),
  col.accessor('department', { header: 'Department' }),
  col.accessor('status', { header: 'Status' }),
  col.accessor('city', { header: 'City' }),
  col.accessor('age', { header: 'Age', type: 'number' }),
  col.accessor('joined', { header: 'Joined', type: 'date' }),
  col.accessor('remote', { header: 'Remote', type: 'boolean' }),
];

/**
 * Ordering and visibility. Drag a header to move a column, or use the Columns button
 * to show and hide them. Both are personal preferences rather than something you would share
 * in a link, so they persist to `localStorage` instead of the URL. Reload and the layout you
 * arranged is still there.
 */
export default function ColumnOrderingVisibilityExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableColumnOrdering
      enableHiding
      enableColumnActions
      enableDensityToggle
      syncState={{ storage: { key: 'react-tablekit:column-layout-demo', version: 1 } }}
      initialState={{
        columnVisibility: { joined: false },
        pagination: { pageIndex: 0, pageSize: 8 },
      }}
    />
  );
}
```

`enableColumnOrdering` lets a reader drag a header to a new position. The drag uses pointer
events rather than HTML5 drag-and-drop, so it behaves the same with a mouse, a pen and a finger.

The same order is editable from the columns menu, where each column has a drag handle and up and
down buttons, which is how the feature works without a pointer at all. Every move is announced
in the live region: _Moved Company name to position 3 of 8_.

### What stays put

Reordering respects the layout rules already in place:

- A [pinned](/react-tablekit/column-pinning/) column moves only within its own side.
- `lockPosition: 'first'` or `'last'` holds a column at its edge.
- `enableOrdering: false` takes a column out of the drag entirely, and `static: true` takes it out
  of everything.

### Reading and setting the order

`state.columnOrder` is an array of column ids; anything missing keeps its declared position. That
makes a "reset layout" button a one-liner, and a saved layout a stored array.

```tsx
<button onClick={() => table.resetColumnOrder()}>Reset layout</button>
```

`moveColumn(id, toIndex)` performs the same move programmatically.

## Customization

Pair ordering with [column visibility](/react-tablekit/column-visibility/) and
[sizing](/react-tablekit/column-sizing/), then persist all three; see
[Persistence](/react-tablekit/persistence/). Those three slices together are what people mean by
"my table layout".

## Limitations

- **Group columns move as a unit.** A leaf cannot be dragged out of its header group.
- **Order is by id.** Renaming a column id invalidates a stored order, so treat ids as an API.
- **There is no drop indicator between two pinned sides.** Dragging across the boundary is
  refused rather than silently ignored.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableColumnOrdering`,
  `onColumnOrderChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `enableOrdering`, `lockPosition`, `static`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setColumnOrder`, `resetColumnOrder`,
  `moveColumn`.
- [`Column`](/react-tablekit/api/table-instance/): `getIndex`, `getCanOrder`.

---

# Column visibility

> A columns menu for the reader, a per-column menu for actions, and responsive hiding that needs no state at all.

URL: https://react-tablekit.vercel.app/react-tablekit/column-visibility/

## Basics

**Example: Column features** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-visibility/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(80);

/**
 * Column power features: drag a header to reorder, drag its right edge (or focus it
 * and press ←/→, Shift for 50px) to resize, and use the “⋮” menu on any header to sort, filter,
 * pin, hide or autosize that column. The Columns button toggles visibility.
 */
export default function ColumnFeaturesExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      enableColumnResizing
      enableColumnOrdering
      enableColumnActions
      enableHiding
      columnResizeMode="onChange"
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

The toolbar's **Columns** button opens a checkbox per column, plus Show all, Hide all and Reset.
Past ten columns the menu grows a search box, because scanning thirty checkboxes is not a
feature.

`defaultHidden` starts a column off, without removing it from the menu:

```tsx
col.accessor('internalId', { header: 'Internal id', defaultHidden: true });
col.accessor('audit', { header: 'Audit trail', enableHiding: false });
```

### Hiding by screen width

`hideBelow` and `hideAbove` are responsive and deliberately _not_ state: a column hidden because
the screen is narrow does not show up as unchecked in the menu, because the reader did not
uncheck it.

```tsx
col.accessor('city', { hideBelow: 'md' });
```

That is the difference between "there is no room for this" and "I do not want this".

### The column actions menu

`enableColumnActions` adds a menu button to each header with sort, filter, group, pin, hide,
autosize and reset-size entries: the per-column versions of what the toolbar offers for the
whole table.

`renderColumnActionsMenuItems` receives the default items, so you can add one, drop one or
reorder them rather than rebuilding the menu:

```tsx
renderColumnActionsMenuItems={({ column, defaultItems, closeMenu }) => [
  ...defaultItems,
  <button
    key="copy"
    type="button"
    role="menuitem"
    onClick={() => {
      copy(column.id);
      closeMenu();
    }}
  >
    Copy column
  </button>,
]}
```

The items are React elements: the defaults are the table's own menu items, and yours sit beside
them. `closeMenu` closes the menu once your item has done its work.

## Customization

The Columns button, the menu and the header menu are all
[slots](/react-tablekit/customization/slots/), and `<DataTable.ColumnsButton />` places the
control outside the toolbar. Visibility is a state slice, so
[persisting](/react-tablekit/persistence/) `columnVisibility` remembers the reader's choice.

## Limitations

- **A hidden column is still computed.** It keeps its filter and its aggregate; hiding is
  presentation. Remove it from `columns` to stop the work.
- **`hideBelow` cannot be overridden by the reader**, by design; there is no checkbox for it.
- **Hiding every column** leaves an empty table rather than an error; the menu's Hide all stops
  one short only when a column sets `enableHiding: false`.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableHiding`,
  `enableColumnActions`, `renderColumnActionsMenuItems`, `onColumnVisibilityChange`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `defaultHidden`, `enableHiding`, `hideBelow`,
  `hideAbove`, `enableColumnActions`, `static`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setColumnVisibility`,
  `resetColumnVisibility`, `toggleAllColumnsVisible`, `getIsAllColumnsVisible`,
  `getVisibleLeafColumns`.
- [`Column`](/react-tablekit/api/table-instance/): `toggleVisibility`, `getIsVisible`,
  `getCanHide`.

---

# Column footers

> A footer row with per-column totals, computed over the current page or over everything the filters left.

URL: https://react-tablekit.vercel.app/react-tablekit/column-footers/

## Basics

**Example: Column footers** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/column-footers/demo-basics)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(60);
const col = createColumnHelper<DemoPerson>();
const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

/**
 * Column footers. A footer is either a renderer or an aggregate over the filtered rows:
 * `footerAggregationScope` is what decides whether "total" means this page or the whole result.
 */
const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    footer: ({ table }) => `${table.getFilteredRowModel().rows.length} people`,
  }),
  col.accessor('department', { header: 'Department', filterVariant: 'select' }),
  col.accessor('age', { header: 'Age', type: 'number', aggregationFn: 'mean' }),
  col.accessor('salary', {
    header: 'Salary',
    type: 'number',
    format: (v) => money.format(v),
    aggregationFn: 'sum',
  }),
];

export default function ColumnFootersExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="Payroll"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableColumnFooters
      footerAggregationScope="filtered"
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

`enableColumnFooters` renders a `tfoot`. Each column fills its cell either with an aggregate
(the same `aggregationFn` [grouping](/react-tablekit/grouping/) uses) or with whatever `footer`
returns.

```tsx
col.accessor('salary', { type: 'number', aggregationFn: 'sum' });
col.accessor('name', {
  footer: ({ table }) => table.getFilteredRowModel().rows.length + ' people',
});
```

### Which rows count

`footerAggregationScope` is the whole question a total has to answer. `'filtered'` sums
everything the filters left, across pages, the number a reader usually means. `'page'` sums what
is on screen, which is what you want when the footer is a per-page subtotal.

Filter the demo above and watch the total move: the count follows the filters, not the page.

### Staying visible

`enableStickyFooter` keeps the footer against the bottom of the scroll container, so the totals
are readable without scrolling to the end of a long table. See [Sticky header and
footer](/react-tablekit/sticky-header-and-footer/).

## Customization

A footer cell is a normal renderer, so it can hold anything: a formatted currency, a small bar,
a link to a report. `formatters` keeps number and date formatting consistent between the body and
the footer without repeating an `Intl` instance in every column.

## Limitations

- **A footer aggregate needs a value.** Display columns have none, so their footer is whatever
  `footer` returns or nothing at all.
- **Server mode aggregates what it received.** The table cannot total rows it has never seen;
  return the totals from the API and render them with `footer`.
- **There is one footer row.** Two levels of subtotal are grouping, not footers.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableColumnFooters`,
  `footerAggregationScope`, `enableStickyFooter`, `aggregationFns`, `formatters`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `footer`, `aggregationFn`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getFooterGroups`,
  `getFilteredRowModel`, `getPrePaginationRowModel`.

---

# Row pinning

> Keep chosen rows at the top or the bottom while the rest of the table sorts, filters and pages away beneath them.

URL: https://react-tablekit.vercel.app/react-tablekit/row-pinning/

## Basics

**Example: Row pinning** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/row-pinning/demo-basics)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(40);
const col = createColumnHelper<DemoPerson>();

/**
 * Row pinning. A pinned row stays visible while the rows around it are sorted, filtered
 * and paged away: the comparison row you keep referring back to.
 */
const columns = [
  col.display({
    id: 'pin',
    header: '',
    size: 96,
    static: true,
    cell: ({ row }) => (
      <button type="button" onClick={() => row.pin(row.getIsPinned() ? false : 'top')}>
        {row.getIsPinned() ? 'Unpin' : 'Pin'}
      </button>
    ),
  }),
  col.accessor((p) => `${p.firstName} ${p.lastName}`, { id: 'name', header: 'Name' }),
  col.accessor('department', { header: 'Department' }),
  col.accessor('age', { header: 'Age', type: 'number' }),
  col.accessor('city', { header: 'City' }),
];

export default function RowPinningExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableRowPinning
      enableStickyHeader
      maxHeight={420}
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

`enableRowPinning` lets a row be held at the top or the bottom of the table. Sort, filter and page
all around it and it stays, which is what makes it useful for comparing one record against the
rest.

```tsx
<button onClick={() => row.pin('top')}>Pin</button>
<button onClick={() => row.pin(false)}>Unpin</button>
```

Pinning is a row action, not a column, so it goes wherever it belongs in your design: a button in
a display column, an entry in a row menu, a keyboard shortcut.

### Staying pinned

`keepPinnedRows` is on by default, which means a pinned row survives a filter that would have
removed it. Turn it off and pinning is only a reordering: the row keeps its place at the top
while it still matches, and disappears when it does not.

### Pinned and sticky

With `enableStickyHeader`, top-pinned rows stick under the header and bottom-pinned rows sit
above the footer, so they stay on screen in a scrolling table rather than only at the top of the
list.

## Customization

`getTopRows` and `getBottomRows` return the pinned rows, so a summary line, a count or a "clear
pins" button can be driven from the same state. Pinned rows carry `data-pinned`, which is enough
for CSS to give them their own treatment without a slot.

## Limitations

- **Pinning is keyed by row id**, so it resets on refetch unless `getRowId` is stable.
- **A pinned row is rendered twice in spirit, once in fact**: it is lifted out of the body, so it
  does not also appear in its sorted position.
- **Pinned rows count against the page.** A page of ten with two pinned rows shows eight
  unpinned rows, not ten.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableRowPinning`,
  `keepPinnedRows`, `enableStickyHeader`, `enableStickyFooter`, `onRowPinningChange`.
- [`Row`](/react-tablekit/api/table-instance/): `pin`, `getIsPinned`, `getCanPin`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setRowPinning`, `resetRowPinning`,
  `getTopRows`, `getBottomRows`, `getCenterRows`.

---

# Row numbers

> A numbered column that counts through the whole result set, or restarts on every page.

URL: https://react-tablekit.vercel.app/react-tablekit/row-numbers/

## Basics

**Example: Row numbers** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/row-numbers/demo-basics)

```tsx
import { useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Row numbers. `absolute` counts through the whole result (row 11 is the first row of
 * page 2), while `relative` restarts at 1 on every page. Page forward to see the difference.
 */
export default function RowNumbersExample() {
  const [mode, setMode] = useState<'absolute' | 'relative'>('absolute');

  return (
    <>
      <fieldset className="demo-controls">
        <legend>Numbering</legend>
        {(['absolute', 'relative'] as const).map((value) => (
          <label key={value}>
            <input
              type="radio"
              name="row-number-mode"
              value={value}
              checked={mode === value}
              onChange={() => setMode(value)}
            />
            {value}
          </label>
        ))}
      </fieldset>
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        enableRowNumbers
        rowNumberMode={mode}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
    </>
  );
}
```

`enableRowNumbers` inserts a narrow display column of ordinals. There are two ways to count, and
the difference only shows up on page two:

- `rowNumberMode: 'absolute'` (the default) numbers through the result set, so page two starts at 11. Use it when the number is a position: "the 47th slowest query".
- `rowNumberMode: 'relative'` restarts at 1 on every page. Use it when the number is a label for
  talking about what is on screen: "row 3, second from the left".

### What the number is not

It is a position, not an identity. Sorting changes it, filtering changes it, and the same record
has a different number tomorrow. For anything a person might quote back to you (a ticket
reference, an order id), use a real column.

## Customization

The column is a display column like any other, so `positionSelectionColumn` and its neighbours
decide where it sits relative to the selection and expand columns. Its width and alignment come
from the same [tokens](/react-tablekit/customization/tokens/) as the rest, and its header label
comes from [localization](/react-tablekit/localization/).

If you want something the built-in column does not do (a rank that survives sorting, say), a
display column with your own `cell` is a few lines and gives you full control.

## Limitations

- **Absolute numbering needs a page size and index**, so in cursor mode, where there is no page
  number, it counts from the start of the loaded rows.
- **The column is excluded from CSV export**, as every display column is. Export the value it
  derives from instead.
- **It is not a rank.** Two rows with the same value get consecutive numbers, not a shared one.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableRowNumbers`,
  `rowNumberMode`, `positionSelectionColumn`, `positionExpandColumn`, `displayColumnDefs`.
- [Localization keys](/react-tablekit/api/localization-keys/): the header label.

---

# Row appearance

> Striping, hover and per-row classes, styles and props: the way a row shows what it is without a custom renderer.

URL: https://react-tablekit.vercel.app/react-tablekit/row-appearance/

## Basics

**Example: Row appearance** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/row-appearance/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Row appearance. Striping and hover are table-wide switches; `getRowClassName` and
 * `getRowStyle` decorate individual rows from their data. Here, suspended people are dimmed and
 * remote workers carry an accent border.
 */
export default function RowAppearanceExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      enableStriped
      enableHover
      getRowClassName={(row) => (row.original.status === 'suspended' ? 'is-muted' : undefined)}
      getRowStyle={(row) =>
        row.original.remote ? { boxShadow: 'inset 3px 0 0 var(--tk-color-accent)' } : undefined
      }
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

`enableStriped` and `enableHover` are table-wide and tokenized, so they follow the theme rather
than hard-coded greys. For anything that depends on the row's data, three callbacks cover almost
every case:

```tsx
<DataTable
  getRowClassName={(row) => (row.original.overdue ? 'is-overdue' : undefined)}
  getRowStyle={(row) => (row.original.pinned ? { fontWeight: 600 } : undefined)}
  getRowProps={(row) => ({ 'data-status': row.original.status })}
/>
```

`getRowProps` is the one that reaches furthest: it puts data attributes on the row, which lets
CSS do the rest without a class name for every state.

### Interaction

`onRowClick`, `onRowDoubleClick` and `onRowContextMenu` make the row itself interactive, and a
row only becomes focusable when one of them is set; an unclickable row should not be a tab stop.
`onRowMouseEnter` and `onRowMouseLeave` cover hover previews.

Clicks that land on a button, a link, an input or an element marked `data-tk-stop` do not count
as row clicks, so a row menu keeps working inside a clickable row.

### Rows that are not available

`isRowDisabled` marks a row unavailable everywhere at once: it is not selectable, select-all
skips it, and it is styled from the disabled tokens. That is one answer instead of three.

## Customization

When the callbacks are not enough, `renderRow` replaces the whole row and hands you
`defaultRender()` so you can wrap rather than rebuild:

```tsx
renderRow={({ row, defaultRender }) => (
  <Tooltip content={row.original.note}>{defaultRender()}</Tooltip>
)}
```

Cell-level work belongs in [Rows and cells](/react-tablekit/customization/rows-and-cells/), and
the colours themselves are [tokens](/react-tablekit/customization/tokens/).

## Limitations

- **Striping counts rendered rows.** With detail panels or grouping, the alternation follows what
  is on screen, which is not always the data order.
- **`getRowStyle` is inline style**, so it wins over the stylesheet. Prefer a class or a data
  attribute when a theme should still be able to override it.
- **`renderRow` opts out of the table's own row markup**, including its accessibility
  attributes, so keep `defaultRender()` in the tree unless you are replacing those deliberately.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableStriped`, `enableHover`,
  `getRowClassName`, `getRowStyle`, `getRowProps`, `renderRow`, `isRowDisabled`, `onRowClick`,
  `onRowDoubleClick`, `onRowContextMenu`, `onRowMouseEnter`, `onRowMouseLeave`, `onCellClick`,
  `bordered`, `noWrap`.
- [Theme tokens](/react-tablekit/api/theme-tokens/): `--tk-row-bg`, `--tk-row-hover-bg`,
  `--tk-row-selected-bg`, `--tk-row-stripe-bg`.

---

# Sticky header and footer

> Keep the header and the totals in view while the rows scroll, in both axes when columns are pinned.

URL: https://react-tablekit.vercel.app/react-tablekit/sticky-header-and-footer/

## Basics

**Example: Sticky header and footer** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/sticky-header-and-footer/demo-basics)

```tsx
import { createColumnHelper, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const col = createColumnHelper<DemoPerson>();
const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

const columns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, { id: 'name', header: 'Name' }),
  col.accessor('department', { header: 'Department', pin: 'left' }),
  col.accessor('city', { header: 'City' }),
  col.accessor('age', { header: 'Age', type: 'number', aggregationFn: 'mean', footer: 'Average' }),
  col.accessor('salary', {
    header: 'Salary',
    type: 'number',
    format: (v) => money.format(v),
    aggregationFn: 'sum',
    footer: 'Total',
  }),
];

const data = generatePeople(200);

/**
 * A bounded, scrollable table: the header sticks to the top of the scroll container, the
 * footer with its aggregates sticks to the bottom, and the pinned Department column stays put in
 * both axes. Pagination lives outside the scroll area, so it is always visible.
 */
export default function StickyHeaderFooterExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      enableStickyHeader
      enableStickyFooter
      enableColumnFooters
      maxHeight={420}
      initialState={{ pagination: { pageIndex: 0, pageSize: 50 } }}
    />
  );
}
```

`enableStickyHeader` holds the header cells against the top of the scroll container.
`enableStickyFooter` does the same for the `tfoot`. Both need the container to be the thing that
scrolls, which means it needs a bounded height:

```tsx
<DataTable enableStickyHeader maxHeight={480} data={rows} columns={columns} />
```

`maxHeight` is the usual way to give it one; a parent with a fixed height works just as well.

### Both directions at once

With [pinned columns](/react-tablekit/column-pinning/), a pinned header cell is stuck vertically
_and_ horizontally: it stays in the corner while the table scrolls both ways. That is one
position rule, not two features interfering.

### Pagination stays put

The pagination control sits outside the scroll container, so it is always visible. Scrolling to
the last row and finding no way to reach page two is a bug this layout cannot have.

## Customization

The header background is `--tk-header-bg` and the shadow that appears when content passes
underneath is a [token](/react-tablekit/customization/tokens/) as well, so a sticky header can
be flat, raised or bordered without touching the markup.

Sticky headers and [virtualization](/react-tablekit/virtualization/) compose: the header is
outside the virtual window, so it does not move as rows are recycled.

## Limitations

- **A sticky header needs a bounded height.** Without one the page scrolls instead of the
  container and nothing sticks; the table warns about this in development.
- **A very tall header group eats the viewport.** Two header rows plus a filter row on a short
  screen leaves little for data.
- **`position: sticky` cannot escape an ancestor with `overflow: hidden`.** If the header will not
  stick, that ancestor is usually why.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableStickyHeader`,
  `enableStickyFooter`, `maxHeight`, `minWidth`, `maxWidth`, `layout`, `bordered`.
- [Theme tokens](/react-tablekit/api/theme-tokens/): `--tk-header-bg`, `--tk-z-pinned-header`.

---

# Surface

> The panel the table sits on: a bordered card with its pagination attached, or nothing at all, with corners you choose.

URL: https://react-tablekit.vercel.app/react-tablekit/surface/

## Basics

**Example: Surface** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/surface/demo-basics)

```tsx
import { useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Surface and corners. `card` wraps the table and its pagination in one panel; `plain` removes it
 * so the table sits on whatever the page provides, with the pagination as a block beneath.
 */
export default function SurfaceExample() {
  const [surface, setSurface] = useState<'card' | 'plain'>('card');
  const [rounded, setRounded] = useState(true);

  return (
    <>
      <fieldset className="demo-controls">
        <legend>Surface</legend>
        {(['card', 'plain'] as const).map((value) => (
          <label key={value}>
            <input
              type="radio"
              name="surface"
              value={value}
              checked={surface === value}
              onChange={() => setSurface(value)}
            />
            {value}
          </label>
        ))}
        <label>
          <input type="checkbox" checked={rounded} onChange={(e) => setRounded(e.target.checked)} />
          rounded
        </label>
      </fieldset>
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        surface={surface}
        rounded={rounded}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
    </>
  );
}
```

By default the table and its pagination share one panel: a border, a background and rounded
corners, with the seam between them removed so they read as a single card.

`surface="plain"` takes the panel away. The table stands on whatever the page already provides,
and the pagination becomes a separate block beneath it rather than the bottom of a card.

```tsx
<DataTable surface="plain" data={rows} columns={columns} />
```

That is the one to reach for when the table is already inside a panel of your own (a dialog, a
dashboard tile, a section with its own border), where a second frame around it is one frame too
many.

### Corners

`rounded` switches the outer corners. A card is rounded unless you say otherwise; a plain surface
is square unless you ask, because rounding something with no border and no fill only shows where a
cell has a background of its own.

```tsx
<DataTable surface="plain" rounded data={rows} columns={columns} />
```

The radius itself is the `--tk-radius` [token](/react-tablekit/customization/tokens/), so
`rounded` is a switch rather than a measurement: set the token once and every table that opts in
follows the same curve.

### Borders inside the table

`bordered` is a different question: it draws the lines _between_ cells rather than around the
table. `'outer'` keeps the frame and the row dividers, `'rows'` keeps only the dividers, `'all'`
draws a full grid, and `'none'` leaves the table unruled.

## Customization

Everything here is a token, so a design system can set the surface once for every table rather
than passing props: `--tk-container-border`, `--tk-container-shadow`, `--tk-color-surface` and
`--tk-radius` are what `card` is made of.

The container is a [slot](/react-tablekit/customization/slots/) as well, so a table that needs to
sit inside something more particular than a border can be given one.

## Limitations

- **A plain surface still scrolls.** The container element remains, because sticky headers and
  pinned columns are positioned against it; only its border, fill and shadow are removed.
- **`rounded` rounds the outside, not the cells.** Individual cells have no radius; that is a
  cell renderer's decision.
- **The card's seam is only removed for pagination.** A table with `paginationType="none"` and a
  footer of your own beneath it will show the container's bottom border between them.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `surface`, `rounded`, `bordered`,
  `maxHeight`, `minWidth`, `maxWidth`, `unstyled`.
- [Theme tokens](/react-tablekit/api/theme-tokens/): `--tk-radius`, `--tk-container-border`,
  `--tk-container-shadow`, `--tk-color-surface`.
- [TableSlots](/react-tablekit/api/slots/): `Container`, `Pagination`.

---

# Density

> Three row heights the reader can switch between (compact, standard and comfortable), driven entirely by tokens.

URL: https://react-tablekit.vercel.app/react-tablekit/density/

## Basics

**Example: Density** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/density/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Density changes row height and cell padding only, never the layout. The toolbar
 * button cycles compact → standard → comfortable; `initialState.density` picks the start.
 */
export default function DensityExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      enableDensityToggle
      enableRowSelection
      initialState={{ density: 'compact', pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

Density changes cell padding and row height, and nothing else. No column moves, no text is
hidden, no layout is recomputed, which is why switching it feels instant and why it is safe to
let the reader do it.

```tsx
<DataTable enableDensityToggle initialState={{ density: 'compact' }} />
```

`enableDensityToggle` puts the control in the toolbar. Without it, `state.density` still applies,
which is useful when density belongs to your own settings screen rather than the table's toolbar.

### Choosing a default

`compact` fits roughly a third more rows on a screen and suits dashboards and monitoring views.
`standard` is the default. `comfortable` suits touch and long reading sessions, where a taller
target matters more than another row.

### Remembering it

Density is a state slice, so adding it to [`syncState`](/react-tablekit/persistence/) makes the
reader's choice stick across visits. It is one of the slices most worth persisting, because it is
a preference rather than a query.

## Customization

Each level is a set of [tokens](/react-tablekit/customization/tokens/), so a design system can
redefine what "compact" means without forking the component:

```css
[data-density='compact'] {
  --tk-cell-py: 4px;
  --tk-cell-px: 8px;
}
```

`toggleDensity` and `setDensity` drive it from your own control: a keyboard shortcut, a settings
menu, a global preference applied to every table at once.

## Limitations

- **Density does not change font size.** Text stays legible; only the space around it shrinks. Set
  `--tk-font-size` yourself if you want both.
- **Row height still depends on content.** A cell with two lines is two lines tall at every
  density.
- **With [virtualization](/react-tablekit/virtualization/), changing density re-measures.** Pass
  the matching `estimateRowHeight` if you set a non-default density up front.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableDensityToggle`,
  `initialState.density`, `state.density`, `estimateRowHeight`, `onDensityChange`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `setDensity`, `toggleDensity`.
- [Theme tokens](/react-tablekit/api/theme-tokens/): the `--tk-cell-*` and `--tk-row-height`
  families.

---

# Virtualization

> Render the rows on screen instead of all of them: a hundred thousand rows with the same markup, the same keyboard model and the same counts for screen readers.

URL: https://react-tablekit.vercel.app/react-tablekit/virtualization/

## Basics

**Example: Virtualization: 100k rows** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/virtualization/demo-basics)

```tsx
import { useMemo, useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

/**
 * 100 000 rows in one scroll container: only the rows near the viewport are in the DOM,
 * with spacer rows keeping the scrollbar honest. Sorting, searching and filtering still run over
 * the whole dataset, and the table exposes grid semantics so screen readers get the real totals.
 */
export default function Virtualization100kExample() {
  const [count, setCount] = useState(100_000);
  const data = useMemo(() => generatePeople(count), [count]);

  return (
    <div className="example-stack">
      <div className="example-controls">
        <label>
          <span>Rows</span>
          <select value={count} onChange={(e) => setCount(Number(e.target.value))}>
            {[1_000, 10_000, 100_000].map((n) => (
              <option key={n} value={n}>
                {n.toLocaleString('en-US')}
              </option>
            ))}
          </select>
        </label>
        <p className="site-muted">
          Scroll the table: the DOM keeps only the visible window, however far you go.
        </p>
      </div>
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        enablePagination={false}
        enableRowVirtualization
        enableStickyHeader
        maxHeight={460}
        estimateRowHeight={44}
      />
    </div>
  );
}
```

Virtualization renders only the rows in view plus a small overscan, so the cost of a table stops
growing with the data. It turns itself on above `virtualizationThreshold` visible rows, which
means most tables never think about it and the one that suddenly loads 50,000 rows does not fall
over.

```tsx
<DataTable data={rows} columns={columns} maxHeight={600} />
```

The one thing it needs is a bounded height: `maxHeight`, or a parent that has one. Without it
there is no viewport to virtualize against, and the table says so in development.

### Row height

`estimateRowHeight` is the starting guess, and rows are measured as they render, so variable
heights settle correctly after the first paint. Give a close estimate and the scrollbar is
accurate immediately; give a poor one and it corrects itself as you scroll.

`overscan` trades memory for smoothness: a few extra rows above and below the viewport mean
fewer blank frames during a fast flick.

### Still a real table

Virtualized rows use grid semantics: the table reports `aria-rowcount` for the whole result and
every row carries its true `aria-rowindex`. A screen reader hears "row 4,201 of 100,000", not
"row 12 of 20". [Keyboard navigation](/react-tablekit/keyboard-navigation/) is
virtualization-aware too, so Page Down moves by a screen of rows and `scrollToRow` brings any row
into view.

### What it composes with

Detail panels (measured), grouping, sticky headers, pinned columns and infinite pagination all
work under virtualization, which is the point of building it in rather than bolting it on.

## Customization

`useVirtualRows` is exported, so a [headless](/react-tablekit/headless/) table or a custom layout
can use the same windowing the built-in body uses. `enableRowVirtualization: false` forces it off
for a table where every row must be in the DOM, such as a print view, or a test that scrapes markup.

## Limitations

- **Browser find-in-page only finds rendered rows.** This is inherent to windowing; offer search
  in the table instead.
- **Columns are not virtualized in 1.0.** Fifty columns render fifty cells per row; column
  virtualization is on the [roadmap](/react-tablekit/discover-more/roadmap/).
- **Wildly variable row heights make the scrollbar approximate** until those rows have been
  measured, because their size is not known before they render.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableRowVirtualization`,
  `virtualizationThreshold`, `estimateRowHeight`, `overscan`, `maxHeight`, `layout`.
- [Hooks](/react-tablekit/api/hooks/): `useVirtualRows`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `scrollToRow`, `getRowCount`.

---

# Responsive

> Breakpoint-aware props, a card layout for small screens, and a server-rendered first paint that does not jump.

URL: https://react-tablekit.vercel.app/react-tablekit/responsive/

## Basics

**Example: Responsive cards** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/responsive/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * The cards layout: below the mobile breakpoint each row renders as a card with
 * label/value pairs instead of a horizontally scrolling table; semantically a list of articles.
 * Narrow the preview (or your window) below 960px to see it switch.
 */
export default function ResponsiveCardsExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      responsive={{
        mobileLayout: 'cards',
        mobileBreakpoint: 'md',
        cardColumns: ['name', 'email', 'department', 'city', 'salary'],
      }}
      initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
    />
  );
}
```

Below the mobile breakpoint a table can stop being a table. `mobileLayout: 'cards'` renders each
row as a card of label and value pairs, with the row actions in the card footer and the selection
checkbox in its header. Sorting moves to a "Sort by" select, because there are no headers left to
click.

```tsx
<DataTable
  responsive={{ mobileLayout: 'cards', cardColumns: ['name', 'status', 'total'] }}
  data={rows}
  columns={columns}
/>
```

`cardColumns` chooses what a card shows; a card with nine fields is a table with extra steps.

### Scrolling instead

The default `mobileLayout: 'scroll'` keeps the table and scrolls it horizontally, with
[pinned](/react-tablekit/column-pinning/) columns holding the identifying column in place and
tighter padding below the small breakpoint. For a table of numbers that is usually better than
cards.

### Responsive props

Several props take a value per breakpoint, mobile-first, so behaviour follows the layout without
a media query in your code:

```tsx
col.display({ id: 'actions', pin: { base: false, md: 'right' } })
<DataTable responsive={{ breakpoints: { xs: 0, sm: 600, md: 960, lg: 1280, xl: 1440 } }} />
```

`pin`, `headerShort`, the pagination variant, `density` and `maxWidth` all accept one.
`headerShort` is the small one worth remembering: a column can be _Monthly recurring revenue_ on
a wide screen and _MRR_ on a narrow one.

### Server rendering

On the server there is no viewport, so the first render uses `ssrBreakpoint`, which is `lg` by default.
Pick the one your typical reader is on and the hydrated layout will match the rendered one
instead of snapping into place.

## Customization

`renderCard` replaces the card entirely when label-and-value is not the right shape.
`useBreakpoint` exposes the same breakpoint state to your own components, so a toolbar button can
disappear on mobile using the table's breakpoints rather than a second set of its own.

## Limitations

- **Cards have no columns**, so column resizing, reordering, pinning and the header menu do not
  apply there.
- **`hideBelow` and the card layout are different tools.** One removes a column, the other
  changes the presentation of all of them.
- **Breakpoints are the table's, not your framework's.** If your design system already has
  breakpoints, pass them in so the two agree.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `responsive` (`breakpoints`,
  `mobileBreakpoint`, `mobileLayout`, `cardColumns`, `renderCard`, `ssrBreakpoint`),
  `compactPagination`, `maxWidth`, `minWidth`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `headerShort`, `hideBelow`, `hideAbove`, `pin`.
- [Hooks](/react-tablekit/api/hooks/): `useBreakpoint`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getBreakpoint`.

---

# Toolbar

> The strip above the table (search, filters, columns, density, export), either as one component or as parts you place yourself.

URL: https://react-tablekit.vercel.app/react-tablekit/toolbar/

## Basics

**Example: Composable layout** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/toolbar/demo-basics)

```tsx
import { DataTable, useDataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(60);

/**
 * The same table, taken apart. `useDataTable` builds the instance and
 * `DataTable.Root` shares it; every part below can be moved, wrapped or left out.
 * Here the search sits in the page header and pagination is above the table.
 */
export default function ComposableLayoutExample() {
  const table = useDataTable<DemoPerson>({
    'aria-label': 'People',
    data,
    columns: peopleColumns,
    getRowId: (p) => p.id,
    enableRowSelection: true,
    enableHiding: true,
    initialState: { pagination: { pageIndex: 0, pageSize: 6 } },
  });

  return (
    <DataTable.Root table={table}>
      <header className="demo-page-header">
        <h3>Team directory</h3>
        <DataTable.Search />
        <DataTable.ColumnsButton />
      </header>
      <DataTable.SelectionBar />
      <DataTable.Pagination />
      <DataTable.Container />
    </DataTable.Root>
  );
}
```

The toolbar assembles itself from the features that are on: search at the start, then the filters
button and the active filter chips; density, columns and export at the end. Turn a feature off
and its control disappears; there is no separate toolbar configuration to keep in sync.

`renderToolbarStart` and `renderToolbarEnd` add your own content beside the built-ins, which is
where an **Add** button or a saved-view picker belongs:

```tsx
<DataTable
  renderToolbarEnd={() => <button onClick={create}>New person</button>}
  data={rows}
  columns={columns}
/>
```

### Taking it apart

When the toolbar should not be a strip above the table (because search belongs in the page
header, or the filters live in a sidebar), use the composable parts. They talk to the same table
instance, so nothing is duplicated:

```tsx
const table = useDataTable({ data, columns });

<DataTable.Root table={table}>
  <PageHeader>
    <DataTable.Search table={table} />
    <DataTable.FiltersButton table={table} />
  </PageHeader>
  <Sidebar>
    <DataTable.FilterPanel table={table} />
  </Sidebar>
  <DataTable.Table />
  <DataTable.Pagination />
</DataTable.Root>;
```

`Search`, `FiltersButton`, `FilterPanel`, `ColumnsButton`, `ExportButton`, `Pagination` and
`SelectionBar` are all available this way.

### The selection bar

When [row selection](/react-tablekit/row-selection/) is on, a second bar appears under the
toolbar as soon as something is selected. `renderBulkActions` fills it, and
`showSelectionBar: false` removes it if your design puts bulk actions somewhere else.

## Customization

Every control is a [slot](/react-tablekit/customization/slots/), so replacing the Filters button
with your design system's button is one entry rather than a rebuild. `toolbarActions` is the
short form of `renderToolbarEnd` for a list of buttons, and `toolbar: false` removes the strip
entirely while leaving the features working.

## Limitations

- **The toolbar wraps, it does not collapse.** On a narrow screen the controls flow onto a second
  line rather than folding into an overflow menu; use the parts if you need one.
- **A composable part still needs the table instance.** The parts are not context-free
  components; they read and write the same state, which is what keeps them consistent.
- **Order within the toolbar is fixed.** Start and end slots are yours; the built-ins keep their
  relative order so tables look alike.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `toolbar`, `toolbarActions`,
  `renderToolbarStart`, `renderToolbarEnd`, `enableGlobalFilter`, `filterDisplayMode`,
  `enableHiding`, `enableDensityToggle`, `enableExport`, `showSelectionBar`, `renderBulkActions`.
- [TableSlots](/react-tablekit/api/slots/): `Toolbar`, `SearchInput`, `FiltersButton`, `FilterPanel`,
  `ColumnsButton`, `ExportButton`, `SelectionBar`.
- [Hooks](/react-tablekit/api/hooks/): `useDataTable` for the composable form.

---

# Loading and empty states

> Skeletons, a delayed overlay, an empty state that knows why it is empty, and errors that keep the rest of the table usable.

URL: https://react-tablekit.vercel.app/react-tablekit/loading-and-empty-states/

## Basics

**Example: Loading, empty and error states** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/loading-and-empty-states/demo-basics)

```tsx
import { useMemo, useState } from 'react';
import { createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

type StateName = 'loading' | 'skeleton' | 'refetching' | 'empty' | 'noResults' | 'error';

const people = generatePeople(40);

/**
 * Every data state: the first load as text or skeleton rows, the refetch overlay
 * that keeps the rows readable, the two empty states (no data at all vs nothing matching the
 * filters) and the error state with Retry.
 */
export default function StatesExample() {
  const [state, setState] = useState<StateName>('skeleton');

  const dataSource = useMemo(() => {
    const rows = state === 'empty' ? [] : people;
    return createLocalDataSource(rows, {
      columns: peopleColumns,
      getRowId: (p: DemoPerson) => p.id,
      // "Loading" states never resolve, so the demo can hold them still.
      latencyMs:
        state === 'loading' || state === 'skeleton' ? 100_000 : state === 'refetching' ? 1_500 : 0,
      failRate: state === 'error' ? 1 : 0,
    });
  }, [state]);

  return (
    <div className="example-stack">
      <div className="example-controls">
        <label>
          <span>State</span>
          <select value={state} onChange={(e) => setState(e.target.value as StateName)}>
            <option value="skeleton">First load: skeleton rows</option>
            <option value="loading">First load: “Loading…” row</option>
            <option value="refetching">Refetch: blocking overlay</option>
            <option value="empty">Empty: no rows at all</option>
            <option value="noResults">Empty: nothing matches the search</option>
            <option value="error">Error, with Retry</option>
          </select>
        </label>
        {state === 'noResults' && (
          <p className="site-muted">Search for something that cannot match, e.g. “zzzz”.</p>
        )}
        {state === 'refetching' && (
          <p className="site-muted">Change the page to see the overlay.</p>
        )}
      </div>
      <DataTable<DemoPerson>
        key={state}
        aria-label="People"
        columns={peopleColumns}
        dataSource={dataSource}
        getRowId={(p) => p.id}
        loadingDisplay={state === 'loading' ? 'text' : 'skeleton'}
        initialState={{
          pagination: { pageIndex: 0, pageSize: 8 },
          ...(state === 'noResults' ? { globalFilter: 'zzzz-nobody' } : {}),
        }}
      />
    </div>
  );
}
```

A table spends real time in three states that are not "showing rows", and each has its own
answer.

**Loading.** `loadingDisplay` chooses between a skeleton (rows of placeholder blocks, sized by
`skeletonRowCount`) and an overlay over the rows that are already there. The skeleton suits a
first load; the overlay suits a refetch, because keeping the old page visible is less jarring
than blanking it.

**Empty.** `renderEmptyState` and `emptyStateContent` fill the body when there is nothing to show.
The most useful empty state distinguishes "no data yet" from "no matches for these filters", and
offers to clear them:

```tsx
renderEmptyState={({ table }) =>
  table.getActiveFilterCount() > 0 ? (
    <button onClick={() => table.clearAllFilters()}>Clear filters</button>
  ) : (
    <p>No people yet.</p>
  )
}
```

**Error.** `error` and `renderErrorState` show the failure without discarding the table around
it, so the reader can change a filter and try again instead of reloading the page.

### The overlay does not flash

`loadingOverlayDelayMs` (150ms by default) means a response that arrives quickly never shows a
spinner at all. A spinner that appears and vanishes in 80 milliseconds is noise, not feedback.

The overlay covers the body from the bottom of the header down, blocks interaction while it is up
and sets `aria-busy` on the table. `loadingOverlayBlocksInteraction: false` lets a reader keep
typing in the search box while the previous request finishes.

### Both flags

`loading` is the first load; `fetching` is a refresh of data that already exists. Using both is
what lets a table show a skeleton once and an overlay after that.

## Customization

Every state is a [slot](/react-tablekit/customization/slots/): `SkeletonRows`, `LoadingRow`,
`LoadingOverlay`, `EmptyState`, `ErrorState` and `ErrorBanner`. A design system replaces them
once and every table follows.

With `dataSource`, the states are driven for you: `getDataStatus()` reports which one the table
is in, and `onStatusChange` fires on every transition.

## Limitations

- **An empty page is not an empty table.** Landing on page 5 of a filtered result shows the empty
  state; `autoResetPageIndex` exists to avoid it.
- **The overlay needs a positioned container**, which the table provides, but a custom
  `LoadingOverlay` slot rendered outside it will not be positioned.
- **`error` is display, not recovery.** Retrying is your decision; the table only shows what you
  give it.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `loading`, `fetching`, `error`,
  `loadingDisplay`, `skeletonRowCount`, `loadingOverlayDelayMs`,
  `loadingOverlayBlocksInteraction`, `renderEmptyState`, `emptyStateContent`,
  `renderErrorState`, `onError`, `onStatusChange`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getDataStatus`, `refresh`,
  `invalidate`, `getActiveFilterCount`, `clearAllFilters`.
- [TableSlots](/react-tablekit/api/slots/): the six state slots.

---

# Keyboard navigation

> The WAI-ARIA data grid pattern: arrow keys between cells, one tab stop for the whole table, and Enter to reach interactive content.

URL: https://react-tablekit.vercel.app/react-tablekit/keyboard-navigation/

## Basics

**Example: Keyboard navigation** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/keyboard-navigation/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(60);

/**
 * Keyboard grid navigation, following the WAI-ARIA "Data Grid" pattern. The whole table
 * is a single tab stop: Tab enters it and Tab leaves it again, arrows move between cells.
 */
export default function KeyboardNavigationExample() {
  return (
    <div className="example-stack">
      <dl className="example-dl">
        <dt>Tab</dt>
        <dd>Enter the grid at the last focused cell, then leave it again</dd>
        <dt>← ↑ → ↓</dt>
        <dd>Move between cells, header included</dd>
        <dt>Home / End</dt>
        <dd>First or last cell of the row; with Ctrl, of the whole grid</dd>
        <dt>Page Up / Page Down</dt>
        <dd>Move by one page of rows</dd>
        <dt>Enter</dt>
        <dd>Sort from a header cell, or move into a cell’s buttons and links (Escape returns)</dd>
        <dt>Space</dt>
        <dd>Select the focused row; Shift+Space selects a range</dd>
        <dt>Ctrl/⌘+A</dt>
        <dd>Select every row on the page</dd>
      </dl>
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        enableKeyboardNavigation
        enableRowSelection
        enableColumnActions
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
    </div>
  );
}
```

`enableKeyboardNavigation` turns the table into a grid in the WAI-ARIA sense: the whole table is
one tab stop, and the arrow keys move between cells inside it. Tab in, navigate, Tab out, rather
than tabbing through four hundred cells to reach the pagination.

| Key                 | Action                                                                         |
| ------------------- | ------------------------------------------------------------------------------ |
| Tab                 | Enters the grid at the last focused cell, or leaves it                         |
| Arrows              | Move between cells, header included                                            |
| Home / End          | First or last cell in the row; with Ctrl, in the grid                          |
| Page Up / Page Down | Move by a screen of rows                                                       |
| Enter               | Sorts on a header, runs `onRowClick` on a row, or steps into a cell's controls |
| Escape              | Steps back out of a cell, and closes menus                                     |
| Space               | Toggles selection of the focused row                                           |
| Left / Right        | Collapse or expand a row that has children or a panel                          |

### Reaching what is inside a cell

A cell with a link and two buttons is a problem for a grid: the arrow keys own those directions.
Enter steps _into_ the cell, where Tab moves between its controls, and Escape steps back out.
That is the standard resolution and it is what this implements.

### Without it

Turned off, everything is still reachable: buttons, checkboxes, sort buttons and links are all in
the tab order. Rows are not focusable unless `onRowClick` makes them interactive; a row you
cannot activate should not be a tab stop.

### With virtualization

Navigation is [virtualization](/react-tablekit/virtualization/)-aware. Page Down moves by a
visible page and scrolls the target row into view, so arrowing past the bottom of the window does
not fall off the end of the rendered rows.

## Customization

`focusCell` and `scrollToRow` drive focus from your own code: after a save, after an insert, or
from a keyboard shortcut of your own. The focus ring is the `--tk-focus-ring`
[token](/react-tablekit/customization/tokens/), so it matches the rest of your application rather
than the browser default.

## Limitations

- **Cell focus is not cell _selection_.** Copying a rectangular range is on the
  [roadmap](/react-tablekit/discover-more/roadmap/), not in 1.0.
- **A cell whose content traps focus will trap it here too.** A custom control that swallows
  Escape breaks the step-out, and the table cannot intercept that.
- **Two grids on one page each have their own tab stop**, which is correct but means the reader
  tabs twice to cross them.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableKeyboardNavigation`,
  `onRowClick`, `onCellClick`, `enableRangeSelection`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `focusCell`, `scrollToRow`.
- [Accessibility](/react-tablekit/guides/accessibility/): how this fits the rest of the
  accessibility model.

---

# Export

> CSV of the page, the selection or everything matching, chunked through your API when the result is larger than the browser holds.

URL: https://react-tablekit.vercel.app/react-tablekit/export/

## Basics

**Example: Export** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/export/demo-basics)

```tsx
import { createLocalDataSource, DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const clientData = generatePeople(120);
const serverPeople = generatePeople(3_000, 11);
const source = createLocalDataSource(serverPeople, {
  columns: peopleColumns,
  getRowId: (p) => p.id,
  latencyMs: [100, 250],
});

/**
 * CSV export: the toolbar menu exports the current page, every matching row, or just the
 * selected ones, and can copy to the clipboard instead. Quoting follows RFC 4180 and the file
 * carries a UTF-8 BOM, so Excel opens accented text correctly.
 *
 * In server mode "all matching" is fetched in chunks through the data source, with progress shown
 * in the button. Try it on the second table, which has 3 000 rows behind a simulated API.
 */
export default function ExportExample() {
  return (
    <div className="example-stack">
      <section className="example-panel">
        <h2>Client mode</h2>
        <DataTable<DemoPerson>
          aria-label="People (client)"
          data={clientData}
          columns={peopleColumns}
          getRowId={(p) => p.id}
          enableExport
          enableRowSelection
          exportFileName="people"
          initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
        />
      </section>
      <section className="example-panel">
        <h2>Server mode (chunked)</h2>
        <DataTable<DemoPerson>
          aria-label="People (server)"
          columns={peopleColumns}
          dataSource={source}
          getRowId={(p) => p.id}
          enableExport
          exportMode="all"
          exportFileName="people-all"
          initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
        />
      </section>
    </div>
  );
}
```

`enableExport` adds a toolbar menu with four entries: the current page, everything matching the
filters, the selected rows, and copy to clipboard. Three of those are different questions and the
menu asks which one you meant rather than guessing.

```tsx
<DataTable enableExport exportFileName="people.csv" data={rows} columns={columns} />
```

### What lands in the file

Headers come from `meta.exportHeader`, then a string `header`, then the column id. Values come
from `exportValue`, then `format`, then the value stringified, so an exported date matches the
displayed date, and an exported currency is a number rather than `$1,240.00` if you say so.
`exportValue` receives the whole row, not the cell value:

```tsx
col.accessor('total', { format: (v) => money.format(v), exportValue: (row) => row.total });
```

Quoting follows RFC 4180, and a UTF-8 byte order mark is written by default so Excel opens
accented text correctly. Display columns and `enableExport: false` columns are left out.

A field a spreadsheet would run as a formula (one starting with `=`, `+`, `-` or `@`) is
written with a leading `'`, so it opens as text. Plain numbers such as `-12` are left alone. The
same applies to copying to the clipboard. `escapeFormulas: false` turns it off, for data you
trust; see [Security](/react-tablekit/guides/security/).

### Exporting more than the browser has

"All matching" in server mode cannot mean the loaded page. The table walks your data source in
chunks of `exportChunkSize` up to `exportMaxRows`, showing progress in the button and letting the
reader cancel.

When your backend already has an export endpoint, `onExport(query, scope)` hands you the query
and the table does nothing else, which is the right answer for a million rows.

### Saving the file

The default download is a blob and a temporary link. `onExportFile(blob, fileName)` intercepts it
for a custom destination: a file-system picker, an upload, a preview dialog.

## Customization

`exportToCsv` is exported, so the same serialization is available outside the table for a
scheduled report or a test. The export button is a [slot](/react-tablekit/customization/slots/)
and `<DataTable.ExportButton />` places it outside the toolbar.

## Limitations

- **CSV only in 1.0.** Excel and PDF are the caller's job; `onExport` is the hook for them.
- **`exportMaxRows` is a real ceiling.** It defaults to 10,000 so a mis-click cannot pull a
  million rows through the browser; raise it deliberately.
- **What you see is what you get, not what you have.** Hidden columns are excluded: an export is
  the table as configured, which is usually what the reader means.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `enableExport`, `exportMode`,
  `exportFileName`, `exportChunkSize`, `exportMaxRows`, `onExport`, `onExportFile`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `enableExport`, `exportValue`,
  `meta.exportHeader`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `exportCsv`, `copyToClipboard`,
  `getQuery`, `getSelectionQuery`.
- [Utilities](/react-tablekit/api/utilities/): `exportToCsv`.

---

# Persistence

> Put table state in the URL, in storage, or in both, so a link reproduces a view and a reload does not lose one.

URL: https://react-tablekit.vercel.app/react-tablekit/persistence/

## Basics

**Example: URL and storage sync** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/persistence/demo-basics)

```tsx
import { useEffect, useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(120);

/**
 * URL and storage persistence. The page, sort, search and filters live in the URL in a
 * compact, stable format (`?tk.page=2&tk.sort=name.asc&tk.q=ava`), so a link restores the exact
 * view; column layout and density go to `localStorage` instead, because they are personal
 * preferences rather than something you share.
 *
 * With a router, pass its search params through `useRouterSync` instead of the History API.
 */
export default function UrlSyncExample() {
  const [url, setUrl] = useState('');
  useEffect(() => {
    const update = () => setUrl(window.location.search || '(no parameters yet)');
    update();
    // The table writes with history.replaceState, which fires no event; poll for the demo.
    const timer = setInterval(update, 250);
    return () => clearInterval(timer);
  }, []);

  return (
    <div className="example-stack">
      <p className="site-muted">
        Sort, search, filter or page, then reload: the view comes back. Current URL state:{' '}
        <code>{url}</code>
      </p>
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        enableHiding
        enableDensityToggle
        showActiveFilterChips
        syncState={{
          url: { keys: ['pagination', 'sorting', 'globalFilter', 'columnFilters'] },
          storage: { key: 'react-tablekit:url-sync-demo', version: 1 },
        }}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
    </div>
  );
}
```

`syncState` writes chosen state slices somewhere they survive. Two destinations, and the choice
between them is about who the state belongs to:

```tsx
<DataTable
  syncState={{
    url: { keys: ['pagination', 'sorting', 'globalFilter', 'columnFilters'] },
    storage: { key: 'people-table', keys: ['columnVisibility', 'columnOrder', 'density'] },
  }}
/>
```

The **URL** is for what a reader would send to a colleague: the search, the filters, the sort, the
page. Paste the link and you get the same view.

**Storage** is for what belongs to the reader alone: which columns they keep, how they ordered
them, how dense they like their rows. Nobody wants those in a shared link.

### Reading state from a link

The table reads the URL on mount, so a link with `?tk.q=ava&tk.sort=age.desc` opens already
searched and sorted. The prefix keeps the parameters out of the way of the rest of your query
string.

### Storage that outlives a schema change

Stored layouts fail badly when the columns change under them. A `version` and a `migrate`
function turn that into a handled case:

```tsx
storage: {
  key: 'people-table',
  version: 2,
  migrate: (old, version) => (version < 2 ? { ...old, columnOrder: undefined } : old),
}
```

Without a migration, a stored payload from an older version is discarded rather than applied:
the table would rather start fresh than render a layout that no longer exists.

### Working with your router

`useRouterSync` connects the URL sync to a router's own history (React Router, Next.js), so a
filter change is a navigation your application knows about rather than a `history.replaceState`
behind its back. See [React Router](/react-tablekit/integrations/react-router/).

## Customization

`useSyncState` is exported for state that is not the table's: a page's own tab, a sidebar's
collapsed flag, kept in the same place with the same encoding. `syncState.storage.storage`
accepts any `Storage`-shaped object, which is how you point it at `sessionStorage` or a memory
stub in tests.

## Limitations

- **URLs have a practical length limit.** A multi-select with fifty values will produce an ugly
  link; keep large filter sets in storage or on the server.
- **Storage is per browser, not per user.** A reader who switches machines starts over; persist
  server-side if the layout must follow the account.
- **A stored page index can point at nothing.** The table clamps it to the last available page
  rather than showing an empty body.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `syncState`, `initialState`,
  `state`, `onStateChange`.
- [Hooks](/react-tablekit/api/hooks/): `useSyncState`, `useRouterSync`, `useTableState`.
- [`TableInstance`](/react-tablekit/api/table-instance/): `getState`, `setState`, `reset`.

---

# Headless

> The engine without the markup: every feature, none of the table. Use it for cards, lists, charts, or a table you draw yourself.

URL: https://react-tablekit.vercel.app/react-tablekit/headless/

## Basics

**Example: Headless** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/headless/demo-basics)

```tsx
import { useDataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(90);

/**
 * No table markup at all. `useDataTable` is the whole engine (searching, sorting,
 * selection and pagination), and it renders nothing, so the rows can become anything. Here they
 * are a grid of cards.
 *
 * Note what this example does *not* do: it does not reuse the library's cell components. Those
 * are part of the table's own rendering and need its context. Headless means reading values off
 * the instance and deciding the presentation yourself. Here, the column's `format` is applied
 * so money and dates still read the way they do in a table.
 */
export default function HeadlessExample() {
  const table = useDataTable<DemoPerson>({
    'aria-label': 'People',
    data,
    columns: peopleColumns,
    getRowId: (p) => p.id,
    enableRowSelection: true,
    initialState: { pagination: { pageIndex: 0, pageSize: 9 } },
  });

  const { globalFilter, pagination } = table.getState();
  const rows = table.getRowModel().rows;
  const pageCount = table.getPageCount();

  // A card has no header row, so each field carries its own label.
  const labels = new Map(
    table.getLeafHeaders().map((header) => {
      const heading = header.column.columnDef.header;
      return [header.column.id, typeof heading === 'string' ? heading : header.column.id];
    }),
  );

  return (
    <div className="example-stack">
      <label className="site-field">
        <span>Search</span>
        <input
          type="search"
          value={globalFilter}
          placeholder="Name, email, city…"
          onChange={(e) => table.setGlobalFilter(e.target.value)}
        />
      </label>

      <p className="site-muted" aria-live="polite">
        {table.getRowCount()} people
      </p>

      <ul className="headless-cards">
        {rows.map((row) => (
          <li key={row.id}>
            <article className="headless-card" data-selected={row.getIsSelected() || undefined}>
              <label className="headless-card__select">
                <input
                  type="checkbox"
                  checked={row.getIsSelected()}
                  onChange={() => {
                    row.toggleSelected();
                  }}
                />
                <span className="site-visually-hidden">Select this person</span>
              </label>
              <dl>
                {row.getVisibleCells().map((cell) => {
                  const value: unknown = cell.getValue();
                  const format = cell.column.columnDef.format as
                    ((v: never, row: DemoPerson) => string) | undefined;
                  // Without a formatter only a primitive is safe to print: anything else would
                  // stringify as "[object Object]", so it reads as missing instead.
                  const printable =
                    (typeof value === 'string' && value !== '') ||
                    typeof value === 'number' ||
                    typeof value === 'boolean';
                  const text = format
                    ? format(value as never, row.original)
                    : printable
                      ? String(value)
                      : '—';
                  return (
                    <div key={cell.id}>
                      <dt>{labels.get(cell.column.id)}</dt>
                      <dd>{text}</dd>
                    </div>
                  );
                })}
              </dl>
            </article>
          </li>
        ))}
        {rows.length === 0 && <li className="site-muted">Nobody matches that search.</li>}
      </ul>

      <nav className="example-controls" aria-label="Pagination">
        <button
          type="button"
          onClick={() => {
            table.setPageIndex((i) => i - 1);
          }}
          disabled={pagination.pageIndex === 0}
        >
          ← Previous
        </button>
        <span>
          Page {pagination.pageIndex + 1} of {Math.max(1, pageCount)}
        </span>
        <button
          type="button"
          onClick={() => {
            table.setPageIndex((i) => i + 1);
          }}
          disabled={pagination.pageIndex >= pageCount - 1}
        >
          Next →
        </button>
      </nav>
    </div>
  );
}
```

The features live in a core that knows nothing about the DOM. `useDataTable` gives you that core
as a React hook, and what you render is entirely yours:

```tsx
const table = useDataTable({ data, columns, getRowId: (p) => p.id });

return (
  <ul>
    {table.getRowModel().rows.map((row) => (
      <li key={row.id}>{row.getValue('name')}</li>
    ))}
  </ul>
);
```

Sorting, filtering, search, pagination, grouping, selection and faceting all work; they are
computations over rows, not decorations on a `table` element. Only the rendering is missing,
because you are supplying it.

### When this is the right shape

A list of cards with a sort control. A chart that has to honour the same filters as the table
beside it. A virtualized feed. A table whose markup is dictated by a design system that will not
accept a component. In all of them the hard part is the data pipeline, and that is exactly what
this gives you.

### Outside React

`createTable` is the same engine as a plain object, with `subscribe` for change notification. It
has no React dependency, which makes it usable in a test, a worker, or another framework's
adapter.

### Meeting in the middle

You do not have to choose all or nothing. `DataTable.Root` plus the composable parts renders the
library's markup where you want it and yours everywhere else; see
[Toolbar](/react-tablekit/toolbar/). `useVirtualRows`, `useBreakpoint` and `useSyncState` are all
exported for the same reason.

## Customization

There is nothing to customize here; that is the point. What the styled table exposes as
[slots](/react-tablekit/customization/slots/) and
[handlers](/react-tablekit/customization/handlers/), the headless form exposes as ordinary
function calls on the instance.

## Limitations

- **Accessibility becomes yours.** The roles, `aria-sort`, the live-region announcements and the
  keyboard model are in the rendering layer you are replacing.
- **The stylesheet is not involved**, so [tokens](/react-tablekit/customization/tokens/) and
  presets do nothing until you use them.
- **`flexRender` needs table context.** In a headless tree, read `cell.getValue()` and apply the
  column's `format` yourself rather than rendering cell contexts outside the table.

## API

- [Hooks](/react-tablekit/api/hooks/): `useDataTable`, `useDataSource`, `useTableState`,
  `useVirtualRows`, `useBreakpoint`, `useSyncState`.
- [Utilities](/react-tablekit/api/utilities/): `createTable`, `createColumnHelper`,
  `createLocalDataSource`, `createRestDataSource`.
- [`TableInstance`](/react-tablekit/api/table-instance/): everything the engine exposes.

---

# Localization

> Every string the table can show, in one object, with number, date and currency formatting that follows the same locale.

URL: https://react-tablekit.vercel.app/react-tablekit/localization/

## Basics

**Example: Localization** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/features/localization/demo-basics)

```tsx
import { useState } from 'react';
import { DataTable, defaultLocalization, type TableLocalization } from '@kingtech/react-tablekit';
import de from '@kingtech/react-tablekit/locales/de';
import es from '@kingtech/react-tablekit/locales/es';
import hu from '@kingtech/react-tablekit/locales/hu';
import { generatePeople } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(80);

const LOCALES: Record<string, { label: string; localization: TableLocalization; locale: string }> =
  {
    en: { label: 'English', localization: defaultLocalization, locale: 'en-US' },
    hu: { label: 'Magyar', localization: hu, locale: 'hu-HU' },
    de: { label: 'Deutsch', localization: de, locale: 'de-DE' },
    es: { label: 'Español', localization: es, locale: 'es-ES' },
  };

/**
 * Localization: every user-visible string comes from a `TableLocalization` object, and
 * `locale` drives the `Intl` formatters (number, date and the row range). Locale packs ship as
 * subpath exports, so you only bundle the ones you import.
 *
 * For i18next or similar, pass your own object built from `t()` calls instead of a pack.
 */
export default function LocalizationExample() {
  const [lang, setLang] = useState('hu');
  const current = LOCALES[lang]!;

  return (
    <div className="example-stack">
      <div className="example-controls">
        <label>
          <span>Language</span>
          <select value={lang} onChange={(e) => setLang(e.target.value)}>
            {Object.entries(LOCALES).map(([code, l]) => (
              <option key={code} value={code}>
                {l.label}
              </option>
            ))}
          </select>
        </label>
        <p className="site-muted">
          Open the toolbar menus, sort a column and page through: labels, announcements, the row
          range and the date and number formats all follow the selection.
        </p>
      </div>
      <DataTable
        key={lang}
        aria-label={current.label}
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        localization={current.localization}
        locale={current.locale}
        enableHiding
        enableDensityToggle
        enableExport
        showActiveFilterChips
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
    </div>
  );
}
```

No English is hard-coded. Every label, placeholder, menu entry, announcement and empty-state
message comes from the `localization` object, and `locale` drives the formatting that goes with
it.

```tsx
import { hu } from '@kingtech/react-tablekit/locales/hu';

<DataTable locale="hu-HU" localization={hu} data={rows} columns={columns} />;
```

`locale` is what `Intl` receives: numbers, currencies, dates and the collator that orders
`text`-sorted columns all follow it. Setting the strings without the locale gives you translated
labels beside American dates, which is worse than either.

### Changing a few strings

The object is merged over the defaults, so overriding one label does not mean supplying all of
them:

```tsx
<DataTable localization={{ searchPlaceholder: 'Find a customer…' }} />
```

That is also how you adjust wording for a domain (_records_ instead of _rows_) without a
translation file.

### Values with a count

A string that depends on a number can be a function instead of a `{count}` template. It receives
the same variables, which is what lets a language with several plural forms be correct:

```tsx
localization={{
  selectedCount: ({ count }) => (count === 1 ? '1 kiválasztva' : `${count} kiválasztva`),
}}
```

### With an i18n library

The object can be built from your existing catalogue, so the table's strings live where the rest
of your application's strings live. See [i18next](/react-tablekit/integrations/i18next/).

## Customization

Every key is listed on [Localization keys](/react-tablekit/api/localization-keys/) with its
English default, so a translation can be checked against a complete list rather than discovered
by clicking around.

`formatters` handles the values rather than the chrome (the row range, numbers, dates and
currencies) and takes the same locale.

Right-to-left needs `dir="rtl"`; the layout uses logical properties, so pinning sides and
alignment flip on their own.

## Limitations

- **The bundled locales are a starting point**, not a guarantee of tone. Review them before
  shipping to native speakers.
- **`locale` does not change the data.** A date stored as a string is formatted only if the column
  is a `date` column or has a `format`.
- **One locale per table.** A page showing two locales needs two tables; the state is per
  instance.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `locale`, `localization`,
  `formatters`, `dir`, `icons`.
- [Localization keys](/react-tablekit/api/localization-keys/): every key and its default.
- [Icons](/react-tablekit/api/icons/): the icon set, replaceable the same way.

---

# Demos

> Complete application screens, rather than one feature at a time.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/

Every other page on this site shows one capability in isolation. These four show the table doing a real job, with the dense `classic` preset, server-backed data and a mock API that has latency and can be made to fail.

They are also the pages whose pixels are locked: each has a visual regression baseline at 1440px and 390px, so an accidental layout change fails the build.

- [Account list](/react-tablekit/demos/account-list/): server pagination, search in the page header, pinned row actions.
- [Asset list](/react-tablekit/demos/asset-list/): a chip list with overflow, clickable chips inside cells.
- [Reading history](/react-tablekit/demos/reading-history/): 18 columns, horizontal scroll, per-row disabled actions.
- [Asset picker](/react-tablekit/demos/asset-picker/): single selection inside a dialog, sticky header in a bounded scroll.

Two interactive tools sit alongside them:

- [Playground](/react-tablekit/demos/playground/): every prop as a control, with the code it produces.
- [Theme editor](/react-tablekit/demos/theme-editor/): edit tokens live and export a theme.

---

# Account list

> A server-mode list screen: search in the page header, debounced requests, numbered pages, and the loading, refetch and empty states that go with them.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/account-list/

**Example: Account list** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/demos/account-list/demo-basics)

```tsx
import { useEffect, useRef, useState } from 'react';
import { DataTable, useDataTable } from '@kingtech/react-tablekit';
import { formatAccountName } from '@/mock/data/accounts';
import type { Account } from '@/mock/types';
import { accountsDataSource } from '@/demo-support/api';
import { ShowcasePage, useShowcaseTheme } from '@/demo-support/ShowcaseFrame';
import { accountColumns, type AccountListMeta } from './basics-columns';

/**
 * A realistic application screen: server mode against the mock `/api/accounts/search`, with the
 * search lifted into the page header (300ms debounce, minimum 3 characters, page reset, Ctrl+K),
 * numbered pagination, the initial loading row, the refetch overlay and an empty state.
 */
export default function ShowcaseAccountList() {
  const { theme, colorScheme, toggle } = useShowcaseTheme();
  const [editing, setEditing] = useState<Account | null>(null);
  const meta: AccountListMeta = { onEditAccount: setEditing };

  const table = useDataTable<Account>({
    columns: accountColumns,
    dataSource: accountsDataSource,
    getRowId: (a) => a.identifiers.id,
    enableSorting: false,
    searchMinLength: 3,
    searchDebounceMs: 300,
    searchHotkey: 'mod+k',
    initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
    theme,
    colorScheme,
    meta,
    toolbar: false,
    'aria-label': 'account list table',
    emptyStateContent: 'No accounts found',
    localization: {
      loading: 'Loading...',
      searchPlaceholder: 'Search by name, email, phone or address',
    },
  });

  return (
    <ShowcasePage>
      {toggle}
      <DataTable.Root table={table}>
        <header className="showcase-header">
          <h2 className="showcase-title">All accounts</h2>
          <DataTable.Search />
          <button type="button" className="showcase-primary-button">
            Add account
          </button>
        </header>
        <div className="showcase-content">
          <DataTable.Container />
          <DataTable.Pagination />
        </div>
      </DataTable.Root>
      <EditDialog account={editing} onClose={() => setEditing(null)} />
    </ShowcasePage>
  );
}

function EditDialog({ account, onClose }: { account: Account | null; onClose: () => void }) {
  const ref = useRef<HTMLDialogElement | null>(null);
  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (account && !dialog.open) dialog.showModal();
    if (!account && dialog.open) dialog.close();
  }, [account]);
  return (
    <dialog
      ref={ref}
      className="showcase-dialog"
      aria-labelledby="edit-account-title"
      onClose={onClose}
    >
      <h2 id="edit-account-title">Edit {account ? formatAccountName(account) : ''}</h2>
      <p>A row action opens your own UI; this dialog stands in for an edit form.</p>
      <button type="button" onClick={onClose}>
        Close
      </button>
    </dialog>
  );
}
```

## What it shows

This is the shape most application screens take: one server-backed list, searched from the page
header rather than from inside the table, paged ten at a time.

- **The search lives outside the table.** `<DataTable.Search />` sits in the page header and the
  toolbar is switched off entirely, which is what [composable
  parts](/react-tablekit/toolbar/) are for. The table still owns the state.
- **The request is gated.** A three-character minimum and a 300ms debounce turn typing into one
  request, and a new search resets to page 1 rather than leaving the reader on page 4 of
  something else.
- **All three data states appear.** The first load shows a loading row, a refetch dims the rows
  already on screen instead of blanking them, and a search with no results gets an empty state.
- **Ctrl+K focuses the box**, scoped to this table.

## How it is built

```tsx
const table = useDataTable<Account>({
  columns: accountColumns,
  dataSource: accountsDataSource,
  getRowId: (a) => a.identifiers.id,
  searchMinLength: 3,
  searchDebounceMs: 300,
  searchHotkey: 'mod+k',
  toolbar: false,
});
```

Everything else is rendering: `DataTable.Root` wraps the page, `DataTable.Search` goes in the
header and `DataTable.Table` and `DataTable.Pagination` go where the design says.

The theme switch above the table is part of the demo, not the library: it swaps the `classic`
preset for `light` so you can see the same screen in two designs.

## Related

- [Client and server](/react-tablekit/client-and-server/): the data source behind it.
- [Global search](/react-tablekit/global-search/): debouncing, gating and the hotkey.
- [Loading and empty states](/react-tablekit/loading-and-empty-states/): the three states.
- [Asset list](/react-tablekit/demos/asset-list/): the same screen with interactive cells.

---

# Asset list

> Interactive content inside a cell: a chip list that collapses past three items, where each chip is a button and the row click still behaves.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/asset-list/

**Example: Asset list** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/demos/asset-list/demo-basics)

```tsx
import { useEffect, useRef, useState } from 'react';
import { DataTable, useDataTable } from '@kingtech/react-tablekit';
import { formatAccountName } from '@/mock/data/accounts';
import type { Account, Asset } from '@/mock/types';
import { accountsDataSource } from '@/demo-support/api';
import { ShowcasePage, useShowcaseTheme } from '@/demo-support/ShowcaseFrame';
import { assetLabel, assetListColumns, type AssetListMeta } from './basics-columns';

/**
 * The same server-mode account search, with a chip list in the widest column: at most three
 * assets are shown and the rest collapse into a `+N` chip. Each chip is a button, which is the
 * point: a cell can hold interactive content without the row swallowing the click.
 */
export default function ShowcaseAssetList() {
  const { theme, colorScheme, toggle } = useShowcaseTheme();
  const [inspecting, setInspecting] = useState<Account | null>(null);
  const [selected, setSelected] = useState<Asset | null>(null);
  const meta: AssetListMeta = { onInspect: setInspecting, onSelectAsset: setSelected };

  const table = useDataTable<Account>({
    columns: assetListColumns,
    dataSource: accountsDataSource,
    getRowId: (a) => a.identifiers.id,
    enableSorting: false,
    searchMinLength: 3,
    searchDebounceMs: 300,
    searchHotkey: 'mod+k',
    initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
    theme,
    colorScheme,
    meta,
    toolbar: false,
    'aria-label': 'asset list table',
    emptyStateContent: 'No accounts found',
    localization: {
      loading: 'Loading...',
      searchPlaceholder: 'Search by name, email, phone or address',
    },
  });

  return (
    <ShowcasePage>
      {toggle}
      <DataTable.Root table={table}>
        <header className="showcase-header">
          <h2 className="showcase-title">Assets by account</h2>
          <DataTable.Search />
        </header>
        <div className="showcase-content">
          <DataTable.Container />
          <DataTable.Pagination />
        </div>
      </DataTable.Root>
      <InfoDialog
        title={inspecting ? `Inspect ${formatAccountName(inspecting)}` : ''}
        body="The row action opens your own UI. The asset picker example shows a selection table in a dialog."
        open={inspecting !== null}
        onClose={() => setInspecting(null)}
      />
      <InfoDialog
        title={selected ? assetLabel(selected) : ''}
        body={
          selected
            ? `${selected.kind} · ${selected.capacity.toLocaleString('en-US')} units/h${
                selected.powerSource ? ` · ${selected.powerSource}` : ''
              }`
            : ''
        }
        open={selected !== null}
        onClose={() => setSelected(null)}
      />
    </ShowcasePage>
  );
}

function InfoDialog({
  title,
  body,
  open,
  onClose,
}: {
  title: string;
  body: string;
  open: boolean;
  onClose: () => void;
}) {
  const ref = useRef<HTMLDialogElement | null>(null);
  useEffect(() => {
    const dialog = ref.current;
    if (!dialog) return;
    if (open && !dialog.open) dialog.showModal();
    if (!open && dialog.open) dialog.close();
  }, [open]);
  return (
    <dialog ref={ref} className="showcase-dialog" aria-label={title || 'Details'} onClose={onClose}>
      <h2>{title}</h2>
      <p>{body}</p>
      <button type="button" onClick={onClose}>
        Close
      </button>
    </dialog>
  );
}
```

## What it shows

The same server-backed account search as the [account list](/react-tablekit/demos/account-list/),
with one column doing something harder: showing a variable number of related items without
letting the row grow.

- **Three chips and a count.** The cell renders up to three assets and collapses the rest into a
  `+N` chip; the row's Inspect action opens the full list. The row height stays predictable
  however many assets an account has.
- **Each chip is a button.** Clicking one selects that asset and does _not_ trigger the row's own
  click handler. Interactive descendants are excluded from row clicks, so a cell can hold
  controls without fighting the row.
- **The widest column takes a share, with a floor.** It gets 40% of the width and never less than
  200px, so the identifying columns keep theirs.

## How it is built

The whole behaviour is one `cell` renderer:

```tsx
col.accessor((a) => getAccountAssets(a), {
  id: 'assets',
  header: 'Assets',
  width: '40%',
  minWidth: '200px',
  cell: ({ getValue, table }) => (
    <ChipList
      items={getValue()}
      maxVisible={3}
      renderChip={(asset) => (
        <button type="button" onClick={() => meta(table).onSelectAsset(asset)}>
          {assetLabel(asset)}
        </button>
      )}
    />
  ),
});
```

`meta` is how a cell reaches the screen around it. The column stays a pure renderer and the page
supplies the callbacks, which is what keeps the column reusable across screens.

## Related

- [Columns](/react-tablekit/columns/): accessors, `cell` and column widths.
- [Rows and cells](/react-tablekit/customization/rows-and-cells/): decorating without replacing.
- [Cell building blocks](/react-tablekit/customization/cell-building-blocks/): the primitives
  these chips are made of.
- [Row selection](/react-tablekit/row-selection/): the same interactive-descendant rule.

---

# Reading history

> A server-sorted time series against a REST endpoint whose parameter names are nothing like the table's, mapped in one place.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/reading-history/

**Example: Reading history** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/demos/reading-history/demo-basics)

```tsx
import { useState } from 'react';
import {
  createRestDataSource,
  DataTable,
  useDataTable,
  type DataSource,
} from '@kingtech/react-tablekit';
import { mockFetch } from '@/mock/ready';
import type { PagedResponse, Reading } from '@/mock/types';
import { ShowcasePage, useShowcaseTheme } from '@/demo-support/ShowcaseFrame';
import { readingColumns, type ReadingsMeta } from './basics-columns';

const ASSET_ID = 'as_0001';

/** `GET /api/assets/:id/readings` with the listing criteria as query parameters. */
const readingsDataSource: DataSource<Reading> = createRestDataSource<
  Reading,
  PagedResponse<Reading>
>({
  url: `/api/assets/${ASSET_ID}/readings`,
  method: 'GET',
  mapQuery: (q) => ({
    pageNumber: q.pagination.pageIndex,
    pageSize: q.pagination.pageSize,
    ...(q.sorting[0] ? { sortColumn: q.sorting[0].id, ascending: !q.sorting[0].desc } : {}),
  }),
  mapResult: (r) => ({
    rows: r.items,
    rowCount: r.totalItemCount,
    query: {
      pagination: {
        pageIndex: r.requestCriteria.pageNumber,
        pageSize: r.requestCriteria.pageSize,
      },
    },
  }),
  // The mock server is a service worker, so requests go through `mockFetch`; a GET carries the
  // mapped query as search params.
  fetcher: async (req) => {
    const params = new URLSearchParams(
      Object.entries(req.body as Record<string, string | number | boolean>).map(([k, v]) => [
        k,
        String(v),
      ]),
    );
    const res = await mockFetch(`${req.url}?${params.toString()}`, {
      method: req.method,
      signal: req.signal,
    });
    if (!res.ok) throw new Error(`Request failed: ${res.status}`);
    return (await res.json()) as PagedResponse<Reading>;
  },
});

/**
 * A deliberately wide table: 18 columns inside a horizontal scroll, sorted on the server, with
 * pinned right actions whose disabled states follow the row: "Resend" needs a report id and
 * "View report" needs a URL, and plenty of rows have neither.
 */
export default function ShowcaseReadings() {
  const { theme, colorScheme, toggle } = useShowcaseTheme();
  const [message, setMessage] = useState<string | null>(null);
  const meta: ReadingsMeta = {
    onResend: (r) => setMessage(`Resending the report of ${r.date}…`),
    onViewReport: (r) => setMessage(`Opening ${r.reportUrl ?? 'the report'}…`),
  };

  const table = useDataTable<Reading>({
    columns: readingColumns,
    dataSource: readingsDataSource,
    getRowId: (r) => r.id,
    sortingMode: 'server',
    initialState: { pagination: { pageIndex: 0, pageSize: 10 } },
    theme,
    colorScheme,
    meta,
    toolbar: false,
    enableGlobalFilter: false,
    'aria-label': 'reading history table',
    emptyStateContent: 'No readings found',
    localization: { loading: 'Loading...' },
  });

  return (
    <ShowcasePage>
      {toggle}
      <DataTable.Root table={table}>
        <header className="showcase-header">
          <h2 className="showcase-title">Reading history</h2>
        </header>
        <div className="showcase-content">
          <DataTable.Container />
          <DataTable.Pagination />
        </div>
      </DataTable.Root>
      <p className="site-muted" role="status">
        {message ?? 'Pick an action on any row.'}
      </p>
    </ShowcasePage>
  );
}
```

## What it shows

A paged, sorted history from an endpoint that was not designed around this table, which is the
normal case. The API wants `pageNumber`, `pageSize`, `sortColumn` and `ascending`; the table
produces a `TableQuery`. One adapter reconciles them.

- **Sorting happens on the server.** Clicking a header sends a new request, not a local
  reordering; this is a page of a much longer series.
- **The API's names stay the API's names.** `mapQuery` translates outward and `mapResult`
  translates back, so nothing else in the code has to know about `totalItemCount`.
- **The response carries the truth about paging.** The server echoes the criteria it actually
  applied, and the table adopts that rather than assuming its own request was honoured.

## How it is built

```tsx
const readingsDataSource = createRestDataSource<Reading, PagedResponse<Reading>>({
  url: `/api/assets/${ASSET_ID}/readings`,
  method: 'GET',
  mapQuery: (q) => ({
    pageNumber: q.pagination.pageIndex,
    pageSize: q.pagination.pageSize,
    ...(q.sorting[0] ? { sortColumn: q.sorting[0].id, ascending: !q.sorting[0].desc } : {}),
  }),
  mapResult: (r) => ({ rows: r.items, rowCount: r.totalItemCount }),
});
```

That object is the entire integration. The table passes it `dataSource` and everything else
(loading, errors, refetch, deduplication) is handled.

Column ids are the sort keys the server receives, which is why `sortServerKey` exists for the
cases where they should differ.

## Related

- [Client and server](/react-tablekit/client-and-server/): the query and the data source.
- [Sorting](/react-tablekit/sorting/): server sorting and `sortServerKey`.
- [Pagination](/react-tablekit/pagination/): page sizes, totals and unknown counts.
- [Server data](/react-tablekit/guides/server-data/): the longer version of this pattern.

---

# Asset picker

> A table inside a dialog: single selection with radio semantics, row-click to choose, and a client-side dataset small enough to filter instantly.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/asset-picker/

**Example: Asset picker** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/demos/asset-picker/demo-basics)

```tsx
import { useMemo, useState } from 'react';
import {
  ActionButton,
  createColumnHelper,
  DataTable,
  TruncatedText,
} from '@kingtech/react-tablekit';
import { generateAccounts, getAccountAssets } from '@/mock/data/accounts';
import type { Asset } from '@/mock/types';
import { PenIcon } from '@/demo-support/icons';
import { ShowcasePage, useShowcaseTheme } from '@/demo-support/ShowcaseFrame';

const col = createColumnHelper<Asset>();
const units = new Intl.NumberFormat('en-US');
const dateFormat = new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: 'short',
  day: 'numeric',
});
const dash = (v: string | undefined) => v ?? '-';

/** Everything is sortable except the notes and the actions. */
const columns = [
  col.accessor('kind', { header: 'Kind', width: '10%', minWidth: '80px' }),
  col.accessor('capacity', {
    header: 'Capacity',
    type: 'number',
    width: '10%',
    minWidth: '96px',
    format: (v) => `${units.format(v)} u/h`,
  }),
  col.accessor('enclosure', {
    header: 'Enclosure',
    width: '15%',
    minWidth: '128px',
    format: dash,
  }),
  col.accessor('powerSource', {
    header: 'Power source',
    width: '15%',
    minWidth: '128px',
    format: dash,
  }),
  col.accessor('tier', { header: 'Tier', width: '15%', minWidth: '128px', format: dash }),
  col.accessor('placement', { header: 'Placement', width: '10%', minWidth: '96px', format: dash }),
  col.accessor('mounting', { header: 'Mounting', width: '15%', minWidth: '128px', format: dash }),
  col.accessor('coolingType', { header: 'Cooling', width: '10%', minWidth: '96px', format: dash }),
  col.accessor('installedAtUTC', {
    header: 'Installed',
    type: 'date',
    width: '15%',
    minWidth: '128px',
    format: (v) => (v ? dateFormat.format(new Date(v)) : '-'),
  }),
  col.accessor('vendor', { header: 'Vendor', width: '15%', minWidth: '128px', format: dash }),
  col.accessor('notes', {
    header: 'Notes',
    width: '15%',
    minWidth: '160px',
    enableSorting: false,
    cell: ({ getValue }) => <TruncatedText text={getValue()} maxChars={30} />,
  }),
  col.display({
    id: 'actions',
    header: 'Actions',
    align: 'right',
    width: '5%',
    minWidth: '48px',
    pin: 'right',
    static: true,
    enableSorting: false,
    cell: () => <ActionButton icon={<PenIcon />} label="Edit" onClick={() => undefined} />,
  }),
];

/**
 * A picker, as it would appear inside a dialog: client mode throughout (client sorting and
 * paging), with single selection by row click or radio, and a sticky header inside a bounded
 * 400px scroll area so the column labels stay visible while you scan.
 */
export default function ShowcaseAssetPicker() {
  const { theme, colorScheme, toggle } = useShowcaseTheme();
  const [selected, setSelected] = useState<Record<string, boolean>>({});
  const rows = useMemo(() => generateAccounts(40).flatMap((a) => getAccountAssets(a)), []);
  const selectedId = Object.keys(selected).find((id) => selected[id]);

  return (
    <ShowcasePage>
      {toggle}
      <h2 className="showcase-title">Select an asset</h2>
      <DataTable<Asset>
        aria-label="asset selection table"
        data={rows}
        columns={columns}
        getRowId={(a) => a.id}
        theme={theme}
        colorScheme={colorScheme}
        toolbar={false}
        enableRowSelection
        enableMultiRowSelection={false}
        selectOnRowClick
        state={{ rowSelection: selected }}
        onRowSelectionChange={(updater) =>
          setSelected((prev) => (typeof updater === 'function' ? updater(prev) : updater))
        }
        enableStickyHeader
        maxHeight={400}
        initialState={{ pagination: { pageIndex: 0, pageSize: 10 } }}
      />
      <p className="site-muted" role="status">
        {selectedId ? `Selected: ${selectedId}` : 'Nothing selected yet.'}
      </p>
    </ShowcasePage>
  );
}
```

## What it shows

The other kind of table: not a screen, but a control inside a dialog whose job is to return one
value.

- **Single selection.** `enableMultiRowSelection: false` turns the checkboxes into radios and
  removes the header checkbox; there is no "select all" in a picker.
- **The row is the target.** `selectOnRowClick` means the reader does not have to hit a 16-pixel
  radio, while the edit button inside the row still works because interactive descendants are
  excluded.
- **Client mode, deliberately.** The candidate list is small, so it is loaded once and filtered in
  the browser: no spinner between keystrokes, which matters much more in a dialog than on a page.
- **Compact by default.** A dialog is short, so the table starts dense and pages in small chunks.

## How it is built

```tsx
<DataTable
  data={assets}
  columns={columns}
  getRowId={(a) => a.id}
  enableRowSelection
  enableMultiRowSelection={false}
  selectOnRowClick
  showSelectionBar={false}
  initialState={{ density: 'compact', pagination: { pageIndex: 0, pageSize: 8 } }}
  onRowSelectionChange={(selection) => setChosen(firstId(selection))}
/>
```

The selection bar is off because the dialog's own footer holds the confirm and cancel buttons,
and two places to act on a selection is one too many.

## Related

- [Row selection](/react-tablekit/row-selection/): single, multiple and row-click selection.
- [Density](/react-tablekit/density/): why a dialog starts compact.
- [Client and server](/react-tablekit/client-and-server/): when loading everything is the right
  call.
- [Cell building blocks](/react-tablekit/customization/cell-building-blocks/): the action button
  and truncated text used here.

---

# Playground

> Every prop of DataTable, generated from the types, with a live table and the code for whatever you have configured.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/playground/

The controls below are generated from `DataTableProps` itself, so the playground cannot drift from
the component: a prop added to the type appears here, and one that is removed disappears. Change
anything and the table re-renders with it; the code panel shows exactly the props you have set.

_The playground is interactive: it runs in the browser at https://react-tablekit.vercel.app/react-tablekit/demos/playground/ and has no Markdown form._

## What to try

- Turn on **grouping** and then **column footers**, and switch `footerAggregationScope` between
  `page` and `filtered` to see which total you actually want.
- Set **pagination type** to `loadMore` or `infinite` and watch the rows accumulate rather than
  replace.
- Switch **density** and **striping** together: the whole visual system is tokens, so nothing
  reflows.
- Turn off **rows per page** and see the control disappear while paging still works.

## Related

- [`DataTableProps`](/react-tablekit/api/data-table-props/): the same props, with their types and
  documentation.
- [Theme editor](/react-tablekit/demos/theme-editor/): the same idea for the visual tokens.
- [All features](/react-tablekit/all-features/): a page per capability, each with its own demo.

---

# Theme editor

> Change any design token and watch the table follow, then copy the CSS or the theme object into your own project.

URL: https://react-tablekit.vercel.app/react-tablekit/demos/theme-editor/

Every visual value in the table is a CSS custom property. Change one here and the table below
updates immediately: no rebuild, no class names, no specificity fight. When you are happy, copy
the result out as CSS or as a theme object.

_The theme editor is interactive: it runs in the browser at https://react-tablekit.vercel.app/react-tablekit/demos/theme-editor/ and has no Markdown form._

## What to try

- Change `--tk-color-accent` and notice how far it reaches: the focus ring, the sort indicator,
  the selected row, the spinner.
- Adjust the cell padding tokens and see that this is all `compact` density really is.
- Set `--tk-pinned-shadow` to something visible, then scroll a wide table sideways.
- Switch the preset and start again from a different set of defaults.

## Related

- [Theming](/react-tablekit/customization/theming/): presets, overrides and dark mode.
- [Design tokens](/react-tablekit/customization/tokens/): what each token controls.
- [Theme tokens](/react-tablekit/api/theme-tokens/): the complete generated list.

---

# API reference

> Generated reference for every exported symbol, prop, slot, handler, token and key.

URL: https://react-tablekit.vercel.app/react-tablekit/api/

Everything in this section is **generated** from the library's own TypeScript declarations and runtime metadata. Nothing here is hand-maintained, which is why it cannot drift from the code.

## Props and types

- [DataTableProps](/react-tablekit/api/data-table-props/): every prop of the all-in-one component.
- [ColumnDef](/react-tablekit/api/column-def/): the shape of a column.
- [TableInstance](/react-tablekit/api/table-instance/): the headless instance and its row, column, cell and header objects.
- [TableState](/react-tablekit/api/table-state/): every state slice and the normalized query sent to a server.

## Functions

- [Hooks](/react-tablekit/api/hooks/): `useDataTable` and the supporting hooks.
- [Utilities](/react-tablekit/api/utilities/): column helper, data sources, CSV export, theming helpers.

## Extension points

- [TableSlots](/react-tablekit/api/slots/): every replaceable component.
- [Handlers](/react-tablekit/api/handlers/): every overridable interaction.
- [Theme tokens](/react-tablekit/api/theme-tokens/): every token, its CSS variable and its value per preset.
- [Localization keys](/react-tablekit/api/localization-keys/): every string, with its English default.
- [Icons](/react-tablekit/api/icons/): every replaceable icon.

---

# DataTableProps

> Every prop of the all-in-one component, the view props shared with DataTable.Root, and the imperative handle.

URL: https://react-tablekit.vercel.app/react-tablekit/api/data-table-props/

These are the props `<DataTable>` accepts. `DataTableViewProps` is the subset that also applies
to `<DataTable.Root>` and the composable parts, and `DataTableHandle` is what a `ref` gives you.

## Reference

### DataTableProps (type)

`<DataTable>` props: engine options + view props.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `_features?` | `TableFeature<TData>[]` |  | Advanced: custom features. |
| `acknowledgePageLocalFiltering?` | `boolean` |  |  |
| `acknowledgePageLocalSorting?` | `boolean` |  |  |
| `aggregationFns?` | `Record<string, AggregationFn<TData>>` |  |  |
| `appendPages?` | `boolean` |  | Append each fetched page to the previous ones instead of replacing them. Inferred from a `loadMore` / `infinite` pagination variant; set it explicitly for a custom UI. |
| `aria-label?` | `string` |  |  |
| `aria-labelledby?` | `string` |  |  |
| `autoResetExpanded?` | `boolean` | ```ts true ``` |  |
| `autoResetPageIndex?` | `boolean` | ```ts true in client mode, false with manual pagination ``` |  |
| `autoResetSelection?` | `boolean` | ```ts false ``` |  |
| `bordered?` | `"all" \| "none" \| "outer" \| "rows"` | ```ts 'outer' + row dividers ``` |  |
| `caption?` | `ReactNode` |  | Visually hidden unless `showCaption`. |
| `className?` | `string` |  |  |
| `classNames?` | `TableClassNames<TData>` |  |  |
| `clearSelectionOnQueryChange?` | `boolean` | ```ts false client / true server ``` |  |
| `colorScheme?` | `"auto" \| "light" \| "dark"` | ```ts 'light' ``` |  |
| `columnDefaults?` | `Partial<ColumnDefBase<TData, unknown>>` |  | Merged into every column. |
| `columnResizeDirection?` | `"ltr" \| "rtl"` | `'ltr'` |  |
| `columnResizeMode?` | `"onChange" \| "onEnd"` | `'onChange'` |  |
| `columns?` | `AnyColumnDef<TData>[]` |  | Column definitions. Required unless `table` is given. |
| `compactPagination?` | `CompactPaginationOptions` |  |  |
| `darkTheme?` | `TableTheme` |  |  |
| `data?` | `TData[]` | ```ts [] ``` | Rows (client mode), or the current page (controlled server mode). |
| `dataMode?` | `DataMode` | `'client'` (`'server'` when `dataSource` is set) |  |
| `dataSource?` | `DataSource<TData>` |  | Server adapter. Implies `dataMode: 'server'`. |
| `datePresets?` | `DatePreset[]` |  |  |
| `debugColumns?` | `boolean` |  |  |
| `debugRows?` | `boolean` |  |  |
| `debugTable?` | `boolean` |  |  |
| `dedupeMs?` | `number` | ```ts 0 (in-flight only) ``` |  |
| `defaultColumn?` | `Partial<ColumnDefBase<TData, unknown>>` |  | Alias of `columnDefaults`. |
| `detailPanelProps?` | `object` |  |  |
| `dir?` | `"ltr" \| "rtl"` |  |  |
| `disablePaginationWhileFetching?` | `boolean` | ```ts true ``` |  |
| `displayColumnDefs?` | `Partial<Record<"select" \| "expand" \| "rowNumber" \| "actions" \| "drag", Partial<ColumnDefBase<TData, unknown>>>>` |  |  |
| `emptyStateContent?` | `ReactNode` |  | Shortcut for a static empty state (e.g. a precondition message). |
| `enableColumnActions?` | `boolean` | ```ts false ``` |  |
| `enableColumnFilters?` | `boolean` | ```ts true ``` |  |
| `enableColumnFooters?` | `boolean` |  |  |
| `enableColumnOrdering?` | `boolean` | ```ts false ``` |  |
| `enableColumnPinning?` | `boolean` | ```ts true ``` |  |
| `enableColumnResizing?` | `boolean` | ```ts false ``` |  |
| `enableDensityToggle?` | `boolean` |  |  |
| `enableExpandAll?` | `boolean` | ```ts true in multiple expand mode ``` |  |
| `enableExpanding?` | `boolean` |  | Auto when `getSubRows`, `renderDetailPanel` or `getRowCanExpand` is set. |
| `enableExport?` | `boolean` |  |  |
| `enableFacetedValues?` | `boolean` | ```ts true ``` |  |
| `enableFilters?` | `boolean` | ```ts true ``` | Master switch for all filtering. |
| `enableGlobalFilter?` | `boolean` | ```ts true ``` |  |
| `enableGrouping?` | `boolean` | ```ts false ``` |  |
| `enableHiding?` | `boolean` | ```ts true ``` |  |
| `enableHover?` | `boolean` | ```ts true (false in classic) ``` |  |
| `enableKeyboardNavigation?` | `boolean` |  |  |
| `enableMultiRowSelection?` | `boolean \| function` | ```ts true ``` | `false` = single selection. |
| `enableMultiSort?` | `boolean` | ```ts true ``` |  |
| `enablePagination?` | `boolean` | ```ts true ``` |  |
| `enableRangeSelection?` | `boolean` | ```ts true ``` |  |
| `enableRowNumbers?` | `boolean` |  |  |
| `enableRowPinning?` | `boolean \| function` | ```ts false ``` |  |
| `enableRowSelection?` | `boolean \| function` | ```ts false ``` |  |
| `enableRowVirtualization?` | `boolean \| "auto"` | ```ts 'auto' ``` |  |
| `enableSorting?` | `boolean` | ```ts true ``` |  |
| `enableSortingRemoval?` | `boolean` | ```ts true ``` |  |
| `enableStickyFooter?` | `boolean` |  |  |
| `enableStickyHeader?` | `boolean` |  |  |
| `enableStriped?` | `boolean` |  |  |
| `enableSubRowSelection?` | `boolean \| function` | ```ts true ``` |  |
| `error?` | `unknown` |  |  |
| `estimateRowHeight?` | `number \| function` | ```ts by density ``` | Row height estimate for virtualization, in px. |
| `expandMode?` | `"multiple" \| "single"` | `'multiple'` |  |
| `expandOnRowClick?` | `boolean` |  |  |
| `exportChunkSize?` | `number` | ```ts 1000 ``` | Rows per chunk for server "export all". |
| `exportFileName?` | `string` |  |  |
| `exportMaxRows?` | `number` | ```ts 10000 ``` | Cap for server "export all". |
| `exportMode?` | `"all" \| "page"` | ```ts 'page' ``` |  |
| `facetingMode?` | `DataMode` |  |  |
| `facets?` | `Record<string, FacetResult>` |  | Server facets in controlled server mode (with `manualFaceting`), by column id. |
| `fetching?` | `boolean` |  | Refetch with existing data (overlay). |
| `fetchOnMount?` | `boolean` | ```ts true ``` |  |
| `filterApplyMode?` | `"instant" \| "manual"` | ```ts 'instant' ``` |  |
| `filterDebounceMs?` | `number` | ```ts 300 ``` |  |
| `filterDisplayMode?` | `"none" \| "popover" \| "row" \| "panel"` | ```ts 'panel' ``` |  |
| `filterFns?` | `Record<string, FilterFn<TData>>` |  |  |
| `filterFromLeafRows?` | `boolean` | ```ts false ``` |  |
| `filterMode?` | `DataMode` |  |  |
| `firstColumnAsRowHeader?` | `boolean` | ```ts true ``` | First data cell rendered as `<th scope="row">`. |
| `footerAggregationScope?` | `"page" \| "filtered"` | `'filtered'` |  |
| `formatters?` | `Partial<TableFormatters>` |  |  |
| `getColumnCanGlobalFilter?` | `function` |  | Decides per column whether the global search looks at it. |
| `getRowCanExpand?` | `function` |  | Decides per row whether it can expand (lazy children that are not loaded yet). |
| `getRowCanSelect?` | `function` |  | Alias of the function form of `enableRowSelection`. |
| `getRowClassName?` | `function` |  | Extra class names per row. |
| `getRowId?` | `function` | ```ts index-based ``` | Strongly recommended; used for keys, selection and expansion. |
| `getRowProps?` | `function` |  | Extra attributes and events for each `<tr>`. |
| `getRowStyle?` | `function` |  | Inline styles per row. |
| `getSubRows?` | `function` |  | Tree data (client mode). |
| `globalFilterFn?` | `FilterFnName \| FilterFn<TData>` | `'includesString'` |  |
| `globalFilterMatch?` | `"all-words" \| "any-word" \| "phrase"` | `'all-words'` |  |
| `groupedColumnMode?` | `false \| "reorder" \| "remove"` | `'reorder'` |  |
| `groupingMode?` | `DataMode` |  |  |
| `handlers?` | `Partial<TableHandlers<TData>>` |  |  |
| `highlightSearchMatches?` | `boolean` | ```ts false ``` | Wraps global-search matches in `<mark class="tk-highlight">` in text cells rendered by the default cell renderer. |
| `icons?` | `Partial<TableIcons>` |  |  |
| `id?` | `string` | ```ts useId() ``` | Base id for aria relationships. |
| `initialState?` | `InitialTableState` |  |  |
| `isMultiSortEvent?` | `function` | ```ts Shift+click ``` | Decides whether a click adds to the sort list. |
| `isRowDisabled?` | `function` |  | Disabled rows are dimmed, not selectable/clickable. |
| `keepPinnedRows?` | `boolean` | ```ts true ``` |  |
| `keepPreviousData?` | `boolean` | ```ts true ``` |  |
| `layout?` | `"table" \| "grid"` | ```ts 'table' (auto 'grid' when virtualized) ``` |  |
| `loading?` | `boolean` |  | Initial load (no data yet). |
| `loadingDisplay?` | `"text" \| "skeleton" \| "spinner"` | ```ts 'skeleton' ('text' in classic) ``` |  |
| `loadingOverlayBlocksInteraction?` | `boolean` | ```ts true ``` |  |
| `loadingOverlayDelayMs?` | `number` | ```ts 150 (0 in classic) ``` |  |
| `locale?` | `string` | `navigator.language` / `'en-US'` | BCP-47 locale for collation/formatting. |
| `localization?` | `Partial<TableLocalization>` |  |  |
| `manualExpanding?` | `boolean` |  |  |
| `manualFaceting?` | `boolean` |  |  |
| `manualFiltering?` | `boolean` |  |  |
| `manualGrouping?` | `boolean` |  |  |
| `manualPagination?` | `boolean` |  |  |
| `manualSorting?` | `boolean` |  |  |
| `maxHeight?` | `string \| number` |  | Scroll container max-height (enables inner scroll). |
| `maxLeafRowFilterDepth?` | `number` | ```ts Infinity ``` |  |
| `maxMultiSortColCount?` | `number` | ```ts 3 ``` |  |
| `maxWidth?` | `ResponsiveValue<string \| number>` | ```ts '100%' ``` |  |
| `mergeOptions?` | `function` |  | Advanced: customizes how new options are merged into the instance on each render. |
| `meta?` | `TableMeta<TData>` |  |  |
| `minWidth?` | `string \| number` | ```ts 650 in classic ``` | Table min-width. |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` | ```ts 'list' ``` |  |
| `noWrap?` | `boolean` | ```ts true ``` | `white-space: nowrap` in cells. |
| `onCellClick?` | `function` |  | Called when a single cell is clicked. |
| `onColumnFiltersChange?` | `OnChangeFn<ColumnFiltersState>` |  |  |
| `onColumnOrderChange?` | `OnChangeFn<ColumnOrderState>` |  |  |
| `onColumnPinningChange?` | `OnChangeFn<ColumnPinningState>` |  |  |
| `onColumnSizingChange?` | `OnChangeFn<ColumnSizingState>` |  |  |
| `onColumnSizingInfoChange?` | `OnChangeFn<ColumnSizingInfoState>` |  |  |
| `onColumnVisibilityChange?` | `OnChangeFn<VisibilityState>` |  |  |
| `onDensityChange?` | `OnChangeFn<Density>` |  |  |
| `onError?` | `function` |  | Called when a data-source request fails, for logging or a toast. |
| `onExpandedChange?` | `OnChangeFn<ExpandedState>` |  |  |
| `onExport?` | `function` |  | Delegates the export to your backend instead of generating CSV in the browser. |
| `onExportFile?` | `function` |  | Takes over saving the generated file (a custom download, a share sheet…). |
| `onGlobalFilterChange?` | `OnChangeFn<string>` |  |  |
| `onGroupingChange?` | `OnChangeFn<GroupingState>` |  |  |
| `onPaginationChange?` | `OnChangeFn<PaginationState>` |  |  |
| `onQueryChange?` | `function` |  | Called whenever the normalized query changes, with the reason. |
| `onRowClick?` | `function` |  | Called when a row is clicked (clicks on interactive content are ignored). |
| `onRowContextMenu?` | `function` |  | Called on a right click / long press, for a custom context menu. |
| `onRowDoubleClick?` | `function` |  | Called on a double click anywhere in the row. |
| `onRowMouseEnter?` | `function` |  | Called when the pointer enters the row. |
| `onRowMouseLeave?` | `function` |  | Called when the pointer leaves the row. |
| `onRowPinningChange?` | `OnChangeFn<RowPinningState>` |  |  |
| `onRowSelectionChange?` | `OnChangeFn<RowSelectionState>` |  |  |
| `onSortingChange?` | `OnChangeFn<SortingState>` |  |  |
| `onStateChange?` | `OnChangeFn<TableState>` |  |  |
| `onStatusChange?` | `function` |  | Called whenever the loading/fetching/error status changes. |
| `overscan?` | `number` | ```ts 8 ``` |  |
| `pageCount?` | `number` |  |  |
| `paginateExpandedRows?` | `boolean` | ```ts true ``` |  |
| `pagination?` | `PaginationDisplayOptions` |  |  |
| `paginationMode?` | `DataMode` |  |  |
| `paginationType?` | `"cursor" \| "offset"` | `'offset'` |  |
| `positionActionsColumn?` | `"first" \| "last"` | ```ts 'last' ``` |  |
| `positionExpandColumn?` | `"first" \| "last" \| "none"` | ```ts 'first' ``` |  |
| `positionSelectionColumn?` | `"first" \| "last"` | ```ts 'first' ``` |  |
| `refetchInterval?` | `number` |  |  |
| `refetchOnWindowFocus?` | `boolean` | ```ts false ``` |  |
| `renderBulkActions?` | `function` |  | Buttons shown in the selection bar while rows are selected. |
| `renderColumnActionsMenuItems?` | `function` |  | Adds to, reorders or replaces the entries of a column's actions menu. |
| `renderDetailPanel?` | `function` |  | Collapsible content rendered in a full-width row under the expanded row. |
| `renderEmptyState?` | `function` |  | Replaces the empty state; `reason` distinguishes "no rows" from "no results". |
| `renderErrorState?` | `function` |  | Replaces the error state shown when loading failed with no rows to fall back on. |
| `renderFallbackValue?` | `Renderable` |  | Table-wide fallback for empty values. |
| `renderRow?` | `function` |  | Replaces or wraps the whole row; call `defaultRender()` to keep the default markup. |
| `renderRowActions?` | `function` |  | Action buttons for each row; they go into an auto-inserted actions column. |
| `renderSubComponent?` | `function` |  | Alias of `renderDetailPanel`. |
| `renderToolbarEnd?` | `function` |  | Replaces the trailing toolbar group (density, columns, export). |
| `renderToolbarStart?` | `function` |  | Replaces the leading toolbar group (search, filters, chips). |
| `resetPageOn?` | `ResetPageReason[]` | ```ts every reason ``` |  |
| `responsive?` | `ResponsiveOptions<TData>` |  |  |
| `rounded?` | `boolean` | ```ts true on a card, false on a plain surface ``` | Round the table's outer corners. The radius itself is the `--tk-radius` token, so this is a switch rather than a measurement. |
| `rowActionsColumn?` | `Partial<ColumnDefBase<TData, unknown>> & object` |  |  |
| `rowCount?` | `number` |  | Total rows in server mode (`-1` = unknown). |
| `rowNumberMode?` | `"absolute" \| "relative"` | ```ts 'absolute' ``` |  |
| `scrollToTopOnPageChange?` | `boolean` | ```ts true ``` |  |
| `searchDebounceMs?` | `number` | ```ts 300 ``` |  |
| `searchHotkey?` | `string \| false` | ```ts false ``` | e.g. `'mod+k'`, scoped to this table. |
| `searchMinLength?` | `number` | ```ts 0 ``` |  |
| `searchMode?` | `DataMode` |  |  |
| `searchPlaceholder?` | `string` |  |  |
| `selectAllMode?` | `"all" \| "page"` | `'page'` |  |
| `selectOnRowClick?` | `boolean` |  |  |
| `showActiveFilterChips?` | `boolean` | ```ts true ``` |  |
| `showCaption?` | `boolean` |  |  |
| `showFacetCounts?` | `boolean` | ```ts true ``` |  |
| `showSearchHotkeyHint?` | `boolean` |  |  |
| `showSelectionBar?` | `boolean` | ```ts true with multi-selection ``` |  |
| `skeletonRowCount?` | `number` | ```ts pageSize ``` |  |
| `slotProps?` | `TableSlotProps<TData>` |  |  |
| `slots?` | `Partial<TableSlots<TData>>` |  |  |
| `sortDescFirst?` | `boolean` | ```ts false ``` |  |
| `sortingFns?` | `Record<string, SortingFn<TData>>` |  |  |
| `sortingMode?` | `DataMode` |  |  |
| `state?` | `Partial<TableState>` |  |  |
| `stickyActions?` | `boolean` |  | Convenience alias: pins the actions column right. |
| `style?` | `CSSProperties` |  |  |
| `styles?` | `TableStyles<TData>` |  |  |
| `surface?` | `"card" \| "plain"` | ```ts 'card' ``` | The surface the table sits on. `'card'` wraps the table and its pagination in one bordered, filled panel. `'plain'` removes that panel: the table stands directly on the page and the pagination becomes a separate block beneath it, which is what you want when the page already provides the surface. |
| `syncState?` | `SyncStateOptions` |  |  |
| `table?` | `TableInstance<TData>` |  | Render an instance created with `useDataTable`. |
| `tableLayout?` | `"auto" \| "fixed"` | ```ts 'auto' ``` |  |
| `tableRef?` | `function` |  | Receives the table instance, for imperative access from outside the component. |
| `theme?` | `TableTheme \| object` |  |  |
| `toolbar?` | `boolean \| "top" \| "bottom" \| "both"` | ```ts 'top' when any toolbar feature is enabled ``` |  |
| `toolbarActions?` | `ReactNode \| function` |  | Custom content for the end of the toolbar (e.g. an "Add customer" button). |
| `treeColumnId?` | `string` |  |  |
| `unstyled?` | `boolean` |  | Drops the visual theme (structural CSS only). |
| `virtualizationThreshold?` | `number` | ```ts 200 ``` |  |

### DataTableViewProps (interface)

View-only props.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `aria-label?` | `string` |  |  |
| `aria-labelledby?` | `string` |  |  |
| `bordered?` | `"all" \| "none" \| "outer" \| "rows"` | ```ts 'outer' + row dividers ``` |  |
| `caption?` | `ReactNode` |  | Visually hidden unless `showCaption`. |
| `className?` | `string` |  |  |
| `classNames?` | `TableClassNames<TData>` |  |  |
| `colorScheme?` | `"auto" \| "light" \| "dark"` | ```ts 'light' ``` |  |
| `compactPagination?` | `CompactPaginationOptions` |  |  |
| `darkTheme?` | `TableTheme` |  |  |
| `datePresets?` | `DatePreset[]` |  |  |
| `detailPanelProps?` | `object` |  |  |
| `dir?` | `"ltr" \| "rtl"` |  |  |
| `disablePaginationWhileFetching?` | `boolean` | ```ts true ``` |  |
| `displayColumnDefs?` | `Partial<Record<"select" \| "expand" \| "rowNumber" \| "actions" \| "drag", Partial<ColumnDefBase<TData, unknown>>>>` |  |  |
| `emptyStateContent?` | `ReactNode` |  | Shortcut for a static empty state (e.g. a precondition message). |
| `enableColumnFooters?` | `boolean` |  |  |
| `enableDensityToggle?` | `boolean` |  |  |
| `enableExpandAll?` | `boolean` | ```ts true in multiple expand mode ``` |  |
| `enableExport?` | `boolean` |  |  |
| `enableHover?` | `boolean` | ```ts true (false in classic) ``` |  |
| `enableKeyboardNavigation?` | `boolean` |  |  |
| `enableRangeSelection?` | `boolean` | ```ts true ``` |  |
| `enableRowNumbers?` | `boolean` |  |  |
| `enableRowVirtualization?` | `boolean \| "auto"` | ```ts 'auto' ``` |  |
| `enableStickyFooter?` | `boolean` |  |  |
| `enableStickyHeader?` | `boolean` |  |  |
| `enableStriped?` | `boolean` |  |  |
| `estimateRowHeight?` | `number \| function` | ```ts by density ``` | Row height estimate for virtualization, in px. |
| `expandOnRowClick?` | `boolean` |  |  |
| `exportFileName?` | `string` |  |  |
| `exportMode?` | `"all" \| "page"` | ```ts 'page' ``` |  |
| `filterApplyMode?` | `"instant" \| "manual"` | ```ts 'instant' ``` |  |
| `filterDisplayMode?` | `"none" \| "popover" \| "row" \| "panel"` | ```ts 'panel' ``` |  |
| `firstColumnAsRowHeader?` | `boolean` | ```ts true ``` | First data cell rendered as `<th scope="row">`. |
| `formatters?` | `Partial<TableFormatters>` |  |  |
| `getRowClassName?` | `function` |  | Extra class names per row. |
| `getRowProps?` | `function` |  | Extra attributes and events for each `<tr>`. |
| `getRowStyle?` | `function` |  | Inline styles per row. |
| `handlers?` | `Partial<TableHandlers<TData>>` |  |  |
| `highlightSearchMatches?` | `boolean` | ```ts false ``` | Wraps global-search matches in `<mark class="tk-highlight">` in text cells rendered by the default cell renderer. |
| `icons?` | `Partial<TableIcons>` |  |  |
| `id?` | `string` | ```ts useId() ``` | Base id for aria relationships. |
| `layout?` | `"table" \| "grid"` | ```ts 'table' (auto 'grid' when virtualized) ``` |  |
| `loadingDisplay?` | `"text" \| "skeleton" \| "spinner"` | ```ts 'skeleton' ('text' in classic) ``` |  |
| `loadingOverlayBlocksInteraction?` | `boolean` | ```ts true ``` |  |
| `loadingOverlayDelayMs?` | `number` | ```ts 150 (0 in classic) ``` |  |
| `localization?` | `Partial<TableLocalization>` |  |  |
| `maxHeight?` | `string \| number` |  | Scroll container max-height (enables inner scroll). |
| `maxWidth?` | `ResponsiveValue<string \| number>` | ```ts '100%' ``` |  |
| `minWidth?` | `string \| number` | ```ts 650 in classic ``` | Table min-width. |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` | ```ts 'list' ``` |  |
| `noWrap?` | `boolean` | ```ts true ``` | `white-space: nowrap` in cells. |
| `onCellClick?` | `function` |  | Called when a single cell is clicked. |
| `onExport?` | `function` |  | Delegates the export to your backend instead of generating CSV in the browser. |
| `onExportFile?` | `function` |  | Takes over saving the generated file (a custom download, a share sheet…). |
| `onRowClick?` | `function` |  | Called when a row is clicked (clicks on interactive content are ignored). |
| `onRowContextMenu?` | `function` |  | Called on a right click / long press, for a custom context menu. |
| `onRowDoubleClick?` | `function` |  | Called on a double click anywhere in the row. |
| `onRowMouseEnter?` | `function` |  | Called when the pointer enters the row. |
| `onRowMouseLeave?` | `function` |  | Called when the pointer leaves the row. |
| `overscan?` | `number` | ```ts 8 ``` |  |
| `pagination?` | `PaginationDisplayOptions` |  |  |
| `positionActionsColumn?` | `"first" \| "last"` | ```ts 'last' ``` |  |
| `positionExpandColumn?` | `"first" \| "last" \| "none"` | ```ts 'first' ``` |  |
| `positionSelectionColumn?` | `"first" \| "last"` | ```ts 'first' ``` |  |
| `renderBulkActions?` | `function` |  | Buttons shown in the selection bar while rows are selected. |
| `renderColumnActionsMenuItems?` | `function` |  | Adds to, reorders or replaces the entries of a column's actions menu. |
| `renderDetailPanel?` | `function` |  | Collapsible content rendered in a full-width row under the expanded row. |
| `renderEmptyState?` | `function` |  | Replaces the empty state; `reason` distinguishes "no rows" from "no results". |
| `renderErrorState?` | `function` |  | Replaces the error state shown when loading failed with no rows to fall back on. |
| `renderRow?` | `function` |  | Replaces or wraps the whole row; call `defaultRender()` to keep the default markup. |
| `renderRowActions?` | `function` |  | Action buttons for each row; they go into an auto-inserted actions column. |
| `renderSubComponent?` | `function` |  | Alias of `renderDetailPanel`. |
| `renderToolbarEnd?` | `function` |  | Replaces the trailing toolbar group (density, columns, export). |
| `renderToolbarStart?` | `function` |  | Replaces the leading toolbar group (search, filters, chips). |
| `responsive?` | `ResponsiveOptions<TData>` |  |  |
| `rounded?` | `boolean` | ```ts true on a card, false on a plain surface ``` | Round the table's outer corners. The radius itself is the `--tk-radius` token, so this is a switch rather than a measurement. |
| `rowActionsColumn?` | `Partial<ColumnDefBase<TData, unknown>> & object` |  |  |
| `rowNumberMode?` | `"absolute" \| "relative"` | ```ts 'absolute' ``` |  |
| `scrollToTopOnPageChange?` | `boolean` | ```ts true ``` |  |
| `searchHotkey?` | `string \| false` | ```ts false ``` | e.g. `'mod+k'`, scoped to this table. |
| `searchPlaceholder?` | `string` |  |  |
| `selectOnRowClick?` | `boolean` |  |  |
| `showActiveFilterChips?` | `boolean` | ```ts true ``` |  |
| `showCaption?` | `boolean` |  |  |
| `showFacetCounts?` | `boolean` | ```ts true ``` |  |
| `showSearchHotkeyHint?` | `boolean` |  |  |
| `showSelectionBar?` | `boolean` | ```ts true with multi-selection ``` |  |
| `skeletonRowCount?` | `number` | ```ts pageSize ``` |  |
| `slotProps?` | `TableSlotProps<TData>` |  |  |
| `slots?` | `Partial<TableSlots<TData>>` |  |  |
| `style?` | `CSSProperties` |  |  |
| `styles?` | `TableStyles<TData>` |  |  |
| `surface?` | `"card" \| "plain"` | ```ts 'card' ``` | The surface the table sits on. `'card'` wraps the table and its pagination in one bordered, filled panel. `'plain'` removes that panel: the table stands directly on the page and the pagination becomes a separate block beneath it, which is what you want when the page already provides the surface. |
| `syncState?` | `SyncStateOptions` |  |  |
| `tableLayout?` | `"auto" \| "fixed"` | ```ts 'auto' ``` |  |
| `tableRef?` | `function` |  | Receives the table instance, for imperative access from outside the component. |
| `theme?` | `TableTheme \| object` |  |  |
| `toolbar?` | `boolean \| "top" \| "bottom" \| "both"` | ```ts 'top' when any toolbar feature is enabled ``` |  |
| `toolbarActions?` | `ReactNode \| function` |  | Custom content for the end of the toolbar (e.g. an "Add customer" button). |
| `treeColumnId?` | `string` |  |  |
| `unstyled?` | `boolean` |  | Drops the visual theme (structural CSS only). |
| `virtualizationThreshold?` | `number` | ```ts 200 ``` |  |

### DataTableHandle (interface)

The imperative handle of `<DataTable ref>`.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `focus` | `() => void` |  | Moves focus into the table (its first interactive cell, or the container). |
| `scrollToRow` | `(id: string, opts?: object) => void` |  | Scrolls one row into view. |
| `scrollToTop` | `() => void` |  | Scrolls the table's scroll container back to the top. |
| `table` | `TableInstance<TData>` |  | The underlying table instance. |

## Related

- [Columns](/react-tablekit/columns/)
- [Client and server](/react-tablekit/client-and-server/)
- [Playground](/react-tablekit/demos/playground/)

---

# ColumnDef

> The shape of a column: accessors, rendering, types, sizing, pinning, filtering, grouping and export.

URL: https://react-tablekit.vercel.app/react-tablekit/api/column-def/

A column definition is one object. The variants differ only in where the value comes from: an
accessor key, an accessor function, nothing at all (a display column), or nested `columns` (a
group column).

## Writing the type down

`TValue` is inferred by `createColumnHelper`, so `cell` and `format` see the narrowed value. That
is also why `ColumnDef<TData>` is the wrong annotation for an array of them: it defaults `TValue`
to `unknown`, and a helper-built column will not assign to it. Use `AnyColumnDef<TData>`, which is
what the `columns` prop takes:

```ts
import type { AnyColumnDef } from '@kingtech/react-tablekit';

export const columns: AnyColumnDef<Person>[] = [
  col.accessor('name', { header: 'Name' }),
  col.accessor('score', { header: 'Score', format: (v) => v.toFixed(1) }),
];
```

If the array stays in the same module as the table, let it infer and skip the annotation.

## Reference

### ColumnDef (variable)

### ColumnDefBase (interface)

Fields shared by every kind of column definition.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `aggregatedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `aggregationFn?` | `AggregationFnName \| AggregationFn<TData>` |  |  |
| `align?` | `ColumnAlign` | `'left'`; `'right'` for `type: 'number'` |  |
| `cell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  | Cell content. Defaults to the formatted value. |
| `cellClassName?` | `string \| function` |  | Class names for this column's body cells. |
| `cellStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's body cells. |
| `defaultHidden?` | `boolean` |  | Initial visibility. |
| `enableColumnActions?` | `boolean` |  |  |
| `enableColumnFilter?` | `boolean` |  |  |
| `enableExport?` | `boolean` | ```ts true ``` |  |
| `enableGlobalFilter?` | `boolean` | `true` for accessor columns |  |
| `enableGrouping?` | `boolean` |  |  |
| `enableHiding?` | `boolean` | ```ts true ``` | `false` = always visible (not in the columns menu). |
| `enableOrdering?` | `boolean` | ```ts true ``` | `false` = can't be reordered. |
| `enableResizing?` | `boolean` | ```ts true ``` |  |
| `enableSorting?` | `boolean` | the table's `enableSorting` | Whether this column can be sorted. |
| `exportValue?` | `function` |  | The value written to CSV, when it differs from what is displayed. |
| `filterFn?` | `FilterFnName \| FilterFn<TData>` |  |  |
| `filterOperators?` | `FilterOperator[]` |  |  |
| `filterOptions?` | `FilterOption[] \| function` |  | Values offered by the filter control; a function or promise loads them lazily. |
| `filterPlaceholder?` | `string` |  |  |
| `filterServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `columnFilters[].id`. |
| `filterVariant?` | `FilterVariant` |  |  |
| `footer?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Footer content. |
| `format?` | `function` |  | Display string (also used by search and export). |
| `getCellProps?` | `function` |  | Extra attributes and events for this column's cells. |
| `getGroupingValue?` | `function` |  | The value rows are grouped by, when it differs from the cell value. |
| `getSearchValue?` | `function` | `format(value)` | What global search matches against. |
| `groupedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `grow?` | `number \| boolean` |  | Flex-grow share in grid layout. |
| `header?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Header content. Defaults to the humanized id. |
| `headerAlign?` | `ColumnAlign` | the column's `align` |  |
| `headerClassName?` | `string \| function` |  | Class names for this column's header cell. |
| `headerShort?` | `ResponsiveValue<Renderable>` |  | Short header shown at small widths. |
| `headerStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's header cell. |
| `headerTooltip?` | `Renderable` |  | Info tooltip content shown next to the header. |
| `hideAbove?` | `Breakpoint` |  |  |
| `hideBelow?` | `Breakpoint` |  |  |
| `invertSorting?` | `boolean` |  | For ranks / "lower is better". |
| `lockPin?` | `boolean` |  | The user can't unpin/repin via the UI. |
| `lockPosition?` | `false \| "first" \| "last"` |  | Keeps the column first/last in the order (does not stick while scrolling). |
| `maxSize?` | `number` |  |  |
| `maxWidth?` | `string \| number` |  |  |
| `meta?` | `ColumnMeta<TData, TValue>` |  | Free-form metadata (module-augmentable). |
| `minSize?` | `number` | ```ts 40 ``` |  |
| `minWidth?` | `string \| number` |  |  |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` |  |  |
| `noWrap?` | `boolean` | the table's `noWrap` |  |
| `pin?` | `ResponsiveValue<false \| ColumnPinPosition>` |  | Static (frozen) column side. Responsive: `{ base: 'right', md: false }`. |
| `renderFallbackValue?` | `Renderable` | `'—'` (`'-'` in the classic preset) | Shown for `null` / `undefined` / `''`. |
| `renderFilter?` | `function` |  | Replaces this column's filter control entirely. |
| `size?` | `number` | ```ts 150 ``` | Width in px used with resizing / fixed layout. |
| `sortDescFirst?` | `boolean \| "auto"` |  |  |
| `sortingFn?` | `SortingFnName \| SortingFn<TData>` |  |  |
| `sortServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `sorting[].id`. |
| `sortUndefined?` | `false \| 1 \| "first" \| "last" \| -1` | `'last'` | Where null/undefined go regardless of direction. |
| `sortValue?` | `function` |  | Overrides the value used for sorting. |
| `static?` | `boolean` |  | Shortcut for `lockPin + enableHiding: false + enableOrdering: false + enableResizing: false + enableColumnActions: false`. |
| `truncate?` | `boolean \| object` |  | Ellipsis, optionally clamped to N lines, with a tooltip of the full value. |
| `type?` | `ColumnType` |  | Data type. Drives default align, format, sort and filter variant. |
| `verticalAlign?` | `"top" \| "middle" \| "bottom"` | `'middle'` |  |
| `width?` | `string \| number` |  | CSS width; supports `'15%'` or px. |

### AccessorKeyColumnDef (interface)

A column reading `row[accessorKey]` (dot paths supported).

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `accessorFn?` | `undefined` |  |  |
| `accessorKey` | `string & object \| conditional` |  |  |
| `aggregatedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `aggregationFn?` | `AggregationFnName \| AggregationFn<TData>` |  |  |
| `align?` | `ColumnAlign` | `'left'`; `'right'` for `type: 'number'` |  |
| `cell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  | Cell content. Defaults to the formatted value. |
| `cellClassName?` | `string \| function` |  | Class names for this column's body cells. |
| `cellStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's body cells. |
| `columns?` | `undefined` |  |  |
| `defaultHidden?` | `boolean` |  | Initial visibility. |
| `enableColumnActions?` | `boolean` |  |  |
| `enableColumnFilter?` | `boolean` |  |  |
| `enableExport?` | `boolean` | ```ts true ``` |  |
| `enableGlobalFilter?` | `boolean` | `true` for accessor columns |  |
| `enableGrouping?` | `boolean` |  |  |
| `enableHiding?` | `boolean` | ```ts true ``` | `false` = always visible (not in the columns menu). |
| `enableOrdering?` | `boolean` | ```ts true ``` | `false` = can't be reordered. |
| `enableResizing?` | `boolean` | ```ts true ``` |  |
| `enableSorting?` | `boolean` | the table's `enableSorting` | Whether this column can be sorted. |
| `exportValue?` | `function` |  | The value written to CSV, when it differs from what is displayed. |
| `filterFn?` | `FilterFnName \| FilterFn<TData>` |  |  |
| `filterOperators?` | `FilterOperator[]` |  |  |
| `filterOptions?` | `FilterOption[] \| function` |  | Values offered by the filter control; a function or promise loads them lazily. |
| `filterPlaceholder?` | `string` |  |  |
| `filterServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `columnFilters[].id`. |
| `filterVariant?` | `FilterVariant` |  |  |
| `footer?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Footer content. |
| `format?` | `function` |  | Display string (also used by search and export). |
| `getCellProps?` | `function` |  | Extra attributes and events for this column's cells. |
| `getGroupingValue?` | `function` |  | The value rows are grouped by, when it differs from the cell value. |
| `getSearchValue?` | `function` | `format(value)` | What global search matches against. |
| `groupedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `grow?` | `number \| boolean` |  | Flex-grow share in grid layout. |
| `header?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Header content. Defaults to the humanized id. |
| `headerAlign?` | `ColumnAlign` | the column's `align` |  |
| `headerClassName?` | `string \| function` |  | Class names for this column's header cell. |
| `headerShort?` | `ResponsiveValue<Renderable>` |  | Short header shown at small widths. |
| `headerStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's header cell. |
| `headerTooltip?` | `Renderable` |  | Info tooltip content shown next to the header. |
| `hideAbove?` | `Breakpoint` |  |  |
| `hideBelow?` | `Breakpoint` |  |  |
| `id?` | `string` |  |  |
| `invertSorting?` | `boolean` |  | For ranks / "lower is better". |
| `lockPin?` | `boolean` |  | The user can't unpin/repin via the UI. |
| `lockPosition?` | `false \| "first" \| "last"` |  | Keeps the column first/last in the order (does not stick while scrolling). |
| `maxSize?` | `number` |  |  |
| `maxWidth?` | `string \| number` |  |  |
| `meta?` | `ColumnMeta<TData, TValue>` |  | Free-form metadata (module-augmentable). |
| `minSize?` | `number` | ```ts 40 ``` |  |
| `minWidth?` | `string \| number` |  |  |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` |  |  |
| `noWrap?` | `boolean` | the table's `noWrap` |  |
| `pin?` | `ResponsiveValue<false \| ColumnPinPosition>` |  | Static (frozen) column side. Responsive: `{ base: 'right', md: false }`. |
| `renderFallbackValue?` | `Renderable` | `'—'` (`'-'` in the classic preset) | Shown for `null` / `undefined` / `''`. |
| `renderFilter?` | `function` |  | Replaces this column's filter control entirely. |
| `size?` | `number` | ```ts 150 ``` | Width in px used with resizing / fixed layout. |
| `sortDescFirst?` | `boolean \| "auto"` |  |  |
| `sortingFn?` | `SortingFnName \| SortingFn<TData>` |  |  |
| `sortServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `sorting[].id`. |
| `sortUndefined?` | `false \| 1 \| "first" \| "last" \| -1` | `'last'` | Where null/undefined go regardless of direction. |
| `sortValue?` | `function` |  | Overrides the value used for sorting. |
| `static?` | `boolean` |  | Shortcut for `lockPin + enableHiding: false + enableOrdering: false + enableResizing: false + enableColumnActions: false`. |
| `truncate?` | `boolean \| object` |  | Ellipsis, optionally clamped to N lines, with a tooltip of the full value. |
| `type?` | `ColumnType` |  | Data type. Drives default align, format, sort and filter variant. |
| `verticalAlign?` | `"top" \| "middle" \| "bottom"` | `'middle'` |  |
| `width?` | `string \| number` |  | CSS width; supports `'15%'` or px. |

### AccessorFnColumnDef (interface)

A column computing its value with a function.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `accessorFn` | `function` |  | Computes this column's value from the row. |
| `accessorKey?` | `undefined` |  |  |
| `aggregatedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `aggregationFn?` | `AggregationFnName \| AggregationFn<TData>` |  |  |
| `align?` | `ColumnAlign` | `'left'`; `'right'` for `type: 'number'` |  |
| `cell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  | Cell content. Defaults to the formatted value. |
| `cellClassName?` | `string \| function` |  | Class names for this column's body cells. |
| `cellStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's body cells. |
| `columns?` | `undefined` |  |  |
| `defaultHidden?` | `boolean` |  | Initial visibility. |
| `enableColumnActions?` | `boolean` |  |  |
| `enableColumnFilter?` | `boolean` |  |  |
| `enableExport?` | `boolean` | ```ts true ``` |  |
| `enableGlobalFilter?` | `boolean` | `true` for accessor columns |  |
| `enableGrouping?` | `boolean` |  |  |
| `enableHiding?` | `boolean` | ```ts true ``` | `false` = always visible (not in the columns menu). |
| `enableOrdering?` | `boolean` | ```ts true ``` | `false` = can't be reordered. |
| `enableResizing?` | `boolean` | ```ts true ``` |  |
| `enableSorting?` | `boolean` | the table's `enableSorting` | Whether this column can be sorted. |
| `exportValue?` | `function` |  | The value written to CSV, when it differs from what is displayed. |
| `filterFn?` | `FilterFnName \| FilterFn<TData>` |  |  |
| `filterOperators?` | `FilterOperator[]` |  |  |
| `filterOptions?` | `FilterOption[] \| function` |  | Values offered by the filter control; a function or promise loads them lazily. |
| `filterPlaceholder?` | `string` |  |  |
| `filterServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `columnFilters[].id`. |
| `filterVariant?` | `FilterVariant` |  |  |
| `footer?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Footer content. |
| `format?` | `function` |  | Display string (also used by search and export). |
| `getCellProps?` | `function` |  | Extra attributes and events for this column's cells. |
| `getGroupingValue?` | `function` |  | The value rows are grouped by, when it differs from the cell value. |
| `getSearchValue?` | `function` | `format(value)` | What global search matches against. |
| `groupedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `grow?` | `number \| boolean` |  | Flex-grow share in grid layout. |
| `header?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Header content. Defaults to the humanized id. |
| `headerAlign?` | `ColumnAlign` | the column's `align` |  |
| `headerClassName?` | `string \| function` |  | Class names for this column's header cell. |
| `headerShort?` | `ResponsiveValue<Renderable>` |  | Short header shown at small widths. |
| `headerStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's header cell. |
| `headerTooltip?` | `Renderable` |  | Info tooltip content shown next to the header. |
| `hideAbove?` | `Breakpoint` |  |  |
| `hideBelow?` | `Breakpoint` |  |  |
| `id` | `string` |  |  |
| `invertSorting?` | `boolean` |  | For ranks / "lower is better". |
| `lockPin?` | `boolean` |  | The user can't unpin/repin via the UI. |
| `lockPosition?` | `false \| "first" \| "last"` |  | Keeps the column first/last in the order (does not stick while scrolling). |
| `maxSize?` | `number` |  |  |
| `maxWidth?` | `string \| number` |  |  |
| `meta?` | `ColumnMeta<TData, TValue>` |  | Free-form metadata (module-augmentable). |
| `minSize?` | `number` | ```ts 40 ``` |  |
| `minWidth?` | `string \| number` |  |  |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` |  |  |
| `noWrap?` | `boolean` | the table's `noWrap` |  |
| `pin?` | `ResponsiveValue<false \| ColumnPinPosition>` |  | Static (frozen) column side. Responsive: `{ base: 'right', md: false }`. |
| `renderFallbackValue?` | `Renderable` | `'—'` (`'-'` in the classic preset) | Shown for `null` / `undefined` / `''`. |
| `renderFilter?` | `function` |  | Replaces this column's filter control entirely. |
| `size?` | `number` | ```ts 150 ``` | Width in px used with resizing / fixed layout. |
| `sortDescFirst?` | `boolean \| "auto"` |  |  |
| `sortingFn?` | `SortingFnName \| SortingFn<TData>` |  |  |
| `sortServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `sorting[].id`. |
| `sortUndefined?` | `false \| 1 \| "first" \| "last" \| -1` | `'last'` | Where null/undefined go regardless of direction. |
| `sortValue?` | `function` |  | Overrides the value used for sorting. |
| `static?` | `boolean` |  | Shortcut for `lockPin + enableHiding: false + enableOrdering: false + enableResizing: false + enableColumnActions: false`. |
| `truncate?` | `boolean \| object` |  | Ellipsis, optionally clamped to N lines, with a tooltip of the full value. |
| `type?` | `ColumnType` |  | Data type. Drives default align, format, sort and filter variant. |
| `verticalAlign?` | `"top" \| "middle" \| "bottom"` | `'middle'` |  |
| `width?` | `string \| number` |  | CSS width; supports `'15%'` or px. |

### DisplayColumnDef (interface)

A column without a value (actions, custom content).

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `accessorFn?` | `undefined` |  |  |
| `accessorKey?` | `undefined` |  |  |
| `aggregatedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `aggregationFn?` | `AggregationFnName \| AggregationFn<TData>` |  |  |
| `align?` | `ColumnAlign` | `'left'`; `'right'` for `type: 'number'` |  |
| `cell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  | Cell content. Defaults to the formatted value. |
| `cellClassName?` | `string \| function` |  | Class names for this column's body cells. |
| `cellStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's body cells. |
| `columns?` | `undefined` |  |  |
| `defaultHidden?` | `boolean` |  | Initial visibility. |
| `enableColumnActions?` | `boolean` |  |  |
| `enableColumnFilter?` | `boolean` |  |  |
| `enableExport?` | `boolean` | ```ts true ``` |  |
| `enableGlobalFilter?` | `boolean` | `true` for accessor columns |  |
| `enableGrouping?` | `boolean` |  |  |
| `enableHiding?` | `boolean` | ```ts true ``` | `false` = always visible (not in the columns menu). |
| `enableOrdering?` | `boolean` | ```ts true ``` | `false` = can't be reordered. |
| `enableResizing?` | `boolean` | ```ts true ``` |  |
| `enableSorting?` | `boolean` | the table's `enableSorting` | Whether this column can be sorted. |
| `exportValue?` | `function` |  | The value written to CSV, when it differs from what is displayed. |
| `filterFn?` | `FilterFnName \| FilterFn<TData>` |  |  |
| `filterOperators?` | `FilterOperator[]` |  |  |
| `filterOptions?` | `FilterOption[] \| function` |  | Values offered by the filter control; a function or promise loads them lazily. |
| `filterPlaceholder?` | `string` |  |  |
| `filterServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `columnFilters[].id`. |
| `filterVariant?` | `FilterVariant` |  |  |
| `footer?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Footer content. |
| `format?` | `function` |  | Display string (also used by search and export). |
| `getCellProps?` | `function` |  | Extra attributes and events for this column's cells. |
| `getGroupingValue?` | `function` |  | The value rows are grouped by, when it differs from the cell value. |
| `getSearchValue?` | `function` | `format(value)` | What global search matches against. |
| `groupedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `grow?` | `number \| boolean` |  | Flex-grow share in grid layout. |
| `header?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Header content. Defaults to the humanized id. |
| `headerAlign?` | `ColumnAlign` | the column's `align` |  |
| `headerClassName?` | `string \| function` |  | Class names for this column's header cell. |
| `headerShort?` | `ResponsiveValue<Renderable>` |  | Short header shown at small widths. |
| `headerStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's header cell. |
| `headerTooltip?` | `Renderable` |  | Info tooltip content shown next to the header. |
| `hideAbove?` | `Breakpoint` |  |  |
| `hideBelow?` | `Breakpoint` |  |  |
| `id` | `string` |  |  |
| `invertSorting?` | `boolean` |  | For ranks / "lower is better". |
| `lockPin?` | `boolean` |  | The user can't unpin/repin via the UI. |
| `lockPosition?` | `false \| "first" \| "last"` |  | Keeps the column first/last in the order (does not stick while scrolling). |
| `maxSize?` | `number` |  |  |
| `maxWidth?` | `string \| number` |  |  |
| `meta?` | `ColumnMeta<TData, TValue>` |  | Free-form metadata (module-augmentable). |
| `minSize?` | `number` | ```ts 40 ``` |  |
| `minWidth?` | `string \| number` |  |  |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` |  |  |
| `noWrap?` | `boolean` | the table's `noWrap` |  |
| `pin?` | `ResponsiveValue<false \| ColumnPinPosition>` |  | Static (frozen) column side. Responsive: `{ base: 'right', md: false }`. |
| `renderFallbackValue?` | `Renderable` | `'—'` (`'-'` in the classic preset) | Shown for `null` / `undefined` / `''`. |
| `renderFilter?` | `function` |  | Replaces this column's filter control entirely. |
| `size?` | `number` | ```ts 150 ``` | Width in px used with resizing / fixed layout. |
| `sortDescFirst?` | `boolean \| "auto"` |  |  |
| `sortingFn?` | `SortingFnName \| SortingFn<TData>` |  |  |
| `sortServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `sorting[].id`. |
| `sortUndefined?` | `false \| 1 \| "first" \| "last" \| -1` | `'last'` | Where null/undefined go regardless of direction. |
| `sortValue?` | `function` |  | Overrides the value used for sorting. |
| `static?` | `boolean` |  | Shortcut for `lockPin + enableHiding: false + enableOrdering: false + enableResizing: false + enableColumnActions: false`. |
| `truncate?` | `boolean \| object` |  | Ellipsis, optionally clamped to N lines, with a tooltip of the full value. |
| `type?` | `ColumnType` |  | Data type. Drives default align, format, sort and filter variant. |
| `verticalAlign?` | `"top" \| "middle" \| "bottom"` | `'middle'` |  |
| `width?` | `string \| number` |  | CSS width; supports `'15%'` or px. |

### GroupColumnDef (interface)

A header group spanning several child columns.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `accessorFn?` | `undefined` |  |  |
| `accessorKey?` | `undefined` |  |  |
| `aggregatedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `aggregationFn?` | `AggregationFnName \| AggregationFn<TData>` |  |  |
| `align?` | `ColumnAlign` | `'left'`; `'right'` for `type: 'number'` |  |
| `cell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  | Cell content. Defaults to the formatted value. |
| `cellClassName?` | `string \| function` |  | Class names for this column's body cells. |
| `cellStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's body cells. |
| `columns` | `ColumnDef<TData, any>[]` |  |  |
| `defaultHidden?` | `boolean` |  | Initial visibility. |
| `enableColumnActions?` | `boolean` |  |  |
| `enableColumnFilter?` | `boolean` |  |  |
| `enableExport?` | `boolean` | ```ts true ``` |  |
| `enableGlobalFilter?` | `boolean` | `true` for accessor columns |  |
| `enableGrouping?` | `boolean` |  |  |
| `enableHiding?` | `boolean` | ```ts true ``` | `false` = always visible (not in the columns menu). |
| `enableOrdering?` | `boolean` | ```ts true ``` | `false` = can't be reordered. |
| `enableResizing?` | `boolean` | ```ts true ``` |  |
| `enableSorting?` | `boolean` | the table's `enableSorting` | Whether this column can be sorted. |
| `exportValue?` | `function` |  | The value written to CSV, when it differs from what is displayed. |
| `filterFn?` | `FilterFnName \| FilterFn<TData>` |  |  |
| `filterOperators?` | `FilterOperator[]` |  |  |
| `filterOptions?` | `FilterOption[] \| function` |  | Values offered by the filter control; a function or promise loads them lazily. |
| `filterPlaceholder?` | `string` |  |  |
| `filterServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `columnFilters[].id`. |
| `filterVariant?` | `FilterVariant` |  |  |
| `footer?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Footer content. |
| `format?` | `function` |  | Display string (also used by search and export). |
| `getCellProps?` | `function` |  | Extra attributes and events for this column's cells. |
| `getGroupingValue?` | `function` |  | The value rows are grouped by, when it differs from the cell value. |
| `getSearchValue?` | `function` | `format(value)` | What global search matches against. |
| `groupedCell?` | `ColumnTemplate<CellContext<TData, TValue>>` |  |  |
| `grow?` | `number \| boolean` |  | Flex-grow share in grid layout. |
| `header?` | `ColumnTemplate<HeaderContext<TData, TValue>>` |  | Header content. Defaults to the humanized id. |
| `headerAlign?` | `ColumnAlign` | the column's `align` |  |
| `headerClassName?` | `string \| function` |  | Class names for this column's header cell. |
| `headerShort?` | `ResponsiveValue<Renderable>` |  | Short header shown at small widths. |
| `headerStyle?` | `Record<string, string \| number> \| function` |  | Inline styles for this column's header cell. |
| `headerTooltip?` | `Renderable` |  | Info tooltip content shown next to the header. |
| `hideAbove?` | `Breakpoint` |  |  |
| `hideBelow?` | `Breakpoint` |  |  |
| `id?` | `string` |  |  |
| `invertSorting?` | `boolean` |  | For ranks / "lower is better". |
| `lockPin?` | `boolean` |  | The user can't unpin/repin via the UI. |
| `lockPosition?` | `false \| "first" \| "last"` |  | Keeps the column first/last in the order (does not stick while scrolling). |
| `maxSize?` | `number` |  |  |
| `maxWidth?` | `string \| number` |  |  |
| `meta?` | `ColumnMeta<TData, TValue>` |  | Free-form metadata (module-augmentable). |
| `minSize?` | `number` | ```ts 40 ``` |  |
| `minWidth?` | `string \| number` |  |  |
| `multiSelectDisplay?` | `"list" \| "autocomplete"` |  |  |
| `noWrap?` | `boolean` | the table's `noWrap` |  |
| `pin?` | `ResponsiveValue<false \| ColumnPinPosition>` |  | Static (frozen) column side. Responsive: `{ base: 'right', md: false }`. |
| `renderFallbackValue?` | `Renderable` | `'—'` (`'-'` in the classic preset) | Shown for `null` / `undefined` / `''`. |
| `renderFilter?` | `function` |  | Replaces this column's filter control entirely. |
| `size?` | `number` | ```ts 150 ``` | Width in px used with resizing / fixed layout. |
| `sortDescFirst?` | `boolean \| "auto"` |  |  |
| `sortingFn?` | `SortingFnName \| SortingFn<TData>` |  |  |
| `sortServerKey?` | `string` | ```ts the column id ``` | Name sent to the server in `sorting[].id`. |
| `sortUndefined?` | `false \| 1 \| "first" \| "last" \| -1` | `'last'` | Where null/undefined go regardless of direction. |
| `sortValue?` | `function` |  | Overrides the value used for sorting. |
| `static?` | `boolean` |  | Shortcut for `lockPin + enableHiding: false + enableOrdering: false + enableResizing: false + enableColumnActions: false`. |
| `truncate?` | `boolean \| object` |  | Ellipsis, optionally clamped to N lines, with a tooltip of the full value. |
| `type?` | `ColumnType` |  | Data type. Drives default align, format, sort and filter variant. |
| `verticalAlign?` | `"top" \| "middle" \| "bottom"` | `'middle'` |  |
| `width?` | `string \| number` |  | CSS width; supports `'15%'` or px. |

## Related

- [Columns](/react-tablekit/columns/)
- [Column filters](/react-tablekit/column-filters/)
- [Column sizing](/react-tablekit/column-sizing/)

---

# TableInstance

> The headless instance returned by useDataTable and createTable, with its row, column, cell and header objects.

URL: https://react-tablekit.vercel.app/react-tablekit/api/table-instance/

This is the engine. Everything the built-in UI does, it does through these methods, so anything
the toolbar can do, your own control can do too.

## Reference

### TableInstance (interface)

The table instance. All setters accept an `Updater`.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `autosizeAllColumns` | `(measure?: function) => void` |  | Sizes every resizable column to its content. |
| `autosizeColumn` | `(id: string, measuredWidth?: number) => void` |  | Sizes one column to its content; pass a measured width to skip measuring. |
| `clearAllFilters` | `() => void` |  | Clears the search and every column filter at once. |
| `copyToClipboard` | `(opts?: ExportCsvOptions) => Promise<void>` |  | Copies the same CSV to the clipboard. |
| `exportCsv` | `(opts?: ExportCsvOptions) => Promise<string>` |  | Builds CSV text for the requested scope (server exports fetch in chunks). |
| `firstPage` | `() => void` |  | Jumps to the first page. |
| `flushQuery` | `() => void` |  | Applies pending debounced query changes immediately. |
| `focusCell` | `(rowId: string, columnId: string) => void` |  | Moves focus to one cell (no-op without the React layer). |
| `getActiveFilterCount` | `() => number` |  | How many filters are active, the global search included. |
| `getAllColumns` | `() => Column<TData, unknown>[]` |  | The top-level columns, group columns included. |
| `getAllFlatColumns` | `() => Column<TData, unknown>[]` |  | Every column, flattened across group levels. |
| `getAllLeafColumns` | `() => Column<TData, unknown>[]` |  | Every leaf column (group columns excluded). |
| `getBottomRows` | `() => Row<TData>[]` |  | Rows pinned to the bottom. |
| `getBreakpoint` | `() => Breakpoint` |  | Resolved breakpoint from the view layer (`'lg'` without one). |
| `getCanNextPage` | `() => boolean` |  | Whether a next page exists (cursor-aware). |
| `getCanPreviousPage` | `() => boolean` |  | Whether a previous page exists. |
| `getCanSomeRowsExpand` | `() => boolean` |  | Whether anything can expand at all (sub-rows, detail panels or lazy children). |
| `getCenterHeaderGroups` | `() => HeaderGroup<TData>[]` |  | Header groups restricted to the unpinned columns. |
| `getCenterRows` | `() => Row<TData>[]` |  | The unpinned rows of the current page. |
| `getCenterVisibleLeafColumns` | `() => Column<TData, unknown>[]` |  | The visible leaf columns that are not pinned. |
| `getColumn` | `(id: string) => Column<TData, unknown> \| undefined` |  | Looks up a column by id. |
| `getCoreRowModel` | `() => RowModel<TData>` |  | Stage 1: the rows built from `data`, before any filtering. |
| `getCursors` | `() => object` |  | Cursor pagination: the next/previous cursors from the last result. |
| `getDataStatus` | `() => DataStatus` |  | Loading, fetching, error and last-updated flags of the data source. |
| `getEffectiveColumnPinning` | `() => ColumnPinningState` |  | Pinning as actually rendered: state plus locked, static and responsive pins. |
| `getExpandedRowModel` | `() => RowModel<TData>` |  | Stage 5: after expansion (sub-rows of expanded rows included). |
| `getFilteredRowModel` | `() => RowModel<TData>` |  | Stage 2: after the global search and column filters. |
| `getFooterGroups` | `() => HeaderGroup<TData>[]` |  | Footer rows, mirroring the header groups. |
| `getGlobalFacetedRowModel` | `() => RowModel<TData>` |  | The rows the global search sees (column filters applied). |
| `getGroupedRowModel` | `() => RowModel<TData>` |  | Stage 3: after grouping (group rows inserted). |
| `getHeaderGroups` | `() => HeaderGroup<TData>[]` |  | One entry per header row (more than one with grouped columns). |
| `getIsAllColumnsVisible` | `() => boolean` |  | Whether every column is visible. |
| `getIsAllMatchingSelected` | `() => boolean` |  | Whether "select all matching" is active with no exclusions. |
| `getIsAllPageRowsSelected` | `() => boolean` |  | Whether every selectable row on this page is selected. |
| `getIsAllRowsExpanded` | `() => boolean` |  | Whether every expandable row is expanded. |
| `getIsAllRowsSelected` | `() => boolean` |  | Whether every selectable row is selected. |
| `getIsSomeColumnsPinned` | `(position?: ColumnPinPosition) => boolean` |  | Whether anything is pinned (optionally on one side). |
| `getIsSomeColumnsVisible` | `() => boolean` |  | Whether at least one column is visible. |
| `getIsSomePageRowsSelected` | `() => boolean` |  | Whether some, but not all, rows on this page are selected. |
| `getIsSomeRowsExpanded` | `() => boolean` |  | Whether at least one row is expanded. |
| `getIsSomeRowsSelected` | `() => boolean` |  | Whether some, but not all, rows are selected (indeterminate). |
| `getLeafHeaders` | `() => Header<TData, unknown>[]` |  | The leaf headers of the last header row. |
| `getLeftHeaderGroups` | `() => HeaderGroup<TData>[]` |  | Header groups restricted to the left-pinned columns. |
| `getLeftVisibleLeafColumns` | `() => Column<TData, unknown>[]` |  | The visible leaf columns pinned to the left. |
| `getPageCount` | `() => number` |  | Number of pages, derived from the row count and page size. |
| `getPageItems` | `(opts?: Omit<PageItemsArgs, "pageIndex" \| "pageCount">) => PageItem[]` |  | The page buttons and ellipses to render for the current page. |
| `getPaginationRowModel` | `() => RowModel<TData>` |  | Stage 6: the current page. |
| `getPrePaginationRowModel` | `() => RowModel<TData>` |  | The rows that pagination will slice. |
| `getQuery` | `() => TableQuery` |  | The normalized query, with server keys applied. |
| `getRightHeaderGroups` | `() => HeaderGroup<TData>[]` |  | Header groups restricted to the right-pinned columns. |
| `getRightVisibleLeafColumns` | `() => Column<TData, unknown>[]` |  | The visible leaf columns pinned to the right. |
| `getRow` | `(id: string, searchAll?: boolean) => Row<TData> \| undefined` |  | Looks up a row by id; `searchAll` also searches rows outside the current page. |
| `getRowChildrenStatus` | `(rowId: string) => object` |  | Lazy children state for a row (server `fetchChildren`). |
| `getRowCount` | `() => number` |  | Total matching rows: the server's count in server mode (`-1` = unknown). |
| `getRowModel` | `() => RowModel<TData>` |  | The rows to render: the end of the pipeline. |
| `getSelectedCount` | `() => number` |  | How many rows are selected (the server total when selecting all matching). |
| `getSelectedRowIds` | `() => string[]` |  | The ids of the selected rows. |
| `getSelectedRowModel` | `() => RowModel<TData>` |  | The selected rows that are currently loaded. |
| `getSelectionQuery` | `() => SelectionQuery` |  | What to send to a server for a bulk action: ids, or the query plus exclusions. |
| `getSortedRowModel` | `() => RowModel<TData>` |  | Stage 4: after sorting. |
| `getState` | `() => TableState` |  | The current state: internal slices merged with any controlled ones. |
| `getTopRows` | `() => Row<TData>[]` |  | Rows pinned to the top, in display order. |
| `getTotalSize` | `() => number` |  | Total width in px of the visible leaf columns. |
| `getVisibleLeafColumns` | `() => Column<TData, unknown>[]` |  | The leaf columns that are rendered, in display order. |
| `initialState` | `TableState` |  |  |
| `insertRow` | `(row: TData, index?: number) => void` |  | Optimistically inserts a row at `index` (0 by default). |
| `invalidate` | `() => void` |  | Marks the data stale so the next mount or focus refetches it. |
| `lastPage` | `() => void` |  | Jumps to the last page (offset pagination only). |
| `moveColumn` | `(id: string, toIndex: number) => void` |  | Moves a column to a position among the visible leaf columns. |
| `nextPage` | `() => void` |  | Advances one page, if there is one. |
| `options` | `ResolvedTableOptions<TData>` |  |  |
| `previousPage` | `() => void` |  | Goes back one page, if there is one. |
| `refresh` | `() => Promise<void>` |  | Refetches the current query. |
| `removeRow` | `(id: string) => void` |  | Optimistically removes one loaded row. |
| `reset` | `() => void` |  | Restores every state slice to `initialState`. |
| `resetColumnFilters` | `(defaultState?: boolean) => void` |  | Clears the column filters, or restores `initialState` with `true`. |
| `resetColumnOrder` | `(defaultState?: boolean) => void` |  | Restores the defined order, or `initialState` with `true`. |
| `resetColumnPinning` | `(defaultState?: boolean) => void` |  | Unpins everything, or restores `initialState` with `true`. |
| `resetColumnSizing` | `(defaultState?: boolean) => void` |  | Restores the defined widths, or `initialState` with `true`. |
| `resetColumnVisibility` | `(defaultState?: boolean) => void` |  | Shows every column again, or restores `initialState` with `true`. |
| `resetExpanded` | `(defaultState?: boolean) => void` |  | Collapses everything, or restores `initialState` with `true`. |
| `resetGlobalFilter` | `(defaultState?: boolean) => void` |  | Clears the search, or restores `initialState` with `true`. |
| `resetGrouping` | `(defaultState?: boolean) => void` |  | Removes all grouping, or restores `initialState` with `true`. |
| `resetPagination` | `(defaultState?: boolean) => void` |  | Returns to page 1, or restores `initialState` with `true`. |
| `resetRowPinning` | `(defaultState?: boolean) => void` |  | Unpins every row, or restores `initialState` with `true`. |
| `resetRowSelection` | `(defaultState?: boolean) => void` |  | Clears the selection, or restores `initialState` with `true`. |
| `resetSorting` | `(defaultState?: boolean) => void` |  | Clears sorting, or restores `initialState` with `true`. |
| `scrollToRow` | `(rowId: string, opts?: object) => void` |  | Scrolls a row into view (no-op without the React layer). |
| `selectAllMatching` | `() => void` |  | Selects every row matching the current query, including rows not yet loaded. |
| `selectRange` | `(fromRowId: string, toRowId: string, value?: boolean) => void` |  | Selects/deselects every selectable row between two rows (inclusive) in display order. |
| `setColumnFilters` | `(updater: Updater<ColumnFiltersState>) => void` |  | Replaces every column filter. |
| `setColumnOrder` | `(updater: Updater<ColumnOrderState>) => void` |  | Replaces the display order (ids; unlisted columns keep their defined order). |
| `setColumnPinning` | `(updater: Updater<ColumnPinningState>) => void` |  | Replaces the pinned column ids per side. |
| `setColumnSizing` | `(updater: Updater<ColumnSizingState>) => void` |  | Replaces the per-column widths in px. |
| `setColumnSizingInfo` | `(updater: Updater<ColumnSizingInfoState>) => void` |  | Updates the in-progress resize (drag offsets). |
| `setColumnVisibility` | `(updater: Updater<VisibilityState>) => void` |  | Replaces the visibility map (missing ids are visible). |
| `setDensity` | `(updater: Updater<Density>) => void` |  | Sets the row density. |
| `setExpanded` | `(updater: Updater<ExpandedState>) => void` |  | Replaces the expansion state (`true` expands every row). |
| `setGlobalFilter` | `(updater: Updater<string>) => void` |  | Sets the global search term (debounced before it reaches the query). |
| `setGrouping` | `(updater: Updater<GroupingState>) => void` |  | Replaces the grouping columns (multi-level, outermost first). |
| `setOptions` | `(updater: Updater<TableOptions<TData>>) => void` |  | Replaces the options (the React layer calls this on every render). |
| `setPageIndex` | `(updater: Updater<number>) => void` |  | Goes to a page (0-based); clamped to the available range. |
| `setPageSize` | `(updater: Updater<number>) => void` |  | Changes the page size, keeping the first visible row where possible. |
| `setPagination` | `(updater: Updater<PaginationState>) => void` |  | Replaces the whole pagination slice. |
| `setRowPinning` | `(updater: Updater<RowPinningState>) => void` |  | Replaces the pinned row ids per side. |
| `setRowSelection` | `(updater: Updater<RowSelectionState>) => void` |  | Replaces the selection (ids, or the `__all` exclusion model in server mode). |
| `setSorting` | `(updater: Updater<SortingState>) => void` |  | Replaces the sort list. |
| `setState` | `(updater: Updater<TableState>) => void` |  | Replaces the whole state. Controlled slices are reported through their `on*Change`. |
| `subscribe` | `(listener: function) => function` |  | Subscribes to state changes; returns an unsubscribe function. |
| `toggleAllColumnsVisible` | `(value?: boolean) => void` |  | Shows or hides every hideable column. |
| `toggleAllPageRowsSelected` | `(value?: boolean) => void` |  | Selects or deselects every selectable row on the current page. |
| `toggleAllRowsExpanded` | `(value?: boolean) => void` |  | Expands or collapses every expandable row. |
| `toggleAllRowsSelected` | `(value?: boolean) => void` |  | Selects or deselects every selectable row in the dataset. |
| `toggleDensity` | `() => void` |  | Cycles compact → standard → comfortable. |
| `updateRow` | `(id: string, updater: Updater<TData>) => void` |  | Optimistically updates one loaded row. |

### Row (interface)

A row. Accessor values are cached per row.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `_groupingValuesCache` | `Record<string, unknown>` |  | Aggregated values of a group row, by column id. |
| `depth` | `number` |  |  |
| `getAllCells` | `() => Cell<TData, unknown>[]` |  | A cell per defined column, hidden ones included. |
| `getCanExpand` | `() => boolean` |  | Whether it can expand: it has sub-rows, a detail panel, or lazy children. |
| `getCanMultiSelect` | `() => boolean` |  | Whether it takes part in multi-selection and range selection. |
| `getCanPin` | `() => boolean` |  | Whether the user may pin it (`enableRowPinning`). |
| `getCanSelect` | `() => boolean` |  | Whether this row may be selected (`enableRowSelection`). |
| `getCanSelectSubRows` | `() => boolean` |  | Whether selecting it cascades to its sub-rows (`enableSubRowSelection`). |
| `getCenterVisibleCells` | `() => Cell<TData, unknown>[]` |  | The visible cells of the unpinned columns. |
| `getIsAllParentsExpanded` | `() => boolean` |  | Whether every ancestor is expanded, i.e. this row is actually visible. |
| `getIsAllSubRowsSelected` | `() => boolean` |  | Whether every sub-row is selected. |
| `getIsDisabled` | `() => boolean` |  | Whether the row is disabled: it ignores clicks and cannot be selected. |
| `getIsExpanded` | `() => boolean` |  | Whether the row is expanded. |
| `getIsGrouped` | `() => boolean` |  | Whether this is a group row rather than a data row. |
| `getIsPinned` | `() => false \| "top" \| "bottom"` |  | Whether the row is pinned above or below the scrolling rows. |
| `getIsSelected` | `() => boolean` |  | Whether this row is selected. |
| `getIsSomeSelected` | `() => boolean` |  | Whether some, but not all, of its sub-rows are selected (indeterminate). |
| `getLeafRows` | `() => Row<TData>[]` |  | Every descendant leaf row (itself, when it has no children). |
| `getLeftVisibleCells` | `() => Cell<TData, unknown>[]` |  | The visible cells of the left-pinned columns. |
| `getParentRow` | `() => Row<TData> \| undefined` |  | The parent row in tree data, or `undefined` at the top level. |
| `getParentRows` | `() => Row<TData>[]` |  | Every ancestor, outermost first. |
| `getRightVisibleCells` | `() => Cell<TData, unknown>[]` |  | The visible cells of the right-pinned columns. |
| `getUniqueValues` | `(columnId: string) => TValue[]` |  | The distinct values of one column across this row and its sub-rows (faceting). |
| `getValue` | `(columnId: string) => TValue` |  | The accessor value of one column, cached per row. |
| `getVisibleCells` | `() => Cell<TData, unknown>[]` |  | The cells that are rendered, in display order. |
| `groupingColumnId` | `string \| undefined` |  |  |
| `groupingValue` | `unknown` |  |  |
| `id` | `string` |  |  |
| `index` | `number` |  |  |
| `original` | `TData` |  |  |
| `parentId` | `string \| undefined` |  |  |
| `pin` | `(position: false \| "top" \| "bottom") => void` |  | Pins the row to the top or bottom, or unpins it with `false`. |
| `renderValue` | `(columnId: string) => TValue \| null` |  | Like `getValue`, but `null` instead of `undefined` (for rendering). |
| `subRows` | `Row<TData>[]` |  |  |
| `toggleExpanded` | `(value?: boolean) => void` |  | Expands or collapses the row; toggles when no value is given. |
| `toggleSelected` | `(value?: boolean, opts?: object) => void` |  | Selects or deselects the row; toggles when no value is given. |

### Column (interface)

A resolved column.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `accessorFn` | `function \| undefined` |  | `undefined` for display and group columns. |
| `clearSorting` | `() => void` |  | Removes this column from the sort list. |
| `columnDef` | `ColumnDef<TData, TValue>` |  | The definition after `columnDefaults` were merged in. |
| `columns` | `Column<TData, unknown>[]` |  | Child columns (group columns only). |
| `depth` | `number` |  |  |
| `formatValue` | `(value: unknown, row: TData) => string` |  | The string used for display/search/export. |
| `getAfter` | `(position?: ColumnPinPosition) => number` |  | Offset in px from the end of its pinned side. |
| `getAggregationFn` | `() => AggregationFn<TData> \| undefined` |  | The aggregation used for this column's cells in group rows. |
| `getAlign` | `() => ColumnAlign` |  | Resolved horizontal alignment (numbers default to `'right'`). |
| `getAutoSortingFn` | `() => SortingFn<TData>` |  | The comparator inferred from the column's values. |
| `getCanFilter` | `() => boolean` |  | Whether the column can be filtered (`enableColumnFilter`). |
| `getCanGlobalFilter` | `() => boolean` |  | Whether the global search looks at this column (`enableGlobalFilter`). |
| `getCanGroup` | `() => boolean` |  | Whether the column may be grouped by (`enableGrouping`). |
| `getCanHide` | `() => boolean` |  | Whether the user may hide it (`enableHiding`). |
| `getCanOrder` | `() => boolean` |  | Whether the user may reorder it (`enableColumnOrdering`). |
| `getCanPin` | `() => boolean` |  | Whether the user may pin it (`enableColumnPinning`). |
| `getCanResize` | `() => boolean` |  | Whether the user may resize it (`enableColumnResizing`). |
| `getCanSort` | `() => boolean` |  | Whether the user may sort by it (`enableSorting`). |
| `getFacetedMinMaxValues` | `() => [number, number] \| undefined` |  | Smallest and largest value, for range filters. |
| `getFacetedRowModel` | `() => RowModel<TData>` |  | Rows used to compute this column's facets (every other column's filters applied). |
| `getFacetedUniqueValues` | `() => Map<unknown, number>` |  | Distinct values with their counts, for select and multi-select filters. |
| `getFilterFn` | `() => FilterFn<TData> \| undefined` |  | The filter predicate in use. |
| `getFilterIndex` | `() => number` |  | Position in the active filter list (`-1` when not filtered). |
| `getFilterOperator` | `() => FilterOperator \| undefined` |  | The active filter operator, when the variant supports one. |
| `getFilterValue` | `() => unknown` |  | The active filter value, or `undefined`. |
| `getFilterVariant` | `() => FilterVariant` |  | The filter control to render (explicit `filterVariant`, or inferred from the type). |
| `getFirstSortDir` | `() => "asc" \| "desc"` |  | The direction the first click applies (`sortDescFirst`). |
| `getFlatColumns` | `() => Column<TData, unknown>[]` |  | This column and every descendant, depth-first. |
| `getGroupedIndex` | `() => number` |  | Position in the grouping list (`-1` when not grouped). |
| `getIndex` | `() => number` |  | Position among the visible leaf columns. |
| `getIsFiltered` | `() => boolean` |  | Whether a filter is active on this column. |
| `getIsGrouped` | `() => boolean` |  | Whether rows are currently grouped by this column. |
| `getIsPinned` | `() => false \| ColumnPinPosition` |  | The side this column is pinned to, or `false`. |
| `getIsResizing` | `() => boolean` |  | Whether a resize drag is in progress on this column. |
| `getIsSorted` | `() => false \| "asc" \| "desc"` |  | The current sort direction, or `false` when unsorted. |
| `getIsVisible` | `() => boolean` |  | Whether the column is currently rendered. |
| `getLeafColumns` | `() => Column<TData, unknown>[]` |  | The leaf columns under this one (itself, when it has no children). |
| `getNextSortingOrder` | `() => false \| "asc" \| "desc"` |  | What the next click on the header will do. |
| `getPinnedIndex` | `() => number` |  | Position within its pinned group. |
| `getServerFacets` | `() => FacetResult \| undefined` |  | Server facets (from `result.facets` or `fetchFacets`), if loaded. |
| `getSize` | `() => number` |  | Current width in px (used when resizing is enabled). |
| `getSortIndex` | `() => number` |  | Position in the multi-sort list (`-1` when unsorted). |
| `getSortingFn` | `() => SortingFn<TData>` |  | The comparator actually used (explicit `sortingFn`, or the inferred one). |
| `getStart` | `(position?: ColumnPinPosition) => number` |  | Offset in px from the start of its pinned side (sticky positioning). |
| `getType` | `() => ColumnType` |  | Resolved data type (explicit or auto-detected). |
| `id` | `string` |  |  |
| `loadFacets` | `() => Promise<void>` |  | Requests this column's facets from the data source (server faceting). |
| `parent` | `Column<TData, unknown> \| undefined` |  |  |
| `pin` | `(position: false \| ColumnPinPosition) => void` |  | Pins the column to a side, or unpins it with `false`. |
| `resetSize` | `() => void` |  | Restores the column's defined width. |
| `setFilterValue` | `(updater: unknown, operator?: FilterOperator) => void` |  | Sets (or clears, with `undefined`) this column's filter. |
| `toggleGrouping` | `() => void` |  | Adds or removes this column from the grouping list. |
| `toggleSorting` | `(desc?: boolean, multi?: boolean) => void` |  | Sorts by this column; `multi` adds it to the sort list instead of replacing it. |
| `toggleVisibility` | `(value?: boolean) => void` |  | Shows or hides the column; toggles when no value is given. |

### Cell (interface)

One cell of a row.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `column` | `Column<TData, TValue>` |  |  |
| `getContext` | `() => CellContext<TData, TValue>` |  | The context object passed to `cell` renderers. |
| `getIsAggregated` | `() => boolean` |  | Whether this cell shows an aggregate of the group's rows. |
| `getIsGrouped` | `() => boolean` |  | Whether this is the grouping cell of a group row (it shows the value and the count). |
| `getIsPlaceholder` | `() => boolean` |  | Whether this cell is blank because another column groups the row. |
| `getValue` | `() => TValue` |  | The raw accessor value. |
| `id` | `string` |  |  |
| `renderValue` | `() => TValue \| null` |  | The value for rendering: like `getValue`, but `null` instead of `undefined`. |
| `row` | `Row<TData>` |  |  |

### Header (interface)

One header cell (multi-row headers supported).

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `colSpan` | `number` |  |  |
| `column` | `Column<TData, TValue>` |  |  |
| `depth` | `number` |  |  |
| `getContext` | `() => HeaderContext<TData, TValue>` |  | The context object passed to `header` renderers. |
| `getLeafHeaders` | `() => Header<TData, unknown>[]` |  | Every leaf header under this one. |
| `getSize` | `() => number` |  | Width in px of this header (the sum of its leaf columns for a group header). |
| `getStart` | `(position?: ColumnPinPosition) => number` |  | Offset in px from the start of its pinned side (sticky positioning). |
| `headerGroup` | `HeaderGroup<TData>` |  |  |
| `id` | `string` |  |  |
| `index` | `number` |  |  |
| `isPlaceholder` | `boolean` |  | Placeholder cells fill the space above leaf columns in multi-row headers. |
| `rowSpan` | `number` |  |  |
| `subHeaders` | `Header<TData, unknown>[]` |  |  |

## Related

- [Headless](/react-tablekit/headless/)
- [Hooks](/react-tablekit/api/hooks/)
- [Toolbar](/react-tablekit/toolbar/)

---

# TableState

> Every state slice, the engine options that own them, and the normalized TableQuery sent to a server.

URL: https://react-tablekit.vercel.app/react-tablekit/api/table-state/

State is a plain object of independent slices. Pass `initialState` to seed them, `state` to
control them, or leave both out and let the table own them. `TableQuery` is the normalized form
that travels to a server.

## Reference

### TableState (interface)

The complete table state. Every slice can be controlled or uncontrolled.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `columnFilters` | `ColumnFiltersState` |  |  |
| `columnOrder` | `ColumnOrderState` |  |  |
| `columnPinning` | `ColumnPinningState` |  |  |
| `columnSizing` | `ColumnSizingState` |  |  |
| `columnSizingInfo` | `ColumnSizingInfoState` |  |  |
| `columnVisibility` | `VisibilityState` |  |  |
| `density` | `Density` |  |  |
| `expanded` | `ExpandedState` |  |  |
| `globalFilter` | `string` |  |  |
| `grouping` | `GroupingState` |  |  |
| `pagination` | `PaginationState` |  |  |
| `rowPinning` | `RowPinningState` |  |  |
| `rowSelection` | `RowSelectionState` |  |  |
| `sorting` | `SortingState` |  |  |

### TableQuery (interface)

The normalized query: everything that affects which rows the server must return.
`globalFilter` and text filters are already debounced and min-length gated.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `columnFilters` | `ColumnFiltersState` |  |  |
| `expanded?` | `Record<string, boolean>` |  |  |
| `globalFilter` | `string` |  |  |
| `grouping` | `GroupingState` |  |  |
| `pagination` | `PaginationState` |  |  |
| `sorting` | `SortingState` |  |  |

### TableOptions (interface)

Engine options. `DataTableProps` adds view-only props on top.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `_features?` | `TableFeature<TData>[]` |  | Advanced: custom features. |
| `acknowledgePageLocalFiltering?` | `boolean` |  |  |
| `acknowledgePageLocalSorting?` | `boolean` |  |  |
| `aggregationFns?` | `Record<string, AggregationFn<TData>>` |  |  |
| `appendPages?` | `boolean` |  | Append each fetched page to the previous ones instead of replacing them. Inferred from a `loadMore` / `infinite` pagination variant; set it explicitly for a custom UI. |
| `autoResetExpanded?` | `boolean` | ```ts true ``` |  |
| `autoResetPageIndex?` | `boolean` | ```ts true in client mode, false with manual pagination ``` |  |
| `autoResetSelection?` | `boolean` | ```ts false ``` |  |
| `clearSelectionOnQueryChange?` | `boolean` | ```ts false client / true server ``` |  |
| `columnDefaults?` | `Partial<ColumnDefBase<TData, unknown>>` |  | Merged into every column. |
| `columnResizeDirection?` | `"ltr" \| "rtl"` | `'ltr'` |  |
| `columnResizeMode?` | `"onChange" \| "onEnd"` | `'onChange'` |  |
| `columns` | `AnyColumnDef<TData>[]` |  |  |
| `data?` | `TData[]` | ```ts [] ``` | Rows (client mode), or the current page (controlled server mode). |
| `dataMode?` | `DataMode` | `'client'` (`'server'` when `dataSource` is set) |  |
| `dataSource?` | `DataSource<TData>` |  | Server adapter. Implies `dataMode: 'server'`. |
| `debugColumns?` | `boolean` |  |  |
| `debugRows?` | `boolean` |  |  |
| `debugTable?` | `boolean` |  |  |
| `dedupeMs?` | `number` | ```ts 0 (in-flight only) ``` |  |
| `defaultColumn?` | `Partial<ColumnDefBase<TData, unknown>>` |  | Alias of `columnDefaults`. |
| `enableColumnActions?` | `boolean` | ```ts false ``` |  |
| `enableColumnFilters?` | `boolean` | ```ts true ``` |  |
| `enableColumnOrdering?` | `boolean` | ```ts false ``` |  |
| `enableColumnPinning?` | `boolean` | ```ts true ``` |  |
| `enableColumnResizing?` | `boolean` | ```ts false ``` |  |
| `enableExpanding?` | `boolean` |  | Auto when `getSubRows`, `renderDetailPanel` or `getRowCanExpand` is set. |
| `enableFacetedValues?` | `boolean` | ```ts true ``` |  |
| `enableFilters?` | `boolean` | ```ts true ``` | Master switch for all filtering. |
| `enableGlobalFilter?` | `boolean` | ```ts true ``` |  |
| `enableGrouping?` | `boolean` | ```ts false ``` |  |
| `enableHiding?` | `boolean` | ```ts true ``` |  |
| `enableMultiRowSelection?` | `boolean \| function` | ```ts true ``` | `false` = single selection. |
| `enableMultiSort?` | `boolean` | ```ts true ``` |  |
| `enablePagination?` | `boolean` | ```ts true ``` |  |
| `enableRowPinning?` | `boolean \| function` | ```ts false ``` |  |
| `enableRowSelection?` | `boolean \| function` | ```ts false ``` |  |
| `enableSorting?` | `boolean` | ```ts true ``` |  |
| `enableSortingRemoval?` | `boolean` | ```ts true ``` |  |
| `enableSubRowSelection?` | `boolean \| function` | ```ts true ``` |  |
| `error?` | `unknown` |  |  |
| `expandMode?` | `"multiple" \| "single"` | `'multiple'` |  |
| `exportChunkSize?` | `number` | ```ts 1000 ``` | Rows per chunk for server "export all". |
| `exportMaxRows?` | `number` | ```ts 10000 ``` | Cap for server "export all". |
| `facetingMode?` | `DataMode` |  |  |
| `facets?` | `Record<string, FacetResult>` |  | Server facets in controlled server mode (with `manualFaceting`), by column id. |
| `fetching?` | `boolean` |  | Refetch with existing data (overlay). |
| `fetchOnMount?` | `boolean` | ```ts true ``` |  |
| `filterDebounceMs?` | `number` | ```ts 300 ``` |  |
| `filterFns?` | `Record<string, FilterFn<TData>>` |  |  |
| `filterFromLeafRows?` | `boolean` | ```ts false ``` |  |
| `filterMode?` | `DataMode` |  |  |
| `footerAggregationScope?` | `"page" \| "filtered"` | `'filtered'` |  |
| `getColumnCanGlobalFilter?` | `function` |  | Decides per column whether the global search looks at it. |
| `getRowCanExpand?` | `function` |  | Decides per row whether it can expand (lazy children that are not loaded yet). |
| `getRowCanSelect?` | `function` |  | Alias of the function form of `enableRowSelection`. |
| `getRowId?` | `function` | ```ts index-based ``` | Strongly recommended; used for keys, selection and expansion. |
| `getSubRows?` | `function` |  | Tree data (client mode). |
| `globalFilterFn?` | `FilterFnName \| FilterFn<TData>` | `'includesString'` |  |
| `globalFilterMatch?` | `"all-words" \| "any-word" \| "phrase"` | `'all-words'` |  |
| `groupedColumnMode?` | `false \| "reorder" \| "remove"` | `'reorder'` |  |
| `groupingMode?` | `DataMode` |  |  |
| `initialState?` | `InitialTableState` |  |  |
| `isMultiSortEvent?` | `function` | ```ts Shift+click ``` | Decides whether a click adds to the sort list. |
| `isRowDisabled?` | `function` |  | Disabled rows are dimmed, not selectable/clickable. |
| `keepPinnedRows?` | `boolean` | ```ts true ``` |  |
| `keepPreviousData?` | `boolean` | ```ts true ``` |  |
| `loading?` | `boolean` |  | Initial load (no data yet). |
| `locale?` | `string` | `navigator.language` / `'en-US'` | BCP-47 locale for collation/formatting. |
| `manualExpanding?` | `boolean` |  |  |
| `manualFaceting?` | `boolean` |  |  |
| `manualFiltering?` | `boolean` |  |  |
| `manualGrouping?` | `boolean` |  |  |
| `manualPagination?` | `boolean` |  |  |
| `manualSorting?` | `boolean` |  |  |
| `maxLeafRowFilterDepth?` | `number` | ```ts Infinity ``` |  |
| `maxMultiSortColCount?` | `number` | ```ts 3 ``` |  |
| `mergeOptions?` | `function` |  | Advanced: customizes how new options are merged into the instance on each render. |
| `meta?` | `TableMeta<TData>` |  |  |
| `onColumnFiltersChange?` | `OnChangeFn<ColumnFiltersState>` |  |  |
| `onColumnOrderChange?` | `OnChangeFn<ColumnOrderState>` |  |  |
| `onColumnPinningChange?` | `OnChangeFn<ColumnPinningState>` |  |  |
| `onColumnSizingChange?` | `OnChangeFn<ColumnSizingState>` |  |  |
| `onColumnSizingInfoChange?` | `OnChangeFn<ColumnSizingInfoState>` |  |  |
| `onColumnVisibilityChange?` | `OnChangeFn<VisibilityState>` |  |  |
| `onDensityChange?` | `OnChangeFn<Density>` |  |  |
| `onError?` | `function` |  | Called when a data-source request fails, for logging or a toast. |
| `onExpandedChange?` | `OnChangeFn<ExpandedState>` |  |  |
| `onGlobalFilterChange?` | `OnChangeFn<string>` |  |  |
| `onGroupingChange?` | `OnChangeFn<GroupingState>` |  |  |
| `onPaginationChange?` | `OnChangeFn<PaginationState>` |  |  |
| `onQueryChange?` | `function` |  | Called whenever the normalized query changes, with the reason. |
| `onRowPinningChange?` | `OnChangeFn<RowPinningState>` |  |  |
| `onRowSelectionChange?` | `OnChangeFn<RowSelectionState>` |  |  |
| `onSortingChange?` | `OnChangeFn<SortingState>` |  |  |
| `onStateChange?` | `OnChangeFn<TableState>` |  |  |
| `onStatusChange?` | `function` |  | Called whenever the loading/fetching/error status changes. |
| `pageCount?` | `number` |  |  |
| `paginateExpandedRows?` | `boolean` | ```ts true ``` |  |
| `paginationMode?` | `DataMode` |  |  |
| `paginationType?` | `"cursor" \| "offset"` | `'offset'` |  |
| `refetchInterval?` | `number` |  |  |
| `refetchOnWindowFocus?` | `boolean` | ```ts false ``` |  |
| `renderFallbackValue?` | `Renderable` |  | Table-wide fallback for empty values. |
| `resetPageOn?` | `ResetPageReason[]` | ```ts every reason ``` |  |
| `rowCount?` | `number` |  | Total rows in server mode (`-1` = unknown). |
| `searchDebounceMs?` | `number` | ```ts 300 ``` |  |
| `searchMinLength?` | `number` | ```ts 0 ``` |  |
| `searchMode?` | `DataMode` |  |  |
| `selectAllMode?` | `"all" \| "page"` | `'page'` |  |
| `sortDescFirst?` | `boolean` | ```ts false ``` |  |
| `sortingFns?` | `Record<string, SortingFn<TData>>` |  |  |
| `sortingMode?` | `DataMode` |  |  |
| `state?` | `Partial<TableState>` |  |  |
| `stickyActions?` | `boolean` |  | Convenience alias: pins the actions column right. |

### SelectionQuery (variable)

### DataSource (interface)

A server adapter. Fetch-library agnostic: return a promise and honour the signal.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `fetch` | `(query: TableQuery, ctx: object) => Promise<DataSourceResult<TData>>` |  | Called with the normalized query and an AbortSignal. Must resolve the page. |
| `fetchAllIds?` | `(query: TableQuery, ctx: object) => Promise<string[]>` |  | Optional: resolve "select all matching" to ids. |
| `fetchChildren?` | `(row: Row<TData>, query: TableQuery, ctx: object) => Promise<TData[]>` |  | Optional: lazy children for tree rows. |
| `fetchFacets?` | `(columnId: string, query: TableQuery, ctx: object) => Promise<FacetResult>` |  | Optional: server-side facets for filter option lists/counts. |

### DataSourceResult (interface)

What a data source's `fetch` resolves to.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `facets?` | `Record<string, FacetResult>` |  |  |
| `nextCursor?` | `string \| null` |  | Cursor pagination. |
| `pageCount?` | `number` |  | Alternative to `rowCount`. |
| `prevCursor?` | `string \| null` |  |  |
| `query?` | `Partial<TableQuery>` |  | The server may normalize/echo the query; the table adopts it without refetching. |
| `rowCount?` | `number` |  | Total matching rows (offset pagination). `-1` = unknown. |
| `rows` | `TData[]` |  |  |

### DataSourceState (interface)

Live server-mode status returned by useDataSource.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `error` | `unknown` |  |  |
| `fetching` | `boolean` |  | Refetch with existing data (overlay). |
| `lastQuery` | `TableQuery \| null` |  |  |
| `lastUpdated` | `number \| null` |  |  |
| `loading` | `boolean` |  | Initial load: no data yet. |
| `refresh` | `function` |  | Refetches the current query. |
| `rowCount` | `number` |  | Total matching rows reported by the server (`-1` = unknown). |
| `rows` | `TData[]` |  | The rows currently loaded from the server. |

## Related

- [Client and server](/react-tablekit/client-and-server/)
- [Persistence](/react-tablekit/persistence/)
- [Hooks](/react-tablekit/api/hooks/)

---

# Hooks

> Every exported hook: the table itself, server data, state selection, detail panels, breakpoints, virtualization and persistence.

URL: https://react-tablekit.vercel.app/react-tablekit/api/hooks/

Each hook is usable on its own. `useDataTable` is the whole engine; the rest are the pieces the
built-in component uses internally, exported because a custom layout needs them too.

## Reference

### useDataTable (function)

Creates a table instance and subscribes the component to it. Accepts every
`<DataTable>` prop; view props are kept on `table.options` for `DataTable.Root`.

```tsx
const table = useDataTable({ data, columns, getRowId: (r) => r.id });
return <DataTable.Root table={table}><DataTable.Container /><DataTable.Pagination /></DataTable.Root>;
```

`useDataTable(props: DataTableProps<TData>): TableInstance<TData>`: Creates a table instance and subscribes the component to it. Accepts every
`<DataTable>` prop; view props are kept on `table.options` for `DataTable.Root`.

### useDataSource (function)

Headless access to server mode: the rows, counts and loading flags of a table driven
by `dataSource`. Create the table with the same `dataSource` (`useDataTable({ dataSource })`).

`useDataSource(dataSource: DataSource<TData>, opts: object): DataSourceState<TData>`: Headless access to server mode: the rows, counts and loading flags of a table driven
by `dataSource`. Create the table with the same `dataSource` (`useDataTable({ dataSource })`).

### useTableState (function)

Subscribes to a slice of table state; the component re-renders only when the selected value
changes (fine-grained subscriptions).

```tsx
const pagination = useTableState(table, (s) => s.pagination);
```

`useTableState(table: TableInstance<TData>, selector: function, isEqual: function): T`: Subscribes to a slice of table state; the component re-renders only when the selected value
changes (fine-grained subscriptions).

### useDetailPanelData (function)

Loads data for a detail panel, cached per row id and loader.

```tsx
const { data, loading } = useDetailPanelData(row, (r) => fetchOrders(r.original.id));
```

`useDetailPanelData(row: Row<TData>, loader: function): object`: Loads data for a detail panel, cached per row id and loader.

### useBreakpoint (function)

The current breakpoint, using the theme's breakpoints.

`useBreakpoint(): Breakpoint`: The current breakpoint, using the theme's breakpoints.

### useVirtualRows (function)

Headless row virtualization: renders only the rows near the viewport of a bounded
scroll container, with optional dynamic measurement.

```tsx
const { virtualRows, before, after } = useVirtualRows({
  count: rows.length,
  getScrollElement: () => containerRef.current,
  estimateSize: 44,
});
```

`useVirtualRows(options: UseVirtualRowsOptions): VirtualRowsResult`: Headless row virtualization: renders only the rows near the viewport of a bounded
scroll container, with optional dynamic measurement.

### useSyncState (function)

Persists table state to the URL and/or storage. On mount the URL wins over storage,
which wins over `initialState`.

`DataTable` calls this for you when `syncState` is set; call it directly only for a table you
built with `createTable`.

`useSyncState(table: TableInstance<TData>, options: SyncStateOptions | undefined, router?: RouterSyncAdapter): void`: Persists table state to the URL and/or storage. On mount the URL wins over storage,
which wins over `initialState`.

`DataTable` calls this for you when `syncState` is set; call it directly only for a table you
built with `createTable`.

### useRouterSync (function)

Plugs `syncState.url` into a router instead of the History API, for React Router,
Next.js and friends.

```tsx
const [params, setParams] = useSearchParams();
useRouterSync(table, {
  getSearch: () => params.toString(),
  setSearch: (search, mode) => setParams(search, { replace: mode === 'replace' }),
});
```

`useRouterSync(table: TableInstance<TData>, adapter: RouterSyncAdapter, options?: SyncStateOptions): void`: Plugs `syncState.url` into a router instead of the History API, for React Router,
Next.js and friends.

### useTableSlots (function)

Inside a custom slot: the resolved slots plus the library defaults, so a replacement can
wrap the default.

`useTableSlots(): TableSlots<TData> & object`: Inside a custom slot: the resolved slots plus the library defaults, so a replacement can
wrap the default.

### useTableContext (function)

The table instance inside slots and composable parts.

`useTableContext(): TableInstance<TData>`: The table instance inside slots and composable parts.

## Related

- [Headless](/react-tablekit/headless/)
- [Persistence](/react-tablekit/persistence/)
- [Responsive](/react-tablekit/responsive/)

---

# Utilities

> The column helper, data-source adapters, CSV export, pagination items, theming helpers and the headless engine factory.

URL: https://react-tablekit.vercel.app/react-tablekit/api/utilities/

Standalone functions with no React dependency of their own. They are the seams: the places where
you can step outside the component and still use what it uses.

## Reference

### createColumnHelper (function)

Typed column builders.

```tsx
const col = createColumnHelper<Customer>();
const columns = [
  col.accessor('displayName.companyName', { header: 'Company name' }),
  col.accessor((r) => r.sites?.length ?? 0, { id: 'siteCount', type: 'number' }),
  col.display({ id: 'actions', cell: ({ row }) => <Edit row={row} />, pin: 'right', static: true }),
];
```

`createColumnHelper(): ColumnHelper<TData>`: Typed column builders.

### defineColumns (function)

Identity with inference, for column arrays defined outside components.

`defineColumns(columns: AnyColumnDef<TData>[]): AnyColumnDef<TData>[]`: Identity with inference, for column arrays defined outside components.

### createRestDataSource (function)

A data source for REST endpoints with page/size/sort/search params.

```tsx
const customers = createRestDataSource<Customer, SearchResponse>({
  url: '/api/customers/search',
  method: 'POST',
  mapQuery: (q) => ({ queryCriteria: q.globalFilter, listingCriteria: { pageNumber: q.pagination.pageIndex, pageSize: q.pagination.pageSize } }),
  mapResult: (json) => ({ rows: json.items, rowCount: json.totalItemCount }),
});
```

`createRestDataSource(options: RestDataSourceOptions<TData, TResponse>): DataSource<TData>`: A data source for REST endpoints with page/size/sort/search params.

### createLocalDataSource (function)

An in-memory array that *behaves like a server* (latency, failures, abort). It reuses the
engine's own filter/sort functions, so client mode and simulated server mode return identical
rows for the same query.

`createLocalDataSource(rows: TData[] | function, options: LocalDataSourceOptions<TData>): DataSource<TData>`: An in-memory array that *behaves like a server* (latency, failures, abort). It reuses the
engine's own filter/sort functions, so client mode and simulated server mode return identical
rows for the same query.

### exportToCsv (function)

Standalone CSV export: RFC 4180 quoting, formula-like fields neutralized (`escapeFormulas`),
optional UTF-8 BOM, headers from `meta.exportHeader ?? string header ?? id`, values from
`exportValue ?? format`.

```tsx
const csv = exportToCsv(customers, customerColumns, { bom: false });
```

`exportToCsv(rows: TData[], columns: AnyColumnDef<TData>[], opts: Pick<ExportCsvOptions, "delimiter" | "bom" | "columns" | "escapeFormulas">): string`: Standalone CSV export: RFC 4180 quoting, formula-like fields neutralized (`escapeFormulas`),
optional UTF-8 BOM, headers from `meta.exportHeader ?? string header ?? id`, values from
`exportValue ?? format`.

### getPageItems (function)

Pure pagination item generator (0-based). `algorithm` is `'classic'` (default, the corrected
classic rule), `'stable'` (constant slot count) or a custom function.

```tsx
getPageItems({ pageIndex: 4, pageCount: 10 });
// → pages 0 1 2 3 4 5, ellipsis, pages 8 9
```

`getPageItems(args: PageItemsArgs): PageItem[]`: Pure pagination item generator (0-based). `algorithm` is `'classic'` (default, the corrected
classic rule), `'stable'` (constant slot count) or a custom function.

### createTable (function)

Creates a headless table instance. Framework-agnostic: React binds to it through
`subscribe` + `getState`.

```tsx
const table = createTable({ data, columns, getRowId: (r) => r.id });
table.setSorting([{ id: 'name', desc: false }]);
table.getRowModel().rows.map((row) => row.getValue('name'));
```

`createTable(userOptions: TableOptions<TData>): TableInstance<TData>`: Creates a headless table instance. Framework-agnostic: React binds to it through
`subscribe` + `getState`.

### createTheme (function)

Deep-merges overrides onto a base theme and returns a complete `TableTheme`.

`createTheme(base: TableTheme, overrides: object[]): TableTheme`: Deep-merges overrides onto a base theme and returns a complete `TableTheme`.

### toCssVars (function)

Tokens as CSS variables (for inline `style`, SSR or static CSS).

`toCssVars(theme: TableThemeTokens): Record<string, string>`: Tokens as CSS variables (for inline `style`, SSR or static CSS).

### functionalUpdate (function)

Resolves an `Updater`: calls it with the old value when it is a function.

`functionalUpdate(updater: Updater<T>, old: T): T`: Resolves an `Updater`: calls it with the old value when it is a function.

### sortingFns (variable)

### filterFns (variable)

### aggregationFns (variable)

### flexRender (function)

Renders a column template (`header` / `cell` / `footer`) with its context. Functions are called;
class components are created; values are rendered as-is.

`flexRender(template: ColumnTemplate<TProps>, props: TProps): ReactNode`: Renders a column template (`header` / `cell` / `footer`) with its context. Functions are called;
class components are created; values are rendered as-is.

### ActionButton (function)

An icon action button with a tooltip on top.

`ActionButton(__namedParameters: ActionButtonProps): Element`: An icon action button with a tooltip on top.

### RowActionsMenu (function)

Inline action buttons plus an overflow "⋮" menu.

`RowActionsMenu(__namedParameters: RowActionsMenuProps): Element`: Inline action buttons plus an overflow "⋮" menu.

### Tooltip (function)

Dark tooltip with portal positioning, shown on hover and focus (no dependency).

`Tooltip(props: object): Element`: Dark tooltip with portal positioning, shown on hover and focus (no dependency).

### Checkbox (function)

22px themed checkbox.

`Checkbox(props: object): Element`: 22px themed checkbox.

### Chip (function)

A small rounded label (optionally clickable / deletable).

`Chip(props: Omit<DetailedHTMLProps<HTMLAttributes<HTMLSpanElement>, HTMLSpanElement>, "onChange" | "onToggle"> & Record<template literal, string | number | boolean | undefined> & object): Element`: A small rounded label (optionally clickable / deletable).

### ChipList (function)

Wrapping chip list with a `+N` overflow chip.

`ChipList(__namedParameters: ChipListProps<T>): Element`: Wrapping chip list with a `+N` overflow chip.

### TruncatedText (function)

Truncated text with the full value in a tooltip.

`TruncatedText(__namedParameters: TruncatedTextProps): Element`: Truncated text with the full value in a tooltip.

### MultiLineList (function)

One item per line, for lists such as emails or phone numbers.

`MultiLineList(__namedParameters: MultiLineListProps): Element`: One item per line, for lists such as emails or phone numbers.

### TwoLineText (function)

A strong first line and a muted second line, for an address or a similar pair.

`TwoLineText(__namedParameters: TwoLineTextProps): Element`: A strong first line and a muted second line, for an address or a similar pair.

## Related

- [Columns](/react-tablekit/columns/)
- [Export](/react-tablekit/export/)
- [Theming](/react-tablekit/customization/theming/)

---

# TableSlots

> Every replaceable component, with the element it renders and the props it receives.

URL: https://react-tablekit.vercel.app/react-tablekit/api/slots/

A slot swaps one piece of the table for one of your own, while the table keeps owning the state
and the accessibility attributes. This list comes from the runtime slot metadata.

## Reference

| Slot | Default element |
| --- | --- |
| `ActiveFilterChips` | `<button>` |
| `Body` | `<tbody>` |
| `Button` | `<button>` |
| `Card` | `<button>` |
| `Cell` | `<td>` |
| `Checkbox` | `<button>` |
| `Chip` | `<button>` |
| `ColumnActionsButton` | `<button>` |
| `ColumnActionsMenu` | `<button>` |
| `ColumnsButton` | `<button>` |
| `ColumnsMenu` | `<button>` |
| `Container` | `<div>` |
| `DensityButton` | `<button>` |
| `DetailPanel` | `<div>` |
| `DetailRow` | `<tr>` |
| `Ellipsis` | `<button>` |
| `EmptyState` | `<tr>` |
| `ErrorBanner` | `<div>` |
| `ErrorState` | `<tr>` |
| `ExpandButton` | `<button>` |
| `ExportButton` | `<button>` |
| `FilterChip` | `<button>` |
| `FilterControl` | `<button>` |
| `FilterPanel` | `<button>` |
| `FilterRow` | `<tr>` |
| `FilterRowCell` | `<th>` |
| `FiltersButton` | `<button>` |
| `FirstButton` | `<button>` |
| `Foot` | `<tfoot>` |
| `FooterCell` | `<td>` |
| `FooterRow` | `<tr>` |
| `GroupCell` | `<td>` |
| `GroupRow` | `<tr>` |
| `Head` | `<thead>` |
| `HeaderCell` | `<th>` |
| `HeaderRow` | `<tr>` |
| `IconButton` | `<button>` |
| `LastButton` | `<button>` |
| `LoadMoreButton` | `<button>` |
| `LoadingOverlay` | `<div>` |
| `LoadingRow` | `<tr>` |
| `Menu` | `<button>` |
| `MenuItem` | `<button>` |
| `NextButton` | `<button>` |
| `PageButton` | `<button>` |
| `PageSizeSelect` | `<button>` |
| `Pagination` | `<nav>` |
| `Popover` | `<button>` |
| `PrevButton` | `<button>` |
| `ResizeHandle` | `<span>` |
| `Root` | `<div>` |
| `Row` | `<tr>` |
| `RowRange` | `<button>` |
| `SearchInput` | `<button>` |
| `Select` | `<button>` |
| `SelectionBar` | `<div>` |
| `SelectionCheckbox` | `<button>` |
| `SkeletonRows` | `<tr>` |
| `SortButton` | `<button>` |
| `SortIcon` | `<button>` |
| `Spinner` | `<button>` |
| `Table` | `<table>` |
| `TextInput` | `<button>` |
| `Toolbar` | `<div>` |
| `Tooltip` | `<button>` |

## Related

- [Slots](/react-tablekit/customization/slots/)
- [Overriding structure](/react-tablekit/customization/structure/)
- [Rows and cells](/react-tablekit/customization/rows-and-cells/)

---

# Handlers

> Every overridable interaction, with the intent it receives and what calling next does.

URL: https://react-tablekit.vercel.app/react-tablekit/api/handlers/

A handler is middleware around an interaction. Yours runs first and decides whether to call
`next`, which is how you observe, change or cancel what a click would have done.

## Reference

```tsx
<DataTable
  handlers={{
    onPageChange: async (ctx, next) => {
      if (await confirmLeave()) return next(ctx);
    },
  }}
/>
```

| Handler | Context | Default behaviour |
| --- | --- | --- |
| `onSortToggle` | `{ column, desc?, multi, event }` | column.toggleSorting(desc, multi) |
| `onGlobalFilterInput` | `{ value, event }` | setGlobalFilter(value), debounced |
| `onColumnFilterChange` | `{ column, value, operator }` | column.setFilterValue(…) |
| `onClearFilters` | `{}` | clearAllFilters() |
| `onPageChange` | `{ pageIndex, reason: 'button' \| 'keyboard' \| 'infinite' }` | setPageIndex |
| `onPageSizeChange` | `{ pageSize }` | setPageSize |
| `onRowClick` | `{ row, event }` | selection / expansion per options, then the onRowClick prop |
| `onRowDoubleClick` | `{ row, event }` | the onRowDoubleClick prop |
| `onRowSelect` | `{ row, value, range, event }` | row.toggleSelected (range-aware) |
| `onSelectAll` | `{ value, scope: 'page' \| 'all' }` | toggleAllPageRowsSelected / toggleAllRowsSelected |
| `onRowExpand` | `{ row, value }` | row.toggleExpanded, plus lazy children |
| `onColumnResize` | `{ column, size }` | setColumnSizing |
| `onColumnMove` | `{ columnId, toIndex }` | moveColumn |
| `onColumnPin` | `{ column, position }` | column.pin |
| `onColumnHide` | `{ column, visible }` | column.toggleVisibility |
| `onExport` | `{ scope, format }` | CSV generation and download |
| `onRefresh` | `{}` | table.refresh() |
| `onHotkey` | `{ key, event }` | focuses and selects the search input |
| `onCellActivate` | `{ cell, event }` | activates the focused cell (keyboard grid) |

## Related

- [Handler middleware](/react-tablekit/customization/handlers/)
- [Sorting](/react-tablekit/sorting/)
- [Row selection](/react-tablekit/row-selection/)

---

# Theme tokens

> Every design token, its CSS custom property and its value in each preset.

URL: https://react-tablekit.vercel.app/react-tablekit/api/theme-tokens/

Every visual value the table uses is one of these. Override a token and everything that reads it
follows, which is why theming needs no class names.

## Reference

| Token | CSS variable | light | classic | dark |
| --- | --- | --- | --- | --- |
| font.family | `--tk-font-family` | `inherit` | `"Open Sans Variable", "Open Sans", Arial, sans-serif` | `inherit` |
| font.size | `--tk-font-size` | `14px` | `14px` | `14px` |
| font.sizeSm | `--tk-font-size-sm` | `0.875rem` | `0.875rem` | `0.875rem` |
| font.sizeXs | `--tk-font-size-xs` | `0.75rem` | `0.75rem` | `0.75rem` |
| font.lineHeight | `--tk-line-height` | `1.5` | `1.5` | `1.5` |
| font.weightRegular | `--tk-font-weight` | `400` | `400` | `400` |
| font.weightStrong | `--tk-font-weight-strong` | `600` | `600` | `600` |
| font.numeric | `--tk-font-variant-numeric` | `tabular-nums` | `tabular-nums` | `tabular-nums` |
| color.surface | `--tk-color-surface` | `#FFFFFF` | `#FFFFFF` | `#0F1115` |
| color.surfaceMuted | `--tk-color-surface-muted` | `#FAFAFA` | `#FAFAFA` | `#161A20` |
| color.surfaceRaised | `--tk-color-surface-raised` | `#FFFFFF` | `#FFFFFF` | `#1B2028` |
| color.surfaceSubtle | `--tk-color-surface-subtle` | `#F5F5F5` | `#F5F5F5` | `#1B2028` |
| color.text | `--tk-color-text` | `#252B37` | `#252B37` | `#E6E8EB` |
| color.textMuted | `--tk-color-text-muted` | `#6B7079` | `#717680` | `#9AA1AC` |
| color.textDisabled | `--tk-color-text-disabled` | `#A4A7AE` | `#A4A7AE` | `#6B7280` |
| color.textOnAccent | `--tk-color-text-on-accent` | `#FFFFFF` | `#FFFFFF` | `#0F1115` |
| color.border | `--tk-color-border` | `#E9EAEB` | `#E9EAEB` | `#262B33` |
| color.borderStrong | `--tk-color-border-strong` | `#D5D7DA` | `#D5D7DA` | `#3A414C` |
| color.accent | `--tk-color-accent` | `#2196F3` | `#2196F3` | `#4DA3FF` |
| color.accentSoft | `--tk-color-accent-soft` | `#369AE91A` | `#369AE91A` | `#4DA3FF26` |
| color.accentBorder | `--tk-color-accent-border` | `#2196F380` | `#2196F380` | `#4DA3FF80` |
| color.danger | `--tk-color-danger` | `#F04438` | `#F04438` | `#FF6B61` |
| color.dangerSoft | `--tk-color-danger-soft` | `#F044381A` | `#F044381A` | `#FF6B6126` |
| color.dangerBorder | `--tk-color-danger-border` | `#F0443880` | `#F0443880` | `#FF6B6180` |
| color.highlight | `--tk-color-highlight` | `#FFF3BF` | `#FFF3BF` | `#5C4A00` |
| header.bg | `--tk-header-bg` | `var(--tk-color-surface-muted)` | `var(--tk-color-surface-muted)` | `var(--tk-color-surface-muted)` |
| header.color | `--tk-header-color` | `var(--tk-color-text-muted)` | `var(--tk-color-text-muted)` | `var(--tk-color-text-muted)` |
| header.fontSize | `--tk-header-font-size` | `var(--tk-font-size-sm)` | `var(--tk-font-size-sm)` | `var(--tk-font-size-sm)` |
| header.fontWeight | `--tk-header-font-weight` | `600` | `600` | `600` |
| header.textTransform | `--tk-header-text-transform` | `none` | `none` | `none` |
| header.letterSpacing | `--tk-header-letter-spacing` | `normal` | `normal` | `normal` |
| header.sortIconIdleOpacity | `--tk-sort-icon-idle-opacity` | `0.35` | `0` | `0.35` |
| row.bg | `--tk-row-bg-default` | `var(--tk-color-surface)` | `var(--tk-color-surface)` | `var(--tk-color-surface)` |
| row.hoverBg | `--tk-row-hover-bg` | `#F5F5F5` | `transparent` | `#1B2028` |
| row.selectedBg | `--tk-row-selected-bg` | `#EAF6FF` | `#EAF6FF` | `#13263B` |
| row.selectedHoverBg | `--tk-row-selected-hover-bg` | `#DDEFFD` | `#EAF6FF` | `#17304A` |
| row.stripedBg | `--tk-row-striped-bg` | `#FAFAFA` | `#FAFAFA` | `#13161B` |
| row.disabledOpacity | `--tk-row-disabled-opacity` | `0.5` | `0.5` | `0.5` |
| row.divider | `--tk-row-divider` | `1px solid var(--tk-color-border)` | `1px solid var(--tk-color-border)` | `1px solid var(--tk-color-border)` |
| space.cellX | `--tk-cell-px` | `20px` | `20px` | `20px` |
| space.cellXSm | `--tk-cell-px-sm` | `12px` | `12px` | `12px` |
| space.cellY | `--tk-cell-py` | `12px` | `12px` | `12px` |
| space.headerY | `--tk-header-py` | `8px` | `8px` | `8px` |
| space.headerYSm | `--tk-header-py-sm` | `12px` | `12px` | `12px` |
| space.toolbarGap | `--tk-toolbar-gap` | `12px` | `12px` | `12px` |
| space.toolbarY | `--tk-toolbar-py` | `16px` | `16px` | `16px` |
| space.treeIndent | `--tk-tree-indent` | `20px` | `20px` | `20px` |
| space.stateY | `--tk-state-py` | `16px` | `16px` | `16px` |
| space.densityCompact | `--tk-density-compact` | `0.5` | `0.5` | `0.5` |
| space.densityComfortable | `--tk-density-comfortable` | `1.33` | `1.33` | `1.33` |
| radius.container | `--tk-radius` | `8px` | `8px` | `8px` |
| radius.control | `--tk-radius-sm` | `4px` | `4px` | `4px` |
| radius.chip | `--tk-radius-chip` | `16px` | `16px` | `16px` |
| container.border | `--tk-container-border` | `1px solid var(--tk-color-border)` | `1px solid #E9EAEB` | `1px solid var(--tk-color-border)` |
| container.shadow | `--tk-container-shadow` | `none` | `none` | `none` |
| container.minTableWidth | `--tk-table-min-width` | `auto` | `650px` | `auto` |
| pinned.shadowLeft | `--tk-pinned-shadow-left` | `4px 0 6px -4px rgb(0 0 0 / 0.12)` | `none` | `4px 0 6px -4px rgb(0 0 0 / 0.6)` |
| pinned.shadowRight | `--tk-pinned-shadow-right` | `-4px 0 6px -4px rgb(0 0 0 / 0.12)` | `none` | `-4px 0 6px -4px rgb(0 0 0 / 0.6)` |
| overlay.bg | `--tk-overlay-bg` | `rgb(255 255 255 / 0.7)` | `rgba(255,255,255,0.7)` | `rgb(15 17 21 / 0.6)` |
| overlay.spinnerSize | `--tk-spinner-size` | `40px` | `40px` | `40px` |
| overlay.spinnerColor | `--tk-spinner-color` | `var(--tk-color-accent)` | `#2196F3` | `var(--tk-color-accent)` |
| focus.ring | `--tk-focus-ring` | `0 0 0 2px var(--tk-color-surface), 0 0 0 4px var(--tk-color-accent)` | `0 0 0 2px #FFFFFF, 0 0 0 4px #2196F3` | `0 0 0 2px var(--tk-color-surface), 0 0 0 4px var(--tk-color-accent)` |
| control.height | `--tk-control-height` | `40px` | `40px` | `40px` |
| control.checkboxSize | `--tk-checkbox-size` | `22px` | `22px` | `22px` |
| control.checkboxBorder | `--tk-checkbox-border` | `#D5D7DA` | `#D5D7DA` | `#4B5563` |
| control.searchWidth | `--tk-search-width` | `500px` | `500px` | `500px` |
| action.color | `--tk-action-color` | `var(--tk-color-accent)` | `var(--tk-color-accent)` | `var(--tk-color-accent)` |
| action.hoverBg | `--tk-action-hover-bg` | `var(--tk-color-accent-soft)` | `var(--tk-color-accent-soft)` | `var(--tk-color-accent-soft)` |
| action.hoverBorder | `--tk-action-hover-border` | `var(--tk-color-accent-border)` | `var(--tk-color-accent-border)` | `var(--tk-color-accent-border)` |
| action.radius | `--tk-action-radius` | `4px` | `4px` | `4px` |
| action.padding | `--tk-action-padding` | `6px 4px` | `6px 4px` | `6px 4px` |
| tooltip.bg | `--tk-tooltip-bg` | `#181D27` | `#181D27` | `#E6E8EB` |
| tooltip.color | `--tk-tooltip-color` | `#FFFFFF` | `#FFFFFF` | `#0F1115` |
| tooltip.radius | `--tk-tooltip-radius` | `8px` | `8px` | `8px` |
| tooltip.padding | `--tk-tooltip-padding` | `8px 12px` | `8px 12px` | `8px 12px` |
| tooltip.fontSize | `--tk-tooltip-font-size` | `0.75rem` | `0.75rem` | `0.75rem` |
| pagination.padding | `--tk-pagination-padding` | `10px 20px` | `10px 20px` | `10px 20px` |
| pagination.navPadding | `--tk-page-nav-padding` | `6px 12px` | `6px 12px` | `6px 12px` |
| pagination.itemSize | `--tk-page-item-size` | `40px` | `40px` | `40px` |
| pagination.compactItemSize | `--tk-page-item-size-compact` | `26px` | `26px` | `26px` |
| pagination.itemGap | `--tk-page-item-gap` | `2px` | `2px` | `2px` |
| pagination.itemRadius | `--tk-page-item-radius` | `4px` | `4px` | `4px` |
| pagination.itemPadding | `--tk-page-item-padding` | `10px 16px` | `10px 16px` | `10px 16px` |
| pagination.itemColor | `--tk-page-item-color` | `#717680` | `#717680` | `#9AA1AC` |
| pagination.itemHoverBg | `--tk-page-item-hover-bg` | `#F5F5F5` | `#F5F5F5` | `#1B2028` |
| pagination.activeBg | `--tk-page-item-active-bg` | `#F5F5F5` | `#F5F5F5` | `#1B2028` |
| pagination.activeColor | `--tk-page-item-active-color` | `#252B37` | `#252B37` | `#E6E8EB` |
| pagination.activeBorder | `--tk-page-item-active-border` | `#E9EAEB` | `#E9EAEB` | `#262B33` |
| pagination.activeHoverBg | `--tk-page-item-active-hover-bg` | `#D5D7DA` | `#D5D7DA` | `#262B33` |
| pagination.navBorder | `--tk-page-nav-border` | `#D5D7DA` | `#D5D7DA` | `#3A414C` |
| pagination.navColor | `--tk-page-nav-color` | `#252B37` | `#252B37` | `#E6E8EB` |
| pagination.navIcon | `--tk-page-nav-icon` | `#A4A7AE` | `#A4A7AE` | `#9AA1AC` |
| pagination.navHoverBorder | `--tk-page-nav-hover-border` | `#252B37` | `#252B37` | `#E6E8EB` |
| pagination.navHoverBg | `--tk-page-nav-hover-bg` | `#F5F5F5` | `#F5F5F5` | `#1B2028` |
| pagination.navDisabledBorder | `--tk-page-nav-disabled-border` | `#E9EAEB` | `#E9EAEB` | `#262B33` |
| pagination.navDisabledColor | `--tk-page-nav-disabled-color` | `#A4A7AE` | `#A4A7AE` | `#6B7280` |
| pagination.navDisabledIcon | `--tk-page-nav-disabled-icon` | `#D5D7DA` | `#D5D7DA` | `#4B5563` |
| z.pinned | `--tk-z-pinned` | `1` | `1` | `1` |
| z.header | `--tk-z-header` | `2` | `2` | `2` |
| z.pinnedHeader | `--tk-z-pinned-header` | `3` | `3` | `3` |
| z.overlay | `--tk-z-overlay` | `10` | `10` | `10` |
| z.popover | `--tk-z-popover` | `1300` | `1300` | `1300` |
| motion.duration | `--tk-motion-duration` | `200ms` | `200ms` | `200ms` |
| motion.easing | `--tk-motion-easing` | `cubic-bezier(.2,0,0,1)` | `cubic-bezier(.2,0,0,1)` | `cubic-bezier(.2,0,0,1)` |

## Related

- [Design tokens](/react-tablekit/customization/tokens/)
- [Theming](/react-tablekit/customization/theming/)
- [Theme editor](/react-tablekit/demos/theme-editor/)

---

# Localization keys

> Every string the table can render, with its English default.

URL: https://react-tablekit.vercel.app/react-tablekit/api/localization-keys/

No English is hard-coded. This is the complete list, so a translation can be checked against it
rather than discovered by clicking around.

## Reference

| Key | English default |
| --- | --- |
| `announceMove` | Moved {column} to position {position} of {count} |
| `announcePage` | Page {page} of {count} |
| `announceResults` | {count} results |
| `announceSelection` | {count} selected |
| `announceSort` | Sorted by {column}, {direction} |
| `announceSortCleared` | Sorting cleared |
| `any` | Any |
| `applyFilters` | Apply |
| `autosize` | Autosize |
| `cancel` | Cancel |
| `clearAllFilters` | Clear all |
| `clearSearch` | Clear search |
| `clearSelection` | Clear |
| `clearSort` | Clear sort |
| `collapseAll` | Collapse all |
| `collapseRow` | Collapse row |
| `columnActions` | Column actions |
| `columns` | Columns |
| `copyToClipboard` | Copy to clipboard |
| `datePreset` | Preset |
| `datePresets.last30Days` | Last 30 days |
| `datePresets.last7Days` | Last 7 days |
| `datePresets.lastMonth` | Last month |
| `datePresets.thisMonth` | This month |
| `datePresets.thisYear` | This year |
| `datePresets.today` | Today |
| `density` | Density |
| `densityComfortable` | Comfortable |
| `densityCompact` | Compact |
| `densityStandard` | Standard |
| `deselectAll` | Deselect all |
| `dismiss` | Dismiss |
| `dropToGroup` | Drag a column here to group |
| `errorTitle` | Something went wrong while loading data. |
| `expandAll` | Expand all |
| `expandRow` | Expand row |
| `export` | Export |
| `exportAll` | Export CSV (all matching) |
| `exportPage` | Export CSV (current page) |
| `exportProgress` | Exporting… {percent}% |
| `exportSelected` | Export selected rows |
| `filterColumn` | Filter… |
| `filters` | Filters |
| `filtersActive` | {count} filters |
| `first` | First page |
| `from` | From |
| `groupBy` | Group by this column |
| `groupedBy` | {column}: {value} |
| `hideAll` | Hide all |
| `hideColumn` | Hide column |
| `last` | Last page |
| `loadMore` | Load more ({remaining} remaining) |
| `loaded` | Loaded |
| `loading` | Loading... |
| `many` | many |
| `max` | Max |
| `min` | Min |
| `moreActions` | More actions |
| `moreOptions` | +{count} more |
| `moveColumnLeft` | Move left |
| `moveColumnRight` | Move right |
| `next` | Next |
| `no` | No |
| `noOptions` | No options |
| `noResults` | No results match your filters |
| `noRows` | No rows |
| `operator` | Operator |
| `operators.after` | After |
| `operators.before` | Before |
| `operators.between` | Between |
| `operators.contains` | Contains |
| `operators.empty` | Is empty |
| `operators.endsWith` | Ends with |
| `operators.equals` | Equals |
| `operators.gt` | Greater than |
| `operators.gte` | Greater than or equal |
| `operators.in` | Is any of |
| `operators.lt` | Less than |
| `operators.lte` | Less than or equal |
| `operators.neq` | Not equal |
| `operators.notContains` | Does not contain |
| `operators.notEmpty` | Is not empty |
| `operators.notIn` | Is none of |
| `operators.on` | On |
| `operators.startsWith` | Starts with |
| `page` | Page {page} |
| `pageOf` | Page {page} of {count} |
| `pagination` | Pagination |
| `pinLeft` | Pin left |
| `pinRight` | Pin right |
| `previous` | Previous |
| `resetColumns` | Reset |
| `resetSize` | Reset size |
| `resizeColumn` | Resize column |
| `retry` | Retry |
| `rowActions` | Actions |
| `rowNumber` | # |
| `rowRange` | {from}–{to} of {total} |
| `rowsPerPage` | Rows per page: |
| `search` | Search |
| `searchColumns` | Search columns |
| `searchHotkeyHint` | {key} |
| `searchPlaceholder` | Search… |
| `selectAll` | Select all |
| `selectAllMatching` | Select all {total} matching |
| `selectAllOnPage` | Select all rows on this page |
| `selectRow` | Select row |
| `selectedCount` | {count} selected |
| `showAll` | Show all |
| `sortAscending` | Sort ascending |
| `sortDescending` | Sort descending |
| `sortPriority` | priority {index} |
| `sortedAscending` | sorted ascending |
| `sortedDescending` | sorted descending |
| `to` | To |
| `toolbar` | Table controls |
| `ungroup` | Ungroup |
| `unpin` | Unpin |
| `yes` | Yes |

## Related

- [Localization](/react-tablekit/localization/)
- [i18next](/react-tablekit/integrations/i18next/)
- [Icons](/react-tablekit/api/icons/)

---

# Icons

> Every icon the table renders, and the name to pass when replacing one.

URL: https://react-tablekit.vercel.app/react-tablekit/api/icons/

Icons are a dependency-free set rendered as inline SVG. Replace any of them by name through the
`icons` prop: one entry, not a fork.

## Reference

Icon names: `check`, `chevronDown`, `chevronLeft`, `chevronRight`, `clear`, `close`, `collapse`, `collapseAll`, `columns`, `density`, `dragHandle`, `error`, `expand`, `expandAll`, `export`, `filter`, `filterActive`, `first`, `hide`, `indeterminate`, `info`, `last`, `more`, `next`, `pinLeft`, `pinRight`, `prev`, `search`, `sortAsc`, `sortDesc`, `sortNone`, `spinner`, `unpin`.

## Related

- [Localization](/react-tablekit/localization/)
- [Slots](/react-tablekit/customization/slots/)
- [Theming](/react-tablekit/customization/theming/)

---

# How to customize

> Nine levels of customization, from a class name to replacing the renderer entirely.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/

Customization here is a ladder, not a switch. Most tables stop at the second rung. The higher ones exist so that needing something unusual never means forking the library.

## The levels

| Level | What you change                   | Where                                                                                                     |
| ----- | --------------------------------- | --------------------------------------------------------------------------------------------------------- |
| 0     | Props and option objects          | Every capability page                                                                                     |
| 1     | Tokens and class names            | [Theming](/react-tablekit/customization/theming/), [Design tokens](/react-tablekit/customization/tokens/) |
| 2     | Cell, header and footer rendering | [Cell building blocks](/react-tablekit/customization/cell-building-blocks/)                               |
| 3     | Row rendering and props           | [Rows and cells](/react-tablekit/customization/rows-and-cells/)                                           |
| 4     | Whole parts replaced              | [Slots](/react-tablekit/customization/slots/)                                                             |
| 5     | Interaction middleware            | [Handler middleware](/react-tablekit/customization/handlers/)                                             |
| 6     | Layout composition                | [Overriding structure](/react-tablekit/customization/structure/)                                          |
| 7     | The engine without the UI         | [Headless](/react-tablekit/headless/)                                                                     |
| 8     | The core without React            | [Headless](/react-tablekit/headless/)                                                                     |

## Choosing a level

Go as low as the problem allows. A colour is a token, not a slot. A different empty state is a slot, not a fork. If you find yourself reimplementing behaviour that already exists in order to change how it looks, you have gone one level too high.

## Recipes

Task-shaped walkthroughs live in [Recipes](/react-tablekit/customization/recipes/).

---

# Theming

> Pick a preset, override the tokens you disagree with, and get dark mode without a second stylesheet.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/theming/

## Basics

**Example: Theme presets** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/theming/demo-presets)

```tsx
import { useState } from 'react';
import { DataTable, presets } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);
const PRESET_NAMES = Object.keys(presets) as (keyof typeof presets)[];
const SCHEMES = ['light', 'dark', 'auto'] as const;

/**
 * The five built-in presets. The preset supplies the token values; `colorScheme`
 * decides whether the light or the dark half of it is used; `'auto'` follows the OS.
 */
export default function ThemingPresetsExample() {
  const [preset, setPreset] = useState<keyof typeof presets>('light');
  const [scheme, setScheme] = useState<(typeof SCHEMES)[number]>('light');

  return (
    <div className="example-stack">
      <div className="example-controls">
        <label className="site-field">
          <span>Preset</span>
          <select
            value={preset}
            onChange={(e) => setPreset(e.target.value as keyof typeof presets)}
          >
            {PRESET_NAMES.map((name) => (
              <option key={name} value={name}>
                {name}
              </option>
            ))}
          </select>
        </label>
        <label className="site-field">
          <span>Colour scheme</span>
          <select
            value={scheme}
            onChange={(e) => setScheme(e.target.value as (typeof SCHEMES)[number])}
          >
            {SCHEMES.map((name) => (
              <option key={name} value={name}>
                {name}
              </option>
            ))}
          </select>
        </label>
      </div>

      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        theme={presets[preset]}
        colorScheme={scheme}
        enableRowSelection
        enableDensityToggle
        initialState={{ pagination: { pageIndex: 0, pageSize: 6 } }}
      />
    </div>
  );
}
```

A theme is a set of CSS custom properties. The presets are starting points, not a fork in the
road: you can take one and change any part of it.

```tsx
import '@kingtech/react-tablekit/styles.css';
import '@kingtech/react-tablekit/presets/classic.css';
```

`classic` is dense and quiet, made for information-heavy back-office screens. `light` is the
modern default with more air. Both are the same component and the same markup.

### Changing values

**Example: Custom theme** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/theming/demo-custom)

```tsx
import { useState } from 'react';
import { createTheme, DataTable, lightTheme } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/** A brand theme: start from a preset and override only what differs. */
const brand = createTheme(lightTheme, {
  color: { accent: '#7C3AED', surface: '#FDFCFF' },
  header: { bg: '#F3EEFF', color: '#3B2A63' },
  radius: { container: '12px' },
});

/**
 * Three ways to change the look:
 *
 * - `theme` with `createTheme`: typed, and the same object works in every table;
 * - a CSS class that sets the `--tk-*` variables, with no JavaScript involved;
 * - `unstyled`, which drops the visual layer entirely and keeps the structure, so a
 *   utility framework or a design system can own every class.
 */
export default function ThemingCustomExample() {
  const [mode, setMode] = useState<'theme' | 'css' | 'unstyled'>('theme');

  return (
    <div className="example-stack">
      <div className="example-controls" role="radiogroup" aria-label="Styling approach">
        {(['theme', 'css', 'unstyled'] as const).map((value) => (
          <label key={value} className="site-field site-field--inline">
            <input
              type="radio"
              name="theming-custom-mode"
              value={value}
              checked={mode === value}
              onChange={() => setMode(value)}
            />
            <span>
              {value === 'theme' ? 'createTheme()' : value === 'css' ? 'CSS variables' : 'unstyled'}
            </span>
          </label>
        ))}
      </div>

      {/* The CSS variables below live in the site's stylesheet as `.demo-brand`. */}
      <div className={mode === 'css' ? 'demo-brand' : undefined}>
        <DataTable<DemoPerson>
          aria-label="People"
          data={data}
          columns={peopleColumns}
          getRowId={(p) => p.id}
          {...(mode === 'theme' ? { theme: brand } : {})}
          unstyled={mode === 'unstyled'}
          enableRowSelection
          initialState={{ pagination: { pageIndex: 0, pageSize: 6 } }}
        />
      </div>
    </div>
  );
}
```

`createTheme` merges your overrides onto a preset and returns a theme object; `toCssVars` turns
one into the custom properties, so a theme can live in a stylesheet instead of a prop:

```tsx
const brand = createTheme(lightTheme, {
  color: { accent: '#7c3aed' },
  header: { bg: '#faf5ff' },
  radius: { container: '12px' },
});

<DataTable theme={brand} data={rows} columns={columns} />;
```

Nothing here is class-name surgery. Because every value is a variable, an override reaches
everything that reads it (the focus ring, the selected row, the spinner) without you finding
each one.

### Dark mode

`colorScheme` follows the system by default and can be forced. `darkTheme` supplies the dark
values, so light and dark are one configuration rather than two stylesheets:

```tsx
<DataTable theme={brand} darkTheme={brandDark} colorScheme="auto" />
```

### Scoping

Tokens cascade, so a theme can also be applied by an ancestor (a section, a dialog, a preview
pane) and every table inside it follows. The stylesheet is inside `@layer tablekit`, which means
your own rules win without `!important`.

## Customization

`unstyled` drops the presentational CSS and keeps the structural rules, which is the starting
point for a design system that supplies all of its own visuals. See [Overriding
structure](/react-tablekit/customization/structure/).

The [theme editor](/react-tablekit/demos/theme-editor/) edits every token live and hands back CSS
or a theme object.

## Limitations

- **Tokens describe values, not layout.** Moving a control is a [slot](/react-tablekit/customization/slots/)
  or a composable part, not a token.
- **A preset is a whole set.** Importing two preset stylesheets means the later one wins for every
  token it defines; use `createTheme` to combine deliberately.
- **`colorScheme="auto"` depends on the OS**, so a screenshot test should pin it rather than
  inherit whatever the machine prefers.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `theme`, `darkTheme`,
  `colorScheme`, `unstyled`, `className`, `classNames`, `styles`.
- [Utilities](/react-tablekit/api/utilities/): `createTheme`, `toCssVars`.
- [Theme tokens](/react-tablekit/api/theme-tokens/): every token and its value per preset.

---

# Design tokens

> What the token families control, how they cascade, and where to override them so a change reaches the whole table.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/tokens/

## Basics

Every visual value in the table is a CSS custom property named `--tk-*`. There are no hard-coded
colours, no magic pixel values in the component tree, and nothing that can only be changed by
overriding a class.

```css
.my-app .tk-root {
  --tk-color-accent: #7c3aed;
  --tk-row-height: 44px;
  --tk-radius-container: 12px;
}
```

That is the whole mechanism. Set a variable anywhere in the cascade above the table and the table
follows.

### The families

- **Colour**: `--tk-color-*` for the palette, plus the semantic pairs the rows use:
  `--tk-row-bg`, `--tk-row-hover-bg`, `--tk-row-selected-bg`, `--tk-row-stripe-bg`,
  `--tk-header-bg`.
- **Spacing**: `--tk-cell-py` and `--tk-cell-px` and their header counterparts. These are what
  [density](/react-tablekit/density/) switches between.
- **Typography**: `--tk-font-family`, `--tk-font-size`, `--tk-font-weight-header`.
- **Border and radius**: `--tk-border-color`, `--tk-border-width`, `--tk-radius-container`.
- **Elevation**: `--tk-pinned-shadow`, `--tk-overlay-bg`, `--tk-focus-ring`.
- **Layering**: `--tk-z-pinned`, `--tk-z-pinned-header`, `--tk-z-overlay`, for when the table
  sits inside your own stacking context.

The generated list with every value per preset is on [Theme
tokens](/react-tablekit/api/theme-tokens/).

### Where to set them

Scope decides reach. On `:root` a token is global; on a wrapper it covers a section; on the table
it covers one instance. A `[data-density='compact']` selector redefines what compact means
everywhere at once.

```css
[data-density='compact'] {
  --tk-cell-py: 4px;
  --tk-cell-px: 8px;
}
```

### The cascade layer

The stylesheet lives in `@layer tablekit`, which is deliberately low priority: an unlayered rule
of yours beats it without `!important` and without counting specificity.

## Customization

`toCssVars(theme)` produces the declarations from a theme object, so the same values can be used
in a stylesheet, in a style attribute, or written to a file at build time. The [theme
editor](/react-tablekit/demos/theme-editor/) is the fastest way to find the token you want.

## Limitations

- **A token is a value, not a rule.** There is no token for "hide the border on the last row".
  That is CSS you write.
- **Custom properties do not animate like colours** unless registered with `@property`; a
  transition between two token values may step rather than blend.
- **Renaming tokens is a breaking change**, so they follow the same
  [versioning](/react-tablekit/getting-started/versions/) rules as the TypeScript API.

## API

- [Theme tokens](/react-tablekit/api/theme-tokens/): the complete generated catalogue.
- [Utilities](/react-tablekit/api/utilities/): `createTheme`, `toCssVars`.
- [`DataTableProps`](/react-tablekit/api/data-table-props/): `theme`, `darkTheme`, `unstyled`.

---

# Overriding structure

> Unstyled mode, primitive slots and composable parts: how to put a design system's own components in charge without losing the engine.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/structure/

## Basics

**Example: Design-system adapter** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/structure/demo-basics)

```tsx
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Re-skinning without replacing anything. `unstyled` drops the visual layer but keeps
 * the markup and behaviour, and `classNames` puts your own class on each part, the same shape
 * a utility framework such as Tailwind expects. The keys are the camelCase slot names.
 *
 * A class can also be a function of the row, which is how the zebra striping and the muted
 * suspended rows below are done.
 */
export default function SlotsDesignSystemExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      unstyled
      classNames={{
        root: 'ds',
        toolbar: 'ds-toolbar',
        table: 'ds-table',
        headerCell: 'ds-th',
        cell: 'ds-td',
        row: ({ row }) =>
          [
            'ds-tr',
            row.index % 2 === 1 ? 'ds-tr--alt' : '',
            row.original.status === 'suspended' ? 'ds-tr--muted' : '',
          ]
            .filter(Boolean)
            .join(' '),
        pagination: 'ds-pager',
      }}
      enableRowSelection
      initialState={{ pagination: { pageIndex: 0, pageSize: 6 } }}
    />
  );
}
```

There are three levels of taking over the markup, and most projects need only the first.

**Unstyled.** `unstyled` drops the presentational CSS and keeps the structural rules: sticky
positioning, pinned offsets, the scroll container. You get a correct table with no opinions about
how it looks.

```tsx
<DataTable unstyled slots={designSystemSlots} data={rows} columns={columns} />
```

**Primitives.** Six [slots](/react-tablekit/customization/slots/), `Button`, `IconButton`,
`Checkbox`, `Select`, `TextInput` and `Menu`, account for nearly every control in the table.
Replacing those is what makes an adapter small:

```tsx
export const designSystemSlots = {
  Button: DsButton,
  IconButton: DsIconButton,
  Checkbox: DsCheckbox,
  Select: DsSelect,
  TextInput: DsTextField,
  Menu: DsMenu,
};
```

**Layout.** When the arrangement itself is wrong (search in the page header, filters in a
sidebar, pagination in a sticky footer), use `DataTable.Root` and the composable parts. The table
instance is shared, so every piece stays in sync:

```tsx
const table = useDataTable({ data, columns });

<DataTable.Root table={table}>
  <DataTable.Search table={table} />
  <DataTable.Table />
  <DataTable.Pagination />
</DataTable.Root>;
```

### Shared defaults

`TableDefaultsProvider` applies slots, localization, icons and handlers to every table beneath
it, so an adapter is configured once for an application rather than per table. Props on a table
still win over the provider.

### All the way down

If even the table element is wrong for what you are building (cards, a list, a chart),
[headless](/react-tablekit/headless/) keeps the engine and gives up the rendering entirely.

## Customization

The levels compose. An adapter usually means: unstyled, the six primitives, the provider, and
tokens for what is left. That is a file, not a fork.

## Limitations

- **Unstyled is not style-free.** The structural rules remain, because sticky headers and pinned
  columns are layout, not decoration.
- **Composable parts still need the instance.** They are not context-free; passing the table is
  what keeps them consistent.
- **Replacing primitives replaces their accessibility too.** A `Checkbox` that does not support
  the indeterminate state breaks tri-state select-all.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `unstyled`, `slots`, `slotProps`,
  `classNames`, `styles`, `handlers`, `icons`, `localization`.
- [TableSlots](/react-tablekit/api/slots/): the full list, primitives included.
- [Hooks](/react-tablekit/api/hooks/): `useDataTable` for the composable form.

---

# Slots

> Replace any part of the table with your own component, and wrap the default rather than reimplementing it.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/slots/

## Basics

**Example: Custom slot components** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/slots/demo-basics)

```tsx
import { DataTable, type TableSlots } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Four replaced slots. Every slot receives the state it needs as props, so a
 * replacement is a plain component; there is no registry to configure and no CSS to fight.
 */
const slots: Partial<TableSlots<DemoPerson>> = {
  /** A search box with its own wrapper; `inputRef` must be forwarded for the hotkey to focus it. */
  SearchInput: ({ value, onChange, onClear, placeholder, label, clearLabel, inputRef }) => (
    <div className="demo-search">
      <input
        ref={inputRef}
        type="search"
        aria-label={label}
        placeholder={placeholder}
        value={value}
        onChange={(e) => onChange(e.target.value)}
      />
      {value && (
        <button type="button" onClick={onClear}>
          {clearLabel}
        </button>
      )}
    </div>
  ),

  /**
   * Wrapping the default is unnecessary here: the header cell is rebuilt, sorting included.
   *
   * Only presentation is taken from the slot props. The default `<th>` is itself clickable to
   * sort, so spreading the rest would leave those handlers on the cell, and a real button inside
   * a clickable cell is a nested interactive control, which is an axe violation.
   */
  HeaderCell: ({ header, isSorted, canSort, children, className, style }) => (
    <th
      className={className}
      style={style}
      scope="col"
      aria-sort={isSorted === 'asc' ? 'ascending' : isSorted === 'desc' ? 'descending' : undefined}
    >
      {/*
        `children` is already the sort control when the column is sortable: the default header
        builds a button around the label. Wrapping it in another button would nest one interactive
        control inside another, so the custom indicator goes beside it rather than around it.
      */}
      <span className="demo-th-button">
        {children}
        {canSort && (
          <span aria-hidden="true">
            {isSorted === 'asc' ? '▲' : isSorted === 'desc' ? '▼' : '·'}
          </span>
        )}
      </span>
      <span className="demo-th-id">{header.column.id}</span>
    </th>
  ),

  /** `reason` distinguishes "there is no data" from "nothing matched the filters". */
  EmptyState: ({ reason, onClearFilters, colSpan, ...rest }) => (
    <tr {...rest}>
      <td colSpan={colSpan} className="demo-empty">
        <p>{reason === 'noRows' ? 'Nobody here yet.' : 'No one matches those filters.'}</p>
        {onClearFilters && (
          <button type="button" onClick={onClearFilters}>
            Clear filters
          </button>
        )}
      </td>
    </tr>
  ),

  /** A minimal pager; the slot hands over every command it needs. */
  Pagination: ({
    pageIndex,
    pageCount,
    canPrev,
    canNext,
    prev,
    next,
    style,
    'aria-label': label,
  }) => (
    <nav style={style} aria-label={label} className="demo-pager">
      <button type="button" onClick={prev} disabled={!canPrev}>
        ← Previous
      </button>
      <span>
        Page {pageIndex + 1} of {pageCount}
      </span>
      <button type="button" onClick={next} disabled={!canNext}>
        Next →
      </button>
    </nav>
  ),
};

/** Replacing the search box, the header cells, the empty state and the pager. */
export default function SlotsCustomComponentsExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={peopleColumns}
      getRowId={(p) => p.id}
      slots={slots}
      enableGlobalFilter
      initialState={{ pagination: { pageIndex: 0, pageSize: 6 } }}
    />
  );
}
```

A slot is a named piece of the table you can swap for a component of your own. There are 65 of
them, from `Root` down to `SortIcon`, and each receives fully computed props (classes, ARIA
attributes, event handlers, state):

```tsx
<DataTable
  slots={{
    Button: MyButton,
    Checkbox: MyCheckbox,
    Menu: MyMenu,
  }}
/>
```

Those three are worth noticing: about six primitive slots (button, icon button, checkbox, select,
text input, menu) are enough to make the whole table look like your design system, because
everything else is built from them.

### Spread the props

A replacement that spreads what it receives keeps every behaviour it was given:

```tsx
const MyHeaderCell: TableSlots<Person>['HeaderCell'] = (props) => (
  <th {...props} className={cx(props.className, 'my-header')} />
);
```

Drop the spread and you drop the sort handler, the `aria-sort`, the pinning offset and the data
attributes with it.

### Wrapping the default

`useTableSlots().defaults` gives you the original implementation, so an override can add rather
than replace:

```tsx
function HeaderCellWithHint(props) {
  const { HeaderCell: Default } = useTableSlots().defaults;
  return (
    <Default {...props}>
      {props.children}
      <InfoIcon title={props.column.columnDef.headerTooltip} />
    </Default>
  );
}
```

This matters more than it looks: the default header cell already contains the sort button, so
rendering `children` _beside_ your addition rather than wrapping it is what keeps a button out of
a button.

### Lighter than a slot

Most of the time you do not need a component. `classNames`, `styles` and `slotProps` take the
same slot names in camelCase and merge rather than replace:

```tsx
<DataTable
  classNames={{ cell: ({ column }) => (column.id === 'amount' ? 'tabular-nums' : undefined) }}
  slotProps={{ row: ({ row }) => ({ 'data-status': row.original.status }) }}
/>
```

Class names concatenate, styles shallow-merge and event handlers chain. Yours runs first, and it can
stop the table's with `event.preventTablekitDefault()`.

## Customization

Slots compose with everything else: a slot can read the theme
[tokens](/react-tablekit/customization/tokens/), and behaviour belongs in
[handlers](/react-tablekit/customization/handlers/) rather than in a slot that reimplements a
click. For a full design-system adapter, see [Overriding
structure](/react-tablekit/customization/structure/).

## Limitations

- **Slots must forward refs** to their DOM root. The table measures and focuses through them; a
  slot that swallows the ref breaks sizing and keyboard navigation.
- **A slot cannot change the table's structure.** Moving a control somewhere else is a composable
  part, not a slot.
- **Replacing a semantic element with a `div`** removes the semantics with it. A `HeaderCell` that
  is not a `th` is not a header cell to a screen reader.

## API

- [TableSlots](/react-tablekit/api/slots/): every slot, its element and its props.
- [`DataTableProps`](/react-tablekit/api/data-table-props/): `slots`, `slotProps`, `classNames`,
  `styles`, `unstyled`.
- [Hooks](/react-tablekit/api/hooks/): `useTableSlots` for the defaults.

---

# Rows and cells

> Prop getters, class and style callbacks, and full row renderers that still let you keep the default markup.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/rows-and-cells/

## Basics

**Example: Row overrides** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/rows-and-cells/demo-basics)

```tsx
import { useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(40);

/**
 * Row-level overrides: `renderRow` wraps or replaces a row while still calling
 * `defaultRender`, `getRowProps` adds attributes and events, `getRowClassName` styles state, and
 * disabled rows ignore clicks.
 */
export default function RowOverridesExample() {
  const [lastClicked, setLastClicked] = useState<string | null>(null);

  return (
    <div className="example-stack">
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        getRowClassName={(row) => (row.original.remote ? 'example-row--remote' : undefined)}
        getRowProps={(row) => ({ 'data-department': row.original.department })}
        enableRowSelection={(row) => row.original.status !== 'suspended'}
        onRowClick={(row) => setLastClicked(row.original.email)}
        renderRow={({ row, defaultRender }) => (
          <>
            {defaultRender()}
            {row.original.status === 'invited' && (
              <tr className="example-note-row">
                <td colSpan={peopleColumns.length + 1}>
                  Invitation pending since {row.original.joined}
                </td>
              </tr>
            )}
          </>
        )}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
      <p className="site-muted" role="status">
        {lastClicked ? `Clicked ${lastClicked}` : 'Click a row (suspended rows are disabled).'}
      </p>
    </div>
  );
}
```

Row and cell customization has a ladder, and the first rung solves most of it. Start light.

**Additive.** `getRowProps`, `getRowClassName` and `getRowStyle` decorate a row without replacing
anything. At the column level, `getCellProps`, `cellClassName` and `cellStyle` do the same per
cell:

```tsx
<DataTable
  getRowProps={(row) => ({ 'data-status': row.original.status })}
  getRowClassName={(row) => (row.original.overdue ? 'is-overdue' : undefined)}
/>
```

`getRowProps` is the most useful of the three: a data attribute lets CSS express every state
without a class name per case.

**Content.** A column's `cell`, `header` and `footer` decide what is rendered inside. That is
where links, chips, avatars and formatted numbers belong; see
[Columns](/react-tablekit/columns/).

**Structural.** `renderRow` replaces the row, and hands you `defaultRender()` so you can wrap
instead of rebuild:

```tsx
renderRow={({ row, defaultRender }) =>
  row.original.pinned ? <HighlightWrapper>{defaultRender()}</HighlightWrapper> : defaultRender()
}
```

Keeping `defaultRender()` in the tree keeps the row's accessibility attributes, its keyboard
behaviour and its state classes.

### Reaching the page from a cell

A cell renderer receives the table, and `meta` is where the page puts its callbacks. The column
stays a pure function of its data and remains reusable across screens:

```tsx
<DataTable meta={{ onEdit: setEditing }} columns={columns} />;

col.display({
  id: 'actions',
  cell: ({ row, table }) => (
    <button onClick={() => table.options.meta?.onEdit?.(row.original)}>Edit</button>
  ),
});
```

### Clicks inside a row

A clickable row does not steal clicks from its contents: buttons, links, inputs and anything
marked `data-tk-stop` are excluded. Mark your own custom control with `data-tk-stop` if it should
not count as a row click.

## Customization

For markup that belongs to the whole table rather than to one column, use
[slots](/react-tablekit/customization/slots/); `Row` and `Cell` are slots too. For behaviour, use
[handlers](/react-tablekit/customization/handlers/): a click that should navigate is a handler,
not a renderer.

## Limitations

- **`getRowStyle` is inline style**, so it outranks the stylesheet. Prefer a class or a data
  attribute if a theme should still be able to win.
- **`renderRow` opts out of the default row**, including the accessibility attributes, unless
  `defaultRender()` stays in the tree.
- **Cell renderers run for every visible row.** Keep them cheap; expensive work belongs in the
  data, not in the render.

## API

- [`DataTableProps`](/react-tablekit/api/data-table-props/): `getRowProps`, `getRowClassName`,
  `getRowStyle`, `renderRow`, `renderDetailPanel`, `meta`, `slotProps`, `classNames`, `styles`.
- [`ColumnDef`](/react-tablekit/api/column-def/): `cell`, `header`, `footer`, `getCellProps`,
  `cellClassName`, `cellStyle`, `headerClassName`, `headerStyle`, `align`, `verticalAlign`.
- [TableSlots](/react-tablekit/api/slots/): `Row`, `Cell`, `HeaderCell`, `DetailRow`.

---

# Cell building blocks

> Chips, truncated text, two-line text, tooltips and a row actions menu: the small components a cell usually needs, already themed and accessible.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/cell-building-blocks/

## Basics

**Example: Cell building blocks** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/cell-building-blocks/demo-basics)

```tsx
import {
  ActionButton,
  ChipList,
  createColumnHelper,
  DataTable,
  MultiLineList,
  RowActionsMenu,
  TruncatedText,
  TwoLineText,
} from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';

const data = generatePeople(30);
const col = createColumnHelper<DemoPerson>();

/**
 * Inline 16px line icons drawn like the table's own, so the example needs no icon dependency.
 * In an application these would come from whatever icon set the rest of the product uses.
 */
const LineIcon = ({ d }: { d: string }) => (
  <svg
    viewBox="0 0 16 16"
    width="16"
    height="16"
    fill="none"
    stroke="currentColor"
    strokeWidth={1.5}
    strokeLinecap="round"
    strokeLinejoin="round"
    aria-hidden="true"
    focusable="false"
  >
    <path d={d} />
  </svg>
);
const MailIcon = () => <LineIcon d="M2.5 4h11v8h-11zM2.5 4.5 8 8.5l5.5-4" />;
const CopyIcon = () => <LineIcon d="M5.5 5.5h8v8h-8zM2.5 10.5v-8h8" />;
const TrashIcon = () => <LineIcon d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.5 9h6l.5-9" />;
const OpenIcon = () => <LineIcon d="M6.5 3.5h-3v9h9v-3M9 3h4v4M13 3 7.5 8.5" />;

const skillsFor = (p: DemoPerson) =>
  ['TypeScript', 'React', 'CSS', 'Testing', 'Node', 'SQL'].slice(0, (p.age % 5) + 1);

const columns = [
  // Two lines: a strong identity line and a muted qualifier, in one cell.
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    cell: ({ row }) => (
      <TwoLineText
        primary={`${row.original.firstName} ${row.original.lastName}`}
        secondary={row.original.city}
      />
    ),
  }),
  // One item per line, with a fallback when the list is empty.
  col.display({
    id: 'contact',
    header: 'Contact',
    cell: ({ row }) => (
      <MultiLineList
        items={[row.original.email, `${row.original.department} team`]}
        empty="No contact details"
      />
    ),
  }),
  // A wrapping chip list that collapses the tail into a "+N" chip.
  col.display({
    id: 'skills',
    header: 'Skills',
    cell: ({ row }) => <ChipList items={skillsFor(row.original)} maxVisible={3} />,
  }),
  // Long text, clipped with the full value in a tooltip.
  col.display({
    id: 'notes',
    header: 'Notes',
    cell: ({ row }) => (
      <TruncatedText
        text={`${row.original.firstName} joined the ${row.original.department} team in ${row.original.joined} and is currently ${row.original.status}.`}
        maxChars={40}
      />
    ),
  }),
  // Inline actions plus an overflow menu; `danger` marks the destructive one.
  col.display({
    id: 'actions',
    header: 'Actions',
    align: 'right',
    size: 140,
    pin: 'right',
    cell: ({ row }) => (
      <RowActionsMenu
        inlineCount={1}
        actions={[
          {
            label: `Email ${row.original.firstName}`,
            icon: <MailIcon />,
            onClick: () => window.alert(`Email ${row.original.email}`),
          },
          {
            label: 'Duplicate',
            icon: <CopyIcon />,
            onClick: () => window.alert('Duplicated'),
          },
          {
            label: 'Delete',
            icon: <TrashIcon />,
            danger: true,
            disabled: row.original.status === 'suspended',
            onClick: () => window.alert('Deleted'),
          },
        ]}
      />
    ),
  }),
  // A single action button, with its label doubling as the tooltip and the accessible name.
  col.display({
    id: 'open',
    header: '',
    align: 'center',
    size: 60,
    cell: ({ row }) => (
      <ActionButton
        icon={<OpenIcon />}
        label={`Open ${row.original.firstName}'s profile`}
        onClick={() => window.alert(row.original.id)}
      />
    ),
  }),
];

/** The cell primitives, each solving a layout problem that recurs in every table. */
export default function CellBuildingBlocksExample() {
  return (
    <DataTable<DemoPerson>
      aria-label="People"
      data={data}
      columns={columns}
      getRowId={(p) => p.id}
      initialState={{ pagination: { pageIndex: 0, pageSize: 6 } }}
    />
  );
}
```

Cells need the same few things over and over: text that has to fit, a list that has to collapse, a
status that has to be recognisable, an action that must not be mistaken for the row. These
components are those things, already using the theme
[tokens](/react-tablekit/customization/tokens/) and already accessible.

```tsx
import { ActionButton, Chip, ChipList, TruncatedText, TwoLineText } from '@kingtech/react-tablekit';
```

| Component        | For                                                                    |
| ---------------- | ---------------------------------------------------------------------- |
| `TruncatedText`  | Text that clips with an ellipsis and keeps the full value as a tooltip |
| `TwoLineText`    | A primary value with a quieter second line, like a name over an email  |
| `MultiLineList`  | Several values in one cell, stacked and clipped after a limit          |
| `Chip`           | A status or label, coloured from its own definition                    |
| `ChipList`       | Several chips with a `+N` overflow so the row height stays put         |
| `ActionButton`   | An icon button sized for a cell, with a required accessible name       |
| `RowActionsMenu` | The `…` menu at the end of a row                                       |
| `Tooltip`        | The table's own tooltip, so hints look the same everywhere             |

### Using them

```tsx
col.accessor('name', {
  cell: ({ row }) => <TwoLineText primary={row.original.name} secondary={row.original.email} />,
});

col.display({
  id: 'actions',
  cell: ({ row, table }) => (
    <ActionButton
      label="Edit"
      icon={<PenIcon />}
      onClick={() => table.options.meta?.onEdit?.(row.original)}
    />
  ),
});
```

`ActionButton` requires a `label`: an icon button without an accessible name is a dead end for a
screen reader, so the type will not let you write one.

### They do not swallow the row click

Every one of these is treated as interactive content, so clicking a chip or an action button does
not also fire the row's `onRowClick`. That is why a picker with an edit button in each row
behaves the way a reader expects.

## Customization

They are ordinary components: pass a class name, wrap them, or ignore them entirely. If your
design system has its own, use those. These exist so that a table looks finished before you have
written any, not to be mandatory.

`Chip` takes colours per value, and the same definitions are reused by the [filter
chips](/react-tablekit/column-filters/), so a status looks the same in the cell and in the filter
bar.

## Limitations

- **They are presentational.** None of them touches table state; an `ActionButton` does whatever
  its `onClick` does.
- **`TruncatedText` needs a bounded width** to truncate. In an auto-width column with no
  `maxWidth` there is nothing to clip against.
- **`ChipList` measures nothing.** The overflow threshold is a count you pass, not a width
  calculation.

## API

- [Utilities](/react-tablekit/api/utilities/): the components and their prop types.
- [Rows and cells](/react-tablekit/customization/rows-and-cells/): where a cell renderer fits.
- [TableSlots](/react-tablekit/api/slots/): replacing the table's own primitives instead.

---

# Handler middleware

> Wrap any interaction to observe it, change its input or cancel it, without reimplementing what it does.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/handlers/

## Basics

**Example: Handler middleware** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/customization/handlers/demo-basics)

```tsx
import { useState } from 'react';
import { DataTable } from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const data = generatePeople(60);

/**
 * Handler middleware: every interaction runs through an overridable handler that receives
 * the intent and a `next` to run the default. You can observe it, change it, or cancel it by not
 * calling `next`. Here sorting is logged, paging past page 3 is blocked, and a search shorter
 * than two characters is rewritten to an empty one.
 */
export default function HandlersMiddlewareExample() {
  const [log, setLog] = useState<string[]>([]);
  const add = (line: string) => setLog((prev) => [line, ...prev].slice(0, 6));

  return (
    <div className="example-stack">
      <DataTable<DemoPerson>
        aria-label="People"
        data={data}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        handlers={{
          onSortToggle: (ctx, next) => {
            add(`sort → ${ctx.column.id}`);
            return next(ctx);
          },
          onPageChange: (ctx, next) => {
            if (ctx.pageIndex > 2) {
              add(`page ${String(ctx.pageIndex + 1)} blocked by middleware`);
              return;
            }
            return next(ctx);
          },
          onGlobalFilterInput: (ctx, next) => {
            const value = ctx.value.length < 2 ? '' : ctx.value;
            add(`search → ${JSON.stringify(value)}`);
            return next({ ...ctx, value });
          },
        }}
        initialState={{ pagination: { pageIndex: 0, pageSize: 8 } }}
      />
      <section className="example-panel">
        <h2>Handler log</h2>
        {log.length === 0 ? (
          <p className="example-pending">Sort a column, search, or try to reach page 4.</p>
        ) : (
          <ol className="example-log">
            {log.map((line, i) => (
              <li key={`${line}-${String(i)}`}>
                <code>{line}</code>
              </li>
            ))}
          </ol>
        )}
      </section>
    </div>
  );
}
```

Every interaction the table performs goes through a named handler, and you can wrap any of them.
A handler receives the intent and a `next` function that runs the default:

```ts
type Handler<Ctx> = (ctx: Ctx, next: (override?: Partial<Ctx>) => void | Promise<void>) => void;
```

Three things you can do with `next`, which is the whole model:

```tsx
handlers={{
  // Observe, then proceed
  onSortToggle: (ctx, next) => { track('sort', ctx.column.id); next(); },

  // Change the intent: always pin to the left, whichever side was chosen
  onColumnPin: (ctx, next) => next({ position: 'left' }),

  // Cancel: a row click navigates instead of selecting
  onRowClick: ({ row }) => navigate('/people/' + row.id),
}}
```

Not calling `next` cancels the default. That is how a row click becomes navigation without
turning selection off and rebuilding it.

### Asking first

A handler may be async, so a confirmation is a normal `await`:

```tsx
handlers={{
  onPageChange: async (ctx, next) => {
    if (!dirty || (await confirm('Discard unsaved changes?'))) next();
  },
}}
```

### What can be wrapped

Sorting, search input, column filters, clearing filters, page and page-size changes, row clicks
and double-clicks, row selection and select-all, expansion, column resize, move, pin and hide,
export, refresh, hotkeys and cell activation. The full list with each context object is on
[Handlers](/react-tablekit/api/handlers/).

### They compose

`TableDefaultsProvider` handlers wrap the library's defaults, and a handler passed to a table
wraps the provider's. Analytics can live at the provider and a screen-specific rule at the table,
and both run.

## Customization

Handlers are for _behaviour_. Appearance is [slots](/react-tablekit/customization/slots/) and
[tokens](/react-tablekit/customization/tokens/); content is a cell renderer. Keeping them apart is
what lets a handler survive a redesign.

## Limitations

- **Calling `next` twice runs the default twice.** The middleware does not deduplicate.
- **A cancelled interaction leaves no trace.** If a reader needs to know why their click did
  nothing, say so; the table will not.
- **A handler cannot invent an interaction.** It wraps the ones that exist; anything else is your
  own control calling the [instance](/react-tablekit/api/table-instance/).

## API

- [Handlers](/react-tablekit/api/handlers/): every handler, its context and its default.
- [`DataTableProps`](/react-tablekit/api/data-table-props/): `handlers`, `onRowClick`,
  `onStateChange`, `onQueryChange`.
- [`TableInstance`](/react-tablekit/api/table-instance/): what the defaults call.

---

# Recipes

> Short answers to the questions that come up once a table is real: saved views, row navigation, custom sort orders, bulk actions and more.

URL: https://react-tablekit.vercel.app/react-tablekit/customization/recipes/

Each of these is a few lines. They are collected here because they are the questions that arrive
after the table is working, not before.

## A row that navigates instead of selecting

Cancel the default by not calling `next`:

```tsx
handlers={{ onRowClick: ({ row }) => navigate('/people/' + row.id) }}
```

The row still gets focus and keyboard activation; only the selection behaviour is replaced. See
[Handler middleware](/react-tablekit/customization/handlers/).

## A domain sort order

Status columns rarely sort alphabetically. Register a comparator and name it:

```tsx
type Severity = 'critical' | 'high' | 'normal' | 'low';
const RANK: Record<Severity, number> = { critical: 0, high: 1, normal: 2, low: 3 };

<DataTable
  sortingFns={{
    severity: (a, b, id) => RANK[a.getValue<Severity>(id)] - RANK[b.getValue<Severity>(id)],
  }}
  columns={[col.accessor('severity', { sortingFn: 'severity' })]}
/>;
```

## Saved views

A view is a state object. Store it and apply it:

```tsx
const view = table.getState(); // save
table.setState(savedView); // restore
```

For the reader's own layout (visible columns, order, widths, density), use
[`syncState.storage`](/react-tablekit/persistence/) instead and it happens automatically.

## A bulk action over everything matching

Send the query, not a list of ids; there may be a hundred thousand of them:

```tsx
renderBulkActions={({ selectionQuery, selectedRows }) => (
  <button onClick={() => api.archive(selectionQuery)}>
    Archive {selectedRows.length}
  </button>
)}
```

See [Row selection](/react-tablekit/row-selection/).

## An empty state that knows why it is empty

```tsx
renderEmptyState={({ table }) =>
  table.getActiveFilterCount() > 0 ? (
    <>
      <p>No people match these filters.</p>
      <button onClick={() => table.clearAllFilters()}>Clear filters</button>
    </>
  ) : (
    <p>No people yet.</p>
  )
}
```

## A refresh button

```tsx
renderToolbarEnd={(table) => <button onClick={() => table.refresh()}>Refresh</button>}
```

`refresh()` refetches with the current query; `invalidate()` also drops cached facets and lazily
loaded children.

## A column that exports differently from how it displays

```tsx
col.accessor('total', {
  format: (v) => money.format(v),
  exportValue: (row) => row.total,
});
```

The reader sees `$1,240`, the spreadsheet gets `1240`. See [Export](/react-tablekit/export/).

## Two tables sharing one filter

Lift the state. Both tables are controlled from the same value:

```tsx
const [globalFilter, setGlobalFilter] = useState('');

<DataTable state={{ globalFilter }} onGlobalFilterChange={setGlobalFilter} />
<DataTable state={{ globalFilter }} onGlobalFilterChange={setGlobalFilter} />
```

## Prefetching a detail panel on hover

```tsx
slotProps={{ row: ({ row }) => ({ onMouseEnter: () => prefetch(row.id) }) }}
```

Handlers chain, so this does not replace the table's own mouse handling.

## Related

- [Handler middleware](/react-tablekit/customization/handlers/): the cancellation model.
- [Slots](/react-tablekit/customization/slots/): `slotProps`, `classNames` and `styles`.
- [Persistence](/react-tablekit/persistence/): saving state properly.
- [Best practices](/react-tablekit/guides/best-practices/): the longer-form advice.

---

# Guides

> Long-form guides for the decisions that span more than one capability.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/

Capability pages answer "how do I use this?". These answer "how should I use it, and what happens when it grows?".

- [Server data](/react-tablekit/guides/server-data/): writing a data source, and everything the table does for you around it.
- [Best practices](/react-tablekit/guides/best-practices/): the handful of decisions that determine whether a table stays fast and predictable.
- [Performance](/react-tablekit/guides/performance/): what costs what, and the measured budgets.
- [Testing](/react-tablekit/guides/testing/): how to test a table you did not write.
- [Accessibility](/react-tablekit/guides/accessibility/): what is handled for you, and what stays yours.
- [Security](/react-tablekit/guides/security/): cell rendering, CSV export and untrusted data.

---

# Server data

> Wiring a table to an API end to end: the query, the adapter, the states, and the decisions that are easy to get wrong.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/server-data/

This is the long version of [Client and server](/react-tablekit/client-and-server/): what to do,
in order, when the data lives behind an API.

## Decide what the server owns

`dataMode="server"` moves everything: paging, sorting, filtering, search, grouping, faceting.
That is the right default for a list that can grow, and the decision worth making explicitly is
which parts you take back:

```tsx
<DataTable dataMode="server" sortingMode="client" acknowledgePageLocalSorting />
```

Client sorting under server paging sorts _the page_. Sometimes that is exactly right: a
ten-row page the reader wants ordered differently. Usually it is a bug. The acknowledgement flag
exists so the choice is visible in the code rather than discovered in production.

## Write the adapter once

Your API's parameter names are its own. Translate them in one place and nothing else has to know:

```tsx
const people = createRestDataSource<Person, PeopleResponse>({
  url: '/api/people',
  method: 'GET',
  mapQuery: (q) => ({
    page: q.pagination.pageIndex,
    size: q.pagination.pageSize,
    sort: q.sorting.map((s) => s.id + ',' + (s.desc ? 'desc' : 'asc')),
    q: q.globalFilter,
    ...Object.fromEntries(q.columnFilters.map((f) => [f.id, f.value])),
  }),
  mapResult: (body) => ({ rows: body.content, rowCount: body.totalElements }),
});
```

Where a column id and a server field genuinely differ, `sortServerKey` and `filterServerKey` keep
the mapping next to the column instead of in a lookup table.

## Let the server win

Have the response say what it actually applied, and adopt that. A server that clamps page 900 to
page 12 should leave the table on page 12, not on a page that does not exist:

```tsx
mapResult: (body) => ({
  rows: body.content,
  rowCount: body.totalElements,
  query: { pagination: { pageIndex: body.page, pageSize: body.size } },
}),
```

## Fetch fewer times

- `searchMinLength` and `searchDebounceMs` turn typing into one request.
- `filterApplyMode: 'manual'` lets a reader set three filters before anything is sent.
- `dedupeMs` collapses duplicates when several things change at once.
- `keepPreviousData` keeps the previous page visible during the next fetch, which removes the
  layout jump as well as the spinner.

## Handle all four states

A server table is in one of four states, and each needs an answer: first load, refetch, empty,
error. `loadingDisplay` covers the first two, `renderEmptyState` the third (distinguishing "no
data" from "no matches"), and `error` with `renderErrorState` the fourth. See [Loading and empty
states](/react-tablekit/loading-and-empty-states/).

## Facets and exports

Options and counts cannot be computed from one page. Implement `fetchFacets` (see
[Faceting](/react-tablekit/faceting/)) and decide how "export all matching" should work: the
table can walk your data source in chunks, or `onExport(query, scope)` can hand the job to a
backend endpoint. Past a few tens of thousands of rows, the backend endpoint is the right answer.

## Selection across pages

"Select all 235 matching" is a query with exclusions, not a list of ids.
`table.getSelectionQuery()` returns it; your API has to accept it. If it can only take ids, keep
`selectAllMode` on the page and say so in the UI.

## Checklist

- `getRowId` returns a stable id; selection and expansion depend on it.
- Sorting and filtering happen where you think they do.
- The response's real page is adopted.
- Requests are debounced and deduplicated.
- Empty distinguishes "no data" from "no matches".
- Errors are visible and the table stays usable.
- Facets come from the server if filters have option lists.
- Export has a plan for more rows than the browser should hold.

## Related

- [Client and server](/react-tablekit/client-and-server/)
- [React Query](/react-tablekit/integrations/react-query/)
- [Performance](/react-tablekit/guides/performance/)
- [Reading history](/react-tablekit/demos/reading-history/): a working adapter.

---

# Best practices

> The choices that make a table pleasant to use and cheap to maintain, and the ones that quietly make it neither.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/best-practices/

## Give every row a stable id

`getRowId` is the single most consequential prop. Selection, expansion, row pinning and the
detail-panel cache are all keyed by it. Without it they reset on every refetch, and the bug looks
like "selection randomly clears" rather than "we forgot an id".

```tsx
<DataTable getRowId={(person) => person.id} />
```

## Define columns outside the render

A new `columns` array on every render is a new column model: sizing, order and visibility have
nothing to attach to.

```tsx
const columns = [col.accessor('name', { header: 'Name' })]; // module scope, or useMemo
```

The same applies to `data`. If it is derived, memoize it.

## Say what a column is

`type` is short and sets the alignment, the formatting, the comparator and the filter control at
once. A `number` column typed as one is right-aligned, range-filtered and sorted numerically
without four more props.

## Keep display and value apart

`format` changes what is shown; the underlying value keeps sorting, filtering and exporting
correctly. Reaching for `cell` to format text loses all three.

```tsx
col.accessor('joined', { type: 'date', format: (v) => v.toLocaleDateString() }); // good
col.accessor('joined', { cell: ({ getValue }) => fmt(getValue()) }); // loses sorting semantics
```

## Fewer columns

A table with twenty columns is a table nobody reads. Use `defaultHidden` for the ones that matter
occasionally, `hideBelow` for the ones that do not fit on a phone, and a detail panel for the
rest. The columns menu is there to be used.

## Decide where the work happens, once

Client mode for data that fits: a picker, a settings list, anything under a few thousand rows.
Server mode for anything that grows. Hybrid only when you can say out loud why, which is what the
acknowledgement flags are for. See [Server data](/react-tablekit/guides/server-data/).

## Persist the right things

The URL is for what a reader would send to a colleague: search, filters, sort, page. Storage is
for what belongs to them alone: visible columns, order, widths, density. Putting the second group
in the URL makes ugly links; putting the first in storage makes links that lie.

## Make empty states useful

"No results" is a dead end. "No people match these filters. Clear filters" is a way out. The
table gives you `getActiveFilterCount()` and `clearAllFilters()` precisely so the empty state can
tell the difference.

## Reach for the lightest override

Tokens before class names, class names before `slotProps`, `slotProps` before slots, slots before
composable parts, composable parts before headless. Each step up costs maintenance; most tables
never leave the first two.

## Do not fight the accessibility

The semantics (`th scope`, `aria-sort`, live-region announcements, the roving tabindex) come
from the default markup. A slot that drops the props it was given drops those too. Spread what
you receive.

## Test the behaviour, not the DOM

Query by role and accessible name. `getByRole('columnheader', { name: 'Name' })` survives a
redesign; a class selector does not. See [Testing](/react-tablekit/guides/testing/).

## Related

- [Server data](/react-tablekit/guides/server-data/)
- [Performance](/react-tablekit/guides/performance/)
- [Accessibility](/react-tablekit/guides/accessibility/)
- [Recipes](/react-tablekit/customization/recipes/)

---

# Performance

> Where a table's time actually goes, what the library does about it, and the four things you control that matter most.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/performance/

## Where the time goes

Three places, in this order:

1. **Rendering rows.** A thousand rows times ten columns is ten thousand cells, and no amount of
   clever memoization makes that free.
2. **Recomputing the row model.** Sorting and filtering are the work; doing them when nothing
   changed is waste.
3. **Fetching.** A request per keystroke costs more than everything else combined.

The library handles the second and third. The first is mostly your decision.

## Render fewer rows

[Virtualization](/react-tablekit/virtualization/) turns "how many rows are there" into "how tall
is the container". It switches on automatically past `virtualizationThreshold` and needs a bounded
height:

```tsx
<DataTable data={rows} columns={columns} maxHeight={600} />
```

Paginating instead is the other answer, and often the better one: a reader who needs to see ten
thousand rows usually needs a filter, not a scrollbar.

## Keep identities stable

Two props decide whether the engine can skip work:

```tsx
const columns = useMemo(() => [...], []);          // not rebuilt every render
<DataTable data={data} columns={columns} getRowId={(r) => r.id} />
```

A new `columns` array is a new column model. A new `data` array is a new row model. `getRowId`
keeps selection and expansion attached across refetches instead of resetting them.

## Keep cell renderers cheap

A `cell` runs for every visible row on every render. Creating an `Intl` formatter inside one
creates it a hundred times:

```tsx
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }); // once
col.accessor('total', { format: (v) => money.format(v) });
```

Derived values belong in the data or in an `accessorFn`, not in the render.

## Fetch less

`searchDebounceMs`, `searchMinLength`, `filterApplyMode: 'manual'` and `dedupeMs` each remove
requests. `keepPreviousData` removes the blank frame between them. See [Server
data](/react-tablekit/guides/server-data/).

## What the library guarantees

The row model is memoized per stage, so changing the page does not re-sort and changing the sort
does not re-filter. Feature modules are tree-shakeable: a table that never groups does not ship
the grouping code.

The published bundle is measured on every build with hard limits: around 58 kB min+gzip for
`import { DataTable }`, about 61.5 kB for the full import, 21 kB for the headless core and under
10 kB for the stylesheet. Those are regression guards: they fail the build when a change makes the
library bigger, rather than being aspirations in a document.

## Measuring

`debugTable`, `debugRows` and `debugColumns` log the row-model timings, which tells you whether
you are in case 1 or case 2 above. For case 1, a browser profile of a scroll is the answer;
virtualization exists to make that flat.

## Related

- [Virtualization](/react-tablekit/virtualization/)
- [Server data](/react-tablekit/guides/server-data/)
- [Best practices](/react-tablekit/guides/best-practices/)
- [Requirements and compatibility](/react-tablekit/getting-started/requirements/)

---

# Testing

> How to write tests against a table that survive a redesign: query by role, drive the engine directly, and control the clock.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/testing/

## Query by role, not by class

The table renders real semantics, which is exactly what a test should hold on to. A class name is
an implementation detail; an accessible name is a promise.

```tsx
await user.click(screen.getByRole('button', { name: 'Sort by Name' }));
expect(screen.getByRole('columnheader', { name: /Name/ })).toHaveAttribute(
  'aria-sort',
  'ascending',
);
```

Rows are `row`, cells are `cell` or `columnheader`, and a selectable table is a `grid` rather than
a `table`. That is the ARIA rule, not a quirk, and asserting it is a useful regression test in
itself.

## Control the clock

Search and filters are debounced, so a test that types and asserts immediately will fail for the
right reason. Either use fake timers, or remove the delay:

```tsx
render(<DataTable searchDebounceMs={0} searchMinLength={0} data={rows} columns={columns} />);
```

Setting both to zero in tests is legitimate: you are testing the filtering, not the debounce. Test
the debounce once, on its own, with fake timers.

## Test the engine without the DOM

Most table logic is not about rendering. `createTable` gives you the engine as a plain object,
with no React and no jsdom:

```ts
const table = createTable({ data, columns, getRowId: (r) => r.id });
table.setSorting([{ id: 'age', desc: true }]);
expect(table.getRowModel().rows.map((r) => r.getValue('age'))).toEqual([45, 36, 28]);
```

These tests are fast and they do not break when the markup changes. Use them for comparators,
filters, aggregates and query building; save rendered tests for interaction.

## Fake the server, not the fetch

`createLocalDataSource` wraps an array in the same interface a REST source has, so a server-mode
table can be tested without a network. It takes the table's columns, so it filters and sorts the
way the table would:

```tsx
<DataTable
  dataMode="server"
  dataSource={createLocalDataSource(rows, { columns })}
  columns={columns}
/>
```

The table takes the same path it would in production (query, fetch, loading, empty), which is
what makes the test worth having.

## Wait for what you mean

Assert on the result, not on a timeout. Testing Library's `findBy*` queries and `waitFor` retry
until the table has settled; a fixed sleep is either flaky or slow, and usually both.

## Accessibility in the suite

An axe assertion per configuration catches the regressions that reviews miss: a slot that
dropped its props, a custom cell that nested a button inside a button. Run it against the
configurations you actually ship, not just the defaults.

## Visual tests, carefully

Screenshots catch what assertions cannot and fail for reasons that have nothing to do with your
change. Store baselines per platform, seed the data so every run renders the same rows, and keep
them in their own suite so a font update does not block a bug fix.

## Related

- [Headless](/react-tablekit/headless/): the engine these tests drive.
- [Accessibility](/react-tablekit/guides/accessibility/): what the roles mean.
- [Client and server](/react-tablekit/client-and-server/): data sources.
- [Best practices](/react-tablekit/guides/best-practices/)

---

# Accessibility

> What the table does for you, what it announces, and the four ways a customization can undo it.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/accessibility/

Accessibility here is not a mode you turn on. It is the default markup, and the work is in not
breaking it.

## What you get without asking

- **Real table semantics.** A `table` with a `caption` or an `aria-label`, `th scope="col"`
  headers and, with `firstColumnAsRowHeader`, a `th scope="row"` per row, so a screen reader
  can say which record a cell belongs to.
- **Sort state.** `aria-sort` on the primary sorted column, which is what the specification
  allows, and an `aria-description` carrying the priority for the others.
- **Real counts.** `aria-rowcount` and `aria-rowindex` report the whole result, not the rendered
  window, so a [virtualized](/react-tablekit/virtualization/) table says "row 4,201 of 100,000".
- **Busy state.** `aria-busy` while fetching, so a reader is told the table is updating.
- **Announcements.** A polite, debounced live region for sort changes, page changes, result
  counts, loading, selection counts and column moves: the things a sighted reader sees happen.
- **Focus.** A visible ring on every interactive element, from the `--tk-focus-ring` token.
- **Motion.** `prefers-reduced-motion` disables the animations.
- **Contrast.** Every default token pair meets WCAG AA, and that is checked in the test suite
  rather than asserted here.

## The keyboard

Without [keyboard navigation](/react-tablekit/keyboard-navigation/), everything is still reachable
by Tab: sort buttons, checkboxes, links, menus. Rows are not focusable unless `onRowClick` makes
them interactive, which is correct: a row you cannot activate should not be a tab stop.

With it, the table becomes a grid: one tab stop, arrow keys between cells, Enter to step into a
cell's controls and Escape to step back out. That is the WAI-ARIA data grid pattern, and it is the
right choice for a dense table a person works in all day.

## Four ways to break it

1. **A slot that does not spread its props.** The classes, the ARIA attributes and the handlers
   arrive as props. Dropping them drops the behaviour.
2. **A slot that changes the element.** A `HeaderCell` rendered as a `div` is not a header.
3. **A button inside a button.** The default header cell already contains the sort button, so a
   custom `HeaderCell` must render `children` _beside_ its addition, not around it. This is the
   single most common regression.
4. **An icon button with no name.** `ActionButton` requires a `label` for this reason; a bare
   icon in a custom cell needs one too.

## Things worth doing yourself

- **Name the table.** `aria-label` or a `caption`. "Table" is not a description, and a page with
  two tables needs two names.
- **Localize.** Announcements are read aloud; they should be in the reader's language. See
  [Localization](/react-tablekit/localization/).
- **Say why it is empty.** An empty state that distinguishes "no data" from "no matches" is an
  accessibility feature as much as a usability one.
- **Test it.** An axe assertion per configuration you ship. See
  [Testing](/react-tablekit/guides/testing/).

## Known exception

The `classic` preset's header text is `#717680` on `#FAFAFA`, about 4.4:1, just under the AA
threshold for normal text. Those token values are frozen because the preset reproduces an
existing design. Every other preset passes, and the exception is asserted in the test suite so it
cannot spread.

## Related

- [Keyboard navigation](/react-tablekit/keyboard-navigation/)
- [Slots](/react-tablekit/customization/slots/)
- [Localization](/react-tablekit/localization/)
- [Testing](/react-tablekit/guides/testing/)

---

# Security

> What the table does with the data you give it, what it sends, and the places where a table can become an injection or disclosure problem.

URL: https://react-tablekit.vercel.app/react-tablekit/guides/security/

## Rendering is React's rendering

Cell values are rendered as React children, so they are escaped. A value containing `<script>`
appears as text. The table never sets `dangerouslySetInnerHTML`, and neither should a cell
renderer unless you have sanitized the value yourself, at which point the risk is yours, not the
table's.

The same applies to `format`: it returns a string that React escapes.

## The query is input to your server

`TableQuery` is a description of what the reader asked for: column ids, filter values, a search
string, a sort list. It is not a query language and it does not compile to SQL.

Treat everything in it as untrusted, because it is:

- **Validate column ids against a whitelist.** A reader can ask to sort by any id the table knows;
  a modified client can ask for anything at all. Never interpolate an id into SQL.
- **Bound the page size.** `pageSize` arrives from the client. Clamp it server-side; an
  unclamped page size is a denial-of-service request with extra steps.
- **Check authorization per row, not per query.** Filters are a convenience for the reader, never
  a security boundary. If a row should not be visible, it must not be returned.

`sortServerKey` and `filterServerKey` make the mapping from column to field explicit, which is a
good place to enforce the whitelist.

## Export leaves your application

CSV is opened in spreadsheets, and spreadsheets execute formulas. A value beginning with `=`,
`+`, `-` or `@` can become a formula in Excel (CSV injection), and a formula such as
`=HYPERLINK(...)` is enough to send the reader somewhere else. Correct CSV is not the same as
safe-to-open-in-Excel.

So the export neutralizes such fields by default: they are written with a leading `'`, and a
spreadsheet shows them as text. Plain numbers such as `-12` or `-1,234.50` are left alone, so
numeric columns stay numeric. It covers headers, `exportValue` results and copying to the
clipboard. Turn it off only for data you trust:

```tsx
table.exportCsv({ escapeFormulas: false });
```

Export also means the reader is taking a copy of the data somewhere you cannot control. If
"everything matching" is more than they are entitled to, use `onExport` and enforce the scope
server-side.

## Persistence writes to places you can read

[`syncState`](/react-tablekit/persistence/) puts state in the URL and in browser storage. Neither
is a secret store:

- A **URL** is shared, logged by proxies and stored in history. Do not sync a filter value that is
  itself sensitive: a national id being searched for, for instance.
- **Storage** persists after logout on a shared machine. Keep layout preferences there and nothing
  else, and clear the key on sign-out if the layout itself is sensitive.

## Third-party content in cells

A cell can render anything, including an iframe, an image from a URL in the data, or a link. A
link built from user data should carry `rel="noopener noreferrer"` if it opens in a new tab, and
an `href` from untrusted data should be checked for a `javascript:` scheme before it is rendered.

## Reporting a problem

Security issues should not be opened as public issues first. Use GitHub's private vulnerability
reporting on the [repository](https://github.com/kiralygyula92/react-tablekit/security/advisories),
or the contact route on [Support](/react-tablekit/getting-started/support/).

## Related

- [Server data](/react-tablekit/guides/server-data/)
- [Export](/react-tablekit/export/)
- [Persistence](/react-tablekit/persistence/)
- [Support](/react-tablekit/getting-started/support/)

---

# Integrations

> Working with the libraries a React application usually already has.

URL: https://react-tablekit.vercel.app/react-tablekit/integrations/

The table has no opinion about how you fetch, route, style or translate. These pages show the seams.

- [React Query](/react-tablekit/integrations/react-query/): let a query client own the cache.
- [React Router](/react-tablekit/integrations/react-router/): keep table state in the URL the router owns.
- [Next.js](/react-tablekit/integrations/next-js/): App Router, server components and SSR.
- [Tailwind CSS](/react-tablekit/integrations/tailwind/): utilities instead of the visual layer.
- [i18next](/react-tablekit/integrations/i18next/): translate from your existing catalogue.

---

# React Query

> Let TanStack Query own the fetching and caching while the table owns the query: one effect, no duplicated state.

URL: https://react-tablekit.vercel.app/react-tablekit/integrations/react-query/

## Basics

**Example: React Query** (runs live at https://react-tablekit.vercel.app/embed/react-tablekit/integrations/react-query/demo-basics)

```tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
  createLocalDataSource,
  DataTable,
  type QueryChangeReason,
  type TableQuery,
} from '@kingtech/react-tablekit';
import { generatePeople, type DemoPerson } from '@/mock/data/people';
import { peopleColumns } from '@/demo-support/columns';

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000, gcTime: 5 * 60_000 } },
});

/** Stands in for a real HTTP endpoint, with latency so the cache is observable. */
const api = createLocalDataSource(generatePeople(500), {
  columns: peopleColumns,
  getRowId: (p: DemoPerson) => p.id,
  latencyMs: 500,
});

/**
 * The table never owns the cache here: `fetch` hands the query to React Query and returns
 * its promise. `fetchQuery` resolves from the cache when it can, so going back to a page you
 * have already seen is instant, while the table keeps doing the debouncing, aborting and
 * race-handling it always does.
 *
 * The query itself is the cache key: it is a plain serializable object, which is what makes
 * this work at all.
 */
const dataSource = {
  fetch: (query: TableQuery, ctx: { signal: AbortSignal; reason: QueryChangeReason }) =>
    queryClient.query({
      queryKey: ['people', query],
      // The context is forwarded whole: the underlying source needs the abort signal, and
      // `reason` tells it why the query changed.
      queryFn: () => api.fetch(query, ctx),
    }),
};

/** `dataSource` backed by React Query, so the two caches never disagree. */
export default function ReactQueryRecipeExample() {
  return (
    <QueryClientProvider client={queryClient}>
      <DataTable<DemoPerson>
        aria-label="People"
        dataSource={dataSource}
        columns={peopleColumns}
        getRowId={(p) => p.id}
        enableGlobalFilter
        initialState={{ pagination: { pageIndex: 0, pageSize: 10 } }}
      />
    </QueryClientProvider>
  );
}
```

The table already maintains the query; React Query already caches responses. The integration is
making the table's query the cache key:

```tsx
function People() {
  const [query, setQuery] = useState<TableQuery>();

  const { data, isLoading, isFetching, error } = useQuery({
    queryKey: ['people', query],
    queryFn: () => api.people(query),
    placeholderData: keepPreviousData,
    enabled: query !== undefined,
  });

  return (
    <DataTable
      dataMode="server"
      onQueryChange={setQuery}
      data={data?.items ?? []}
      rowCount={data?.total ?? 0}
      loading={isLoading}
      fetching={isFetching}
      error={error}
      columns={columns}
      getRowId={(p) => p.id}
    />
  );
}
```

That is the whole thing. `onQueryChange` fires once, after debouncing, so the key changes when the
request should change and not on every keystroke.

### Why not `dataSource`

`dataSource` is the table's own fetching, and it duplicates what React Query gives you: caching,
deduplication, retries, invalidation, devtools. Pick one. When your application already uses
React Query, `onQueryChange` is the smaller surface.

### Keeping the previous page

`placeholderData: keepPreviousData` and the table's `fetching` flag do the same job from two
sides: the old rows stay visible and the table dims them instead of blanking. Together they
remove the layout jump between pages.

### Invalidating after a mutation

The table does not need to know. Invalidate the key and React Query refetches what is on screen:

```tsx
await api.archive(ids);
queryClient.invalidateQueries({ queryKey: ['people'] });
```

### Infinite queries

`useInfiniteQuery` pairs with `paginationType="infinite"` or `"loadMore"`: flatten the pages into
`data` and let the table accumulate. Keep `appendPages` in mind: the table appends rather than
replaces in these modes.

## Limitations

- **The query object is the cache key**, so it must be serializable. It is, but a custom filter
  value holding a class instance is not.
- **Two sources of truth for loading.** Map `isLoading` and `isFetching` onto `loading` and
  `fetching` rather than merging them, or the first load looks like a refetch.
- **Facets are a second query.** The table's `fetchFacets` belongs to `dataSource`; with React
  Query, supply `filterOptions` from your own query instead.

## Related

- [Client and server](/react-tablekit/client-and-server/)
- [Server data](/react-tablekit/guides/server-data/)
- [Pagination](/react-tablekit/pagination/)
- [Faceting](/react-tablekit/faceting/)

---

# React Router

> Put the table's state in the URL through the router's own history, so filters are navigations and the back button works.

URL: https://react-tablekit.vercel.app/react-tablekit/integrations/react-router/

## Basics

The table can write its state to the URL on its own, but in a routed application that means
changing the address bar behind the router's back. `useRouterSync` hands the reading and writing
to the router instead:

```tsx
import { useSearchParams } from 'react-router';
import { DataTable, useDataTable, useRouterSync } from '@kingtech/react-tablekit';

function People() {
  const [searchParams, setSearchParams] = useSearchParams();
  const table = useDataTable({ data: rows, columns, getRowId: (r) => r.id });

  useRouterSync(table, {
    getSearch: () => searchParams.toString(),
    setSearch: (search, mode) => setSearchParams(search, { replace: mode === 'replace' }),
  });

  return <DataTable table={table} aria-label="People" />;
}
```

Now a filter change is a navigation the router knows about: the back button undoes it, a link
reproduces it, and a loader can read it.

### Replace, not push

Use `replace: true` for anything that changes while the reader types or clicks around: search,
filters, sorting. Otherwise the back button walks through every keystroke's worth of history.
Pushing is reasonable for page changes, where going back to the previous page is what a reader
expects.

### Reading the state in a loader

Because the state is in the search params, a route loader can read it and fetch server-side:

```tsx
export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url);
  return api.people({
    page: Number(url.searchParams.get('tk.page') ?? 0),
    q: url.searchParams.get('tk.q') ?? '',
  });
}
```

Pair that with `dataMode="server"` and the table renders what the loader returned.

### What belongs in storage instead

Visible columns, their order, their widths and the density are the reader's own preferences, not
part of the address. Keep those in `syncState.storage` so links stay short and shareable; see
[Persistence](/react-tablekit/persistence/).

## Limitations

- **The parameter prefix is shared with the page.** `tk.` keeps the table's parameters out of the
  way, but two tables on one route need different prefixes or they will fight.
- **Loaders run before hydration**, so a table whose state is loader-driven should receive
  `initialState` from the loader rather than discovering it after mount.
- **Router APIs differ between major versions.** `useRouterSync` takes the params and a setter, so
  it works with anything that has those two, but you supply the adapter.

## Related

- [Persistence](/react-tablekit/persistence/)
- [Client and server](/react-tablekit/client-and-server/)
- [Next.js](/react-tablekit/integrations/next-js/)
- [Hooks](/react-tablekit/api/hooks/)

---

# Next.js

> Server components, the client boundary, and rendering a first paint that does not jump when it hydrates.

URL: https://react-tablekit.vercel.app/react-tablekit/integrations/next-js/

## The client boundary

The table is interactive, so it is a client component. The page around it does not have to be:
fetch on the server, render the shell there, and pass the rows across.

```tsx
// app/people/page.tsx (a server component)
import { PeopleTable } from './PeopleTable';

export default async function PeoplePage({ searchParams }) {
  const data = await getPeople(searchParams);
  return <PeopleTable initialData={data} />;
}
```

```tsx
// app/people/PeopleTable.tsx
'use client';
import { DataTable } from '@kingtech/react-tablekit';

export function PeopleTable({ initialData }) {
  return <DataTable data={initialData.items} rowCount={initialData.total} columns={columns} />;
}
```

Columns contain functions, so they cannot cross the server-client boundary as props. Define them
in the client module.

## The stylesheet

Import it once, in the root layout:

```tsx
import '@kingtech/react-tablekit/styles.css';
```

That is plain CSS, so it works with the App Router's own CSS handling without a transform. It
carries the default look; a preset is a second import on top of it, and only when you want one:

```tsx
import '@kingtech/react-tablekit/presets/classic.css';
```

## Rendering without a jump

On the server there is no viewport, so the table renders at `ssrBreakpoint`, which is `lg` by default. If
most of your readers are on phones, set it to `sm` and the server-rendered layout will match what
they hydrate into:

```tsx
<DataTable responsive={{ ssrBreakpoint: 'sm' }} data={rows} columns={columns} />
```

This is the difference between a first paint that settles and one that visibly re-lays-out.

## State in the URL

`useSearchParams`, `useRouter` and `usePathname` give `useRouterSync` what it needs, so filters
and paging become real navigations that a server component can read. It works on a table
instance, so build one with `useDataTable` and render it with `<DataTable table={table} />`:

```tsx
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { DataTable, useDataTable, useRouterSync } from '@kingtech/react-tablekit';

export function PeopleTable({ data }: { data: Person[] }) {
  const searchParams = useSearchParams();
  const router = useRouter();
  const pathname = usePathname();
  const table = useDataTable({ data, columns, getRowId: (p) => p.id });

  useRouterSync(table, {
    getSearch: () => searchParams.toString(),
    setSearch: (search, mode) => router[mode](pathname + search, { scroll: false }),
  });

  return <DataTable table={table} aria-label="People" />;
}
```

With `scroll: false` the page does not jump to the top when a filter changes. The table asks for
`replace` by default, so typing in the search box does not fill the history;
`useRouterSync(table, adapter, { url: { mode: 'push' } })` makes every change a history entry.

## Server actions

A bulk action is an ordinary server action. Pass the selection query, not a list of ids, so it
still works when the reader selected everything matching:

```tsx
renderBulkActions={({ selectionQuery }) => (
  <button onClick={() => archivePeople(selectionQuery)}>Archive</button>
)}
```

## Limitations

- **The table cannot be a server component.** It has state and event handlers; there is no
  read-only build of it.
- **Columns are not serializable.** `cell`, `format` and `accessorFn` are functions, so the column
  list has to be defined on the client side of the boundary.
- **Streaming and the first paint.** With `loading.tsx` and a suspended fetch, decide whether the
  table's own skeleton or the route's fallback is the one the reader should see; showing both is
  worse than either.

## Related

- [Responsive](/react-tablekit/responsive/): `ssrBreakpoint`.
- [Persistence](/react-tablekit/persistence/)
- [Server data](/react-tablekit/guides/server-data/)
- [Installation](/react-tablekit/getting-started/installation/)

---

# Tailwind CSS

> Utility classes on any part of the table, tokens driven from your Tailwind theme, and why the cascade layer means no !important.

URL: https://react-tablekit.vercel.app/react-tablekit/integrations/tailwind/

## Classes where you want them

`classNames` takes the slot names in camelCase and merges rather than replaces, so a utility class
lands on exactly one part of the table:

```tsx
<DataTable
  classNames={{
    root: 'rounded-xl shadow-sm',
    headerCell: 'uppercase tracking-wide text-xs',
    cell: ({ column }) => (column.id === 'amount' ? 'tabular-nums text-right' : undefined),
    row: ({ row }) => (row.original.overdue ? 'bg-red-50' : undefined),
  }}
/>
```

The function form receives the same context the slot receives, which is how a class can depend on
the row or the column without a renderer.

## The cascade layer

The library's stylesheet is inside `@layer tablekit`. Tailwind's utilities are in its own layers,
and unlayered CSS beats both. That means a utility class wins over the table's own rule without
`!important` and without specificity games, which is the usual reason component libraries and
Tailwind fight.

If you want the table's defaults to win in a particular place, put your rule in a lower layer
rather than reaching for `!important`.

## Driving the tokens from your theme

Utilities style the parts you name; [tokens](/react-tablekit/customization/tokens/) style
everything else. Point the tokens at the same variables Tailwind generates and the table follows
your palette by default:

```css
@theme {
  --color-brand-500: oklch(0.55 0.2 275);
}

.tk-root {
  --tk-color-accent: var(--color-brand-500);
  --tk-border-color: var(--color-gray-200);
  --tk-row-hover-bg: var(--color-gray-50);
}
```

This is usually the better first move: one block, and the focus ring, the sort indicator, the
selected row and the spinner all match your design without a single utility class.

## Going further

For a table that should look nothing like the default, `unstyled` drops the presentational CSS and
keeps the structural rules, leaving you free to style it entirely with utilities. See [Overriding
structure](/react-tablekit/customization/structure/).

## Limitations

- **Dynamic class names must be complete strings.** Tailwind scans source text, so
  `'bg-' + color + '-50'` produces nothing. Use a lookup of full class names.
- **`classNames` merges, it does not resolve conflicts.** Two utilities setting the same property
  are resolved by CSS order, not by intent, so a merge helper such as `tailwind-merge` is worth
  wrapping the function form in.
- **Utilities do not reach pseudo-content.** Sticky offsets and pinned positions are computed in
  the structural CSS, which is not something to override with a utility.

## Related

- [Design tokens](/react-tablekit/customization/tokens/)
- [Slots](/react-tablekit/customization/slots/)
- [Overriding structure](/react-tablekit/customization/structure/)
- [Theming](/react-tablekit/customization/theming/)

---

# i18next

> Build the table's localization object from your existing catalogue, so its strings live where all your other strings live.

URL: https://react-tablekit.vercel.app/react-tablekit/integrations/i18next/

## Basics

The table takes its strings as an object. Your catalogue already has strings. The integration is a
function that builds one from the other:

```tsx
import { useTranslation } from 'react-i18next';
import { useMemo } from 'react';

function usePeopleTableLocalization() {
  const { t, i18n } = useTranslation('table');

  return useMemo(
    () => ({
      locale: i18n.language,
      localization: {
        search: { placeholder: t('search.placeholder') },
        pagination: { next: t('pagination.next'), previous: t('pagination.previous') },
        selection: { count: (n: number) => t('selection.count', { count: n }) },
        empty: { noResults: t('empty.noResults') },
      },
    }),
    [t, i18n.language],
  );
}
```

```tsx
const { locale, localization } = usePeopleTableLocalization();
<DataTable locale={locale} localization={localization} data={rows} columns={columns} />;
```

Two things matter here. The object is memoized on the language, so switching locale rebuilds it
and nothing else does. And `locale` is passed as well as `localization`: the strings and the
number, date and sorting behaviour should agree.

## Plurals

Keys that depend on a count are functions, which is exactly what i18next's `count` option wants:

```tsx
selection: {
  count: (n) => t('selection.count', { count: n });
}
```

A language with three plural forms is then i18next's problem, which is the right place for it.

## Only what you translate

The object is merged over the defaults, so an incomplete catalogue is not an error; untranslated
keys fall back to English. That makes incremental translation practical: ship the toolbar first
and the column menu later.

The complete list of keys, each with its English default, is on [Localization
keys](/react-tablekit/api/localization-keys/). Generating your catalogue's skeleton from that page
is less work than discovering the keys by clicking.

## Right-to-left

Pass `dir="rtl"` when the language needs it. The layout uses logical properties, so pinned sides
and alignment flip on their own, but the direction itself has to be told to it.

```tsx
<DataTable dir={i18n.dir()} locale={i18n.language} localization={localization} />
```

## Everywhere at once

`TableDefaultsProvider` takes localization and icons for every table beneath it, so an application
configures this once rather than per table.

## Limitations

- **Rebuilding the object on every render defeats memoization** in the table as well as in your
  component. Memoize on the language.
- **Lazy-loaded namespaces render the defaults first.** If the catalogue arrives after mount, the
  table briefly shows English; suspend or gate on `ready` if that matters.
- **The bundled locales are a starting point.** Review them with a native speaker before shipping.

## Related

- [Localization](/react-tablekit/localization/)
- [Localization keys](/react-tablekit/api/localization-keys/)
- [Icons](/react-tablekit/api/icons/)
- [Overriding structure](/react-tablekit/customization/structure/)

---

# Migration

> How upgrades between major versions will be documented. 1.0.0 is the first stable release, so there is nothing to migrate yet.

URL: https://react-tablekit.vercel.app/react-tablekit/migration/

## There is nothing to migrate yet

1.0.0 is the first stable release, so there is no earlier version to upgrade from.

This page exists now so the first upgrade guide has a home that will not move, and so the link from the [versions page](/react-tablekit/getting-started/versions/) is never broken.

## What a migration page will contain

When 2.0 arrives, each jump gets its own page under this section, kept online permanently, containing:

- every breaking change, with the before and after;
- a codemod where the change is mechanical;
- the deprecation warnings you should already have seen in development;
- anything that changed in the `tk-*` class names, `--tk-*` variables or `data-*` attributes, since those are public too.

---

# Discover more

> Where the project is going, and what has already shipped.

URL: https://react-tablekit.vercel.app/react-tablekit/discover-more/

- [Roadmap](/react-tablekit/discover-more/roadmap/): what is specified but not yet built, and why.
- [Changelog](/react-tablekit/discover-more/changelog/): what changed in every release.

---

# Roadmap

> What is planned after 1.0, what is deliberately not planned, and how to influence either.

URL: https://react-tablekit.vercel.app/react-tablekit/discover-more/roadmap/

1.0 is feature-complete against its specification: every feature works on the client and on the
server, everything is themeable, replaceable and overridable, and the API is stable under
[semver](/react-tablekit/getting-started/versions/).

What follows is the shape of 1.x. Dates are not promised; this is an order of intent, and it
changes with what people actually need.

## Planned for 1.x

**Inline editing.** Cell, row and modal editing modes, with a validator per column, optimistic
commits that roll back on rejection, and the keyboard model that goes with it: Enter commits and
moves down, Tab commits and moves right, Escape cancels. It will arrive as new, optional props,
so it lands in a minor release rather than a major one.

**Column virtualization.** Row virtualization landed in 1.0; the column axis has not. It matters
past roughly fifty columns, where rendering every cell in a row becomes the cost even when only
twenty are on screen.

**Row drag reordering.** `enableRowOrdering` with an `onRowOrderChange` callback, client mode
only; reordering rows the server paginates is a different problem.

**Cell range selection and copy.** Selecting a rectangle and copying it as TSV, the way a
spreadsheet does.

**A leaner entry point.** `import { DataTable }` currently measures close to the full import
because the features are not yet split into side-effect-free modules. Doing that work under
several hundred tests was deliberately not done immediately before a first stable release. Until
then, [`@kingtech/react-tablekit/core`](/react-tablekit/headless/) is the small-bundle path.

**More locales.** Four are bundled. More are welcome, and a locale is one of the easiest useful
contributions.

**A Material UI adapter.** A package of [slots](/react-tablekit/customization/slots/) mapping the
six primitives onto MUI components, as a reference for what a design-system adapter looks like.

## Deliberately not planned

- **A pivot table.** Grouping with aggregates is not a pivot, and building one inside a data table
  makes both worse.
- **Charting.** A chart that shares the table's filters is a good idea; putting it in this package
  is not.
- **A styling framework.** [Tokens](/react-tablekit/customization/tokens/) and slots are the
  extent of the visual API on purpose.

## Influencing it

Open an issue describing the problem rather than the feature: what you are trying to do, what
you tried, and where it stopped working. A concrete use case moves something up this list far
more reliably than a request for an API.

## Related

- [Changelog](/react-tablekit/discover-more/changelog/)
- [Versions](/react-tablekit/getting-started/versions/)
- [Support](/react-tablekit/getting-started/support/)

---

# Changelog

> What changed in each release, and where the authoritative record lives.

URL: https://react-tablekit.vercel.app/react-tablekit/discover-more/changelog/

The authoritative changelog is
[`CHANGELOG.md`](https://github.com/kiralygyula92/react-tablekit/blob/main/packages/react-tablekit/CHANGELOG.md)
in the package. Each release adds a section to it, written from the commits that went into that
release. GitHub
[releases](https://github.com/kiralygyula92/react-tablekit/releases) carry the same notes.

## 1.0.2

Published from GitHub Actions with
[provenance](https://docs.npmjs.com/generating-provenance-statements), so npm shows the commit
and workflow that built the package. The library's code and API are unchanged.

## 1.0.0

The first stable release. The API is stable and covered by the
[semver promise](/react-tablekit/getting-started/versions/).

**What it contains.** Every feature documented under [All
features](/react-tablekit/all-features/), each working in client mode and in server mode:
sorting, global search, column filters, faceting, pagination in six presentations, row selection
with an exclusion model for select-all, expansion and tree data, grouping and aggregation, column
pinning, sizing, ordering, visibility and footers, row pinning, numbers and appearance, sticky
header and footer, density, virtualization, responsive layouts, export, persistence, keyboard
navigation, localization, and a fully headless core.

**Quality gates at release.** The end-to-end suite runs on Chromium, Firefox and WebKit: 702
tests. Server-side rendering is covered by a smoke test that asserts what reaches the first paint,
including `role="grid"` for a selectable table and the cards layout under a small `ssrBreakpoint`.
Packaging passes `publint --strict` and `@arethetypeswrong/cli` on the packed tarball.

**Bundle budgets.** The measured sizes are the guards: about 58 kB min+gzip for
`import { DataTable }`, 61.5 kB for the full import, 21 kB for `@kingtech/react-tablekit/core`, and under
7 kB for `styles.css`. The originally specified 24 kB basic-usage target needs the feature modules
split apart, a refactor deferred to 1.x rather than landed immediately before a stable release.
Locale packs and CSS presets are separate entry points and are never reachable from the main one.

## Before 1.0

The library was built in seven milestones (engine and rendering, search and filtering, selection
and expansion, column power features and large data, grouping and export, theming and this site,
then hardening), each ending with the full gate green. There are no pre-1.0 releases to migrate
from.

## Related

- [Roadmap](/react-tablekit/discover-more/roadmap/): what comes next.
- [Versions](/react-tablekit/getting-started/versions/): the semver policy.
- [Migration](/react-tablekit/migration/): moving from another table library.

---

# Shared example code

The examples above import these site modules: sample data and shared column definitions. They are not part of the package; they are here so that every example is complete.

## content/react-tablekit/demos/account-list/basics-columns.tsx

```tsx
import {
  ActionButton,
  createColumnHelper,
  MultiLineList,
  TwoLineText,
} from '@kingtech/react-tablekit';
import { formatAccountName, getAccountAssets } from '@/mock/data/accounts';
import { formatPhoneLines, getShortFormatAddress } from '@/mock/format';
import type { Account } from '@/mock/types';
import { PenIcon } from '@/demo-support/icons';

/** Handlers passed through `meta`, so the columns are created once at module scope. */
export interface AccountListMeta {
  onEditAccount: (account: Account) => void;
}

const col = createColumnHelper<Account>();

/** An application-like account list: percentage widths with pixel minimums. */
export const accountColumns = [
  col.accessor((a) => formatAccountName(a), {
    id: 'accountName',
    header: 'Account name',
    width: '15%',
    minWidth: '160px',
  }),
  col.accessor('displayName.companyName', {
    id: 'companyName',
    header: 'Company name',
    width: '15%',
    minWidth: '160px',
  }),
  col.accessor('billingAddress', {
    header: 'Billing address',
    width: '15%',
    minWidth: '128px',
    cell: ({ getValue }) => {
      const address = getValue();
      return <TwoLineText primary={address?.address1} secondary={getShortFormatAddress(address)} />;
    },
    getSearchValue: (a) => a.billingAddress?.address1 ?? '',
  }),
  col.accessor((a) => a.contactInformation?.emailAddresses ?? [], {
    id: 'emails',
    header: 'Emails',
    width: '10%',
    minWidth: '128px',
    cell: ({ getValue }) => <MultiLineList items={getValue().map((e) => e.email)} />,
  }),
  col.accessor((a) => a.contactInformation?.phoneNumbers ?? [], {
    id: 'phones',
    header: 'Phones',
    width: '15%',
    minWidth: '128px',
    cell: ({ getValue }) => <MultiLineList items={formatPhoneLines(getValue())} />,
  }),
  col.accessor((a) => a.sites ?? [], {
    id: 'sites',
    header: 'Sites',
    width: '15%',
    minWidth: '128px',
    cell: ({ getValue }) => (
      <MultiLineList
        gap={12}
        items={getValue().map((s) => (
          <TwoLineText
            key={s.identifiers.id}
            primary={s.address.address1}
            secondary={getShortFormatAddress(s.address)}
          />
        ))}
      />
    ),
  }),
  col.accessor((a) => getAccountAssets(a).length, {
    id: 'assets',
    header: 'Assets',
    type: 'number',
    width: '5%',
    minWidth: '96px',
  }),
  col.display({
    id: 'actions',
    header: 'Actions',
    align: 'right',
    width: '5%',
    minWidth: '48px',
    // Pinned right and locked: the row's actions stay reachable however the table is scrolled.
    pin: 'right',
    static: true,
    cell: ({ row, table }) => (
      <ActionButton
        icon={<PenIcon />}
        label="Edit"
        onClick={() => (table.options.meta as AccountListMeta).onEditAccount(row.original)}
      />
    ),
  }),
];
```

## content/react-tablekit/demos/asset-list/basics-columns.tsx

```tsx
import {
  ActionButton,
  ChipList,
  createColumnHelper,
  MultiLineList,
  TwoLineText,
} from '@kingtech/react-tablekit';
import { formatAccountName, getAccountAssets } from '@/mock/data/accounts';
import { formatPhoneLines, getShortFormatAddress } from '@/mock/format';
import type { Account, Asset } from '@/mock/types';
import { GaugeIcon } from '@/demo-support/icons';

/** Handlers passed through `meta`, so the columns are created once at module scope. */
export interface AssetListMeta {
  onInspect: (account: Account) => void;
  onSelectAsset: (asset: Asset) => void;
}

const col = createColumnHelper<Account>();
const meta = (table: { options: { meta?: unknown } }) => table.options.meta as AssetListMeta;

/** An asset's chip label: its name when it has one, otherwise its kind. */
export const assetLabel = (asset: Asset) => asset.name ?? asset.kind;

export const assetListColumns = [
  col.accessor((a) => formatAccountName(a), {
    id: 'accountName',
    header: 'Account name',
    width: '15%',
    minWidth: '200px',
  }),
  col.accessor('displayName.companyName', {
    id: 'companyName',
    header: 'Company name',
    width: '15%',
    minWidth: '200px',
  }),
  col.accessor('billingAddress', {
    header: 'Billing address',
    width: '20%',
    minWidth: '200px',
    cell: ({ getValue }) => {
      const address = getValue();
      return <TwoLineText primary={address?.address1} secondary={getShortFormatAddress(address)} />;
    },
    getSearchValue: (a) => a.billingAddress?.address1 ?? '',
  }),
  col.accessor((a) => a.contactInformation?.phoneNumbers ?? [], {
    id: 'phones',
    header: 'Phones',
    width: '15%',
    minWidth: '200px',
    cell: ({ getValue }) => <MultiLineList items={formatPhoneLines(getValue())} />,
  }),
  col.accessor((a) => getAccountAssets(a), {
    id: 'assets',
    header: 'Assets',
    width: '40%',
    minWidth: '200px',
    // At most three chips plus a `+N` overflow chip; each chip is clickable.
    cell: ({ getValue, table }) => (
      <ChipList
        items={getValue()}
        maxVisible={3}
        renderChip={(asset) => (
          <button
            type="button"
            className="showcase-chip-button"
            onClick={() => meta(table).onSelectAsset(asset)}
          >
            {assetLabel(asset)}
          </button>
        )}
      />
    ),
    getSearchValue: (a) => getAccountAssets(a).map(assetLabel).join(' '),
  }),
  col.display({
    id: 'actions',
    header: 'Actions',
    align: 'right',
    width: '10%',
    minWidth: '48px',
    pin: 'right',
    static: true,
    cell: ({ row, table }) => (
      <ActionButton
        icon={<GaugeIcon />}
        label="Inspect"
        onClick={() => meta(table).onInspect(row.original)}
      />
    ),
  }),
];
```

## content/react-tablekit/demos/reading-history/basics-columns.tsx

```tsx
import { ActionButton, createColumnHelper } from '@kingtech/react-tablekit';
import type { Reading } from '@/mock/types';
import { FileIcon, PaperPlaneIcon } from '@/demo-support/icons';

/** Handlers passed through `meta`, so the columns are created once at module scope. */
export interface ReadingsMeta {
  onResend: (reading: Reading) => void;
  onViewReport: (reading: Reading) => void;
}

const col = createColumnHelper<Reading>();
const meta = (table: { options: { meta?: unknown } }) => table.options.meta as ReadingsMeta;

const dateFormat = new Intl.DateTimeFormat('en-US', {
  year: 'numeric',
  month: 'short',
  day: 'numeric',
});

/** Sixteen numeric metrics, which is what makes this table wider than any viewport. */
const METRICS: { id: keyof Reading; header: string; width: string; min: string }[] = [
  { id: 'loadFactor', header: 'Load factor', width: '5%', min: '80px' },
  { id: 'inputVoltage', header: 'Input voltage', width: '6%', min: '96px' },
  { id: 'outputVoltage', header: 'Output voltage', width: '6%', min: '96px' },
  { id: 'throughput', header: 'Throughput', width: '5%', min: '80px' },
  { id: 'errorRate', header: 'Error rate', width: '6%', min: '96px' },
  { id: 'queueDepth', header: 'Queue depth', width: '7%', min: '112px' },
  { id: 'memoryUsed', header: 'Memory used', width: '7%', min: '112px' },
  { id: 'diskUsed', header: 'Disk used', width: '8%', min: '128px' },
  { id: 'packetLoss', header: 'Packet loss', width: '6%', min: '96px' },
  { id: 'jitter', header: 'Jitter', width: '5%', min: '80px' },
  { id: 'latency', header: 'Latency', width: '6%', min: '96px' },
  { id: 'uptimeDays', header: 'Uptime (days)', width: '5%', min: '80px' },
  { id: 'fanSpeed', header: 'Fan speed', width: '5%', min: '80px' },
  { id: 'powerDraw', header: 'Power draw', width: '6%', min: '96px' },
  { id: 'peakDraw', header: 'Peak draw', width: '7%', min: '112px' },
  { id: 'temperature', header: 'Temperature', width: '7%', min: '112px' },
];

export const readingColumns = [
  col.accessor('date', {
    header: 'Date',
    type: 'date',
    width: '10%',
    minWidth: '128px',
    format: (v) => dateFormat.format(new Date(v)),
  }),
  ...METRICS.map((m) =>
    col.accessor(m.id, {
      header: m.header,
      type: 'number',
      align: 'right' as const,
      width: m.width,
      minWidth: m.min,
      // Measured values are emphasised so they stand out from the labels.
      cellClassName: 'showcase-metric',
    }),
  ),
  col.display({
    id: 'actions',
    header: 'Actions',
    align: 'right',
    width: '6%',
    minWidth: '90px',
    // Pinned right, so the actions stay put while the metrics scroll sideways.
    pin: 'right',
    static: true,
    cell: ({ row, table }) => (
      <>
        <ActionButton
          icon={<PaperPlaneIcon />}
          label="Resend"
          disabled={!row.original.reportId}
          onClick={() => meta(table).onResend(row.original)}
        />
        <ActionButton
          icon={<FileIcon />}
          label="View report"
          disabled={!row.original.reportUrl}
          onClick={() => meta(table).onViewReport(row.original)}
        />
      </>
    ),
  }),
];
```

## @/demo-support/ShowcaseFrame

```tsx
import { useState, type ReactNode } from 'react';
import { classicTheme, lightTheme, type TableTheme } from '@kingtech/react-tablekit';

/**
 * The theme switch shared by the showcase pages: the dense `classic` preset against the modern
 * `light` defaults, with the same configuration either way.
 *
 * A showcase is an application's own light screen whatever the documentation's appearance, so its
 * table is always given the light colour scheme. Left to follow the site, dark mode would put a
 * dark table on the light page around it.
 */
export function useShowcaseTheme(): {
  theme: TableTheme;
  colorScheme: 'light';
  toggle: ReactNode;
} {
  const [mode, setMode] = useState<'classic' | 'light'>('classic');
  const toggle = (
    <fieldset className="showcase-toggle">
      <legend>Theme</legend>
      {(['classic', 'light'] as const).map((m) => (
        <label key={m}>
          <input
            type="radio"
            name="showcase-theme"
            value={m}
            checked={mode === m}
            onChange={() => setMode(m)}
          />
          {m === 'classic' ? 'Classic (dense)' : 'Light (modern defaults)'}
        </label>
      ))}
    </fieldset>
  );
  return { theme: mode === 'classic' ? classicTheme : lightTheme, colorScheme: 'light', toggle };
}

/** An application-like page canvas, so the table is shown in the context it is used in. */
export function ShowcasePage({ children }: { children: ReactNode }) {
  return <div className="showcase-page">{children}</div>;
}
```

## @/demo-support/api

```ts
import { createRestDataSource, type DataSource } from '@kingtech/react-tablekit';
import { mockFetch } from '@/mock/ready';
import type { Account, PagedResponse, SearchAccountRequest } from '@/mock/types';

/**
 * The account search as a data source: `{ queryCriteria, listingCriteria }` in,
 * `{ items, totalItemCount, requestCriteria }` out. The table adopts the server's echo of the
 * request, so a page the server corrected wins over the one the table asked for.
 */
export const accountsDataSource: DataSource<Account> = createRestDataSource<
  Account,
  PagedResponse<Account>
>({
  url: '/api/accounts/search',
  method: 'POST',
  mapQuery: (q): SearchAccountRequest => ({
    queryCriteria: q.globalFilter,
    listingCriteria: {
      pageNumber: q.pagination.pageIndex,
      pageSize: q.pagination.pageSize,
      ...(q.sorting[0] ? { sortColumn: q.sorting[0].id, ascending: !q.sorting[0].desc } : {}),
    },
  }),
  mapResult: (r) => ({
    rows: r.items,
    rowCount: r.totalItemCount,
    query: {
      pagination: { pageIndex: r.requestCriteria.pageNumber, pageSize: r.requestCriteria.pageSize },
    },
  }),
  fetcher: async (req) => {
    const res = await mockFetch(req.url, {
      method: req.method,
      headers: { 'content-type': 'application/json', ...req.headers },
      body: JSON.stringify(req.body),
      signal: req.signal,
    });
    if (!res.ok) throw new Error(`Request failed: ${res.status}`);
    return (await res.json()) as PagedResponse<Account>;
  },
});
```

## @/demo-support/columns

```tsx
import { createColumnHelper } from '@kingtech/react-tablekit';
import type { DemoPerson } from '@/mock/data/people';

const col = createColumnHelper<DemoPerson>();
const money = new Intl.NumberFormat('en-US', {
  style: 'currency',
  currency: 'USD',
  maximumFractionDigits: 0,
});

/** Columns shared by the generic feature examples. */
export const peopleColumns = [
  col.accessor((p) => `${p.firstName} ${p.lastName}`, {
    id: 'name',
    header: 'Name',
    getSearchValue: (p) => `${p.firstName} ${p.lastName} ${p.email}`,
  }),
  col.accessor('email', { header: 'Email' }),
  col.accessor('age', { header: 'Age', type: 'number' }),
  col.accessor('department', { header: 'Department', filterVariant: 'select' }),
  col.accessor('status', { header: 'Status', filterVariant: 'multiSelect' }),
  col.accessor('salary', { header: 'Salary', type: 'number', format: (v) => money.format(v) }),
  col.accessor('joined', {
    header: 'Joined',
    type: 'date',
    format: (v) => new Date(v).toLocaleDateString('en-US'),
  }),
  col.accessor('remote', { header: 'Remote', type: 'boolean', format: (v) => (v ? 'Yes' : 'No') }),
  col.accessor('city', { header: 'City' }),
];
```

## @/demo-support/icons

```tsx
import type { SVGProps } from 'react';

/** Small in-house icons for the example pages. */
function Icon({ d, ...props }: SVGProps<SVGSVGElement> & { d: string }) {
  return (
    <svg
      viewBox="0 0 512 512"
      width="16"
      height="16"
      fill="currentColor"
      aria-hidden="true"
      focusable="false"
      {...props}
    >
      <path d={d} />
    </svg>
  );
}

export const PenIcon = () => (
  <Icon d="M362.7 19.3 314.3 67.7 444.3 197.7 492.7 149.3c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zM291.7 90.3 58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7.8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3z" />
);
export const GaugeIcon = () => (
  <Icon d="M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm320 0c0-15.9-7.7-30-19.6-38.8L232.9 129c-5.1-6.6-14.4-8-21.2-3.2s-8.7 14-4.3 21.1l45.3 71.9c-13.6 9.4-22.7 25.4-22.7 43.4 0 35.3 28.7 64 64 64s64-28.7 64-64c0-2.9-.2-5.7-.6-8.5H320zM144 208a32 32 0 1 1 0 64 32 32 0 1 1 0-64zm288 0a32 32 0 1 1 0 64 32 32 0 1 1 0-64zM208 144a32 32 0 1 1 64 0 32 32 0 1 1-64 0z" />
);
export const PaperPlaneIcon = () => (
  <Icon d="M498.1 5.6c10.1 7 15.4 19.1 13.5 31.2l-64 416c-1.5 9.7-7.4 18.2-16 23s-18.9 5.4-28 1.6L284 427.7l-68.5 74.1c-8.9 9.7-22.9 12.9-35.2 8.1S160 493.2 160 480v-83.6c0-4 1.5-7.8 4.2-10.8l167.6-182.9c5.8-6.3 5.6-16-.4-22s-15.7-6.4-22-.7L106 360.8 17.7 316.6C7.1 311.3.3 300.7 0 288.9s5.9-22.8 16.1-28.7l448-256c10.7-6.1 23.9-5.5 34 1.4z" />
);
export const FileIcon = () => (
  <Icon
    d="M64 0C28.7 0 0 28.7 0 64v384c0 35.3 28.7 64 64 64h256c35.3 0 64-28.7 64-64V160H256c-17.7 0-32-14.3-32-32V0zm192 0v128h128z"
    viewBox="0 0 384 512"
  />
);
```

## @/demo-support/orgTree

```ts
import { createRandom } from '@/mock/prng';

/** A node of the demo organisation tree. */
export interface OrgNode {
  id: string;
  name: string;
  role: string;
  headcount: number;
  location: string;
  children?: OrgNode[];
}

const DIVISIONS = ['Product', 'Platform', 'Revenue', 'Operations'];
const TEAMS = ['Core', 'Growth', 'Billing', 'Insights', 'Mobile', 'Data'];
const ROLES = ['Engineer', 'Designer', 'Analyst', 'Manager'];
const CITIES = ['Budapest', 'Berlin', 'Austin', 'Lisbon', 'Toronto'];
const NAMES = ['Ava', 'Ben', 'Chloé', 'Dev', 'Emma', 'Finn', 'Gyula', 'Hana', 'Ivan', 'Júlia'];

/** Deterministic three-level tree: divisions → teams → people. */
export function buildOrgTree(seed = 7): OrgNode[] {
  const r = createRandom(seed);
  return DIVISIONS.map((division, d) => {
    const teams = TEAMS.slice(0, 2 + (d % 3)).map((team, t) => {
      const people = Array.from({ length: 2 + r.int(0, 3) }, (_, p): OrgNode => ({
        id: `d${d}-t${t}-p${p}`,
        name: `${r.pick(NAMES)} ${String.fromCharCode(65 + p)}.`,
        role: r.pick(ROLES),
        headcount: 1,
        location: r.pick(CITIES),
      }));
      return {
        id: `d${d}-t${t}`,
        name: `${division} · ${team}`,
        role: 'Team',
        headcount: people.length,
        location: r.pick(CITIES),
        children: people,
      } satisfies OrgNode;
    });
    return {
      id: `d${d}`,
      name: division,
      role: 'Division',
      headcount: teams.reduce((n, t) => n + t.headcount, 0),
      location: 'Global',
      children: teams,
    } satisfies OrgNode;
  });
}

/** Flat lookup by parent id, for the lazily loaded tree demo. */
export function childrenOf(id: string | null, tree = buildOrgTree()): OrgNode[] {
  // The lazy demo serves one level at a time, so the children are stripped off.
  const flat = ({ children: _children, ...node }: OrgNode): OrgNode => node;
  if (id === null) return tree.map(flat);
  const find = (nodes: OrgNode[]): OrgNode | undefined => {
    for (const n of nodes) {
      if (n.id === id) return n;
      const hit = n.children ? find(n.children) : undefined;
      if (hit) return hit;
    }
    return undefined;
  };
  return (find(tree)?.children ?? []).map(flat);
}

/** Whether a node has children (the lazy tree cannot infer it from the row itself). */
export function hasChildren(id: string, tree = buildOrgTree()): boolean {
  return childrenOf(id, tree).length > 0;
}
```

## @/demo-support/requestLog

```tsx
import { useCallback, useMemo, useState } from 'react';
import type { DataSource, TableQuery } from '@kingtech/react-tablekit';

export interface LoggedRequest {
  at: string;
  reason: string;
  query: Pick<TableQuery, 'globalFilter' | 'columnFilters' | 'sorting' | 'pagination'>;
}

/** Wraps a data source so the demo can show what the "server" was asked for. */
export function useRequestLog<TData>(source: DataSource<TData>, limit = 8) {
  const [log, setLog] = useState<LoggedRequest[]>([]);

  const dataSource = useMemo<DataSource<TData>>(
    () => ({
      fetch: (query, ctx) => {
        setLog((prev) =>
          [
            {
              at: new Date().toLocaleTimeString(),
              reason: ctx.reason,
              query: {
                globalFilter: query.globalFilter,
                columnFilters: query.columnFilters,
                sorting: query.sorting,
                pagination: query.pagination,
              },
            },
            ...prev,
          ].slice(0, limit),
        );
        return source.fetch(query, ctx);
      },
      ...(source.fetchFacets ? { fetchFacets: source.fetchFacets.bind(source) } : {}),
    }),
    [source, limit],
  );

  const clear = useCallback(() => setLog([]), []);
  return { dataSource, log, clear };
}

/** Renders the request log as a definition list. */
export function RequestLog({ log, clear }: { log: LoggedRequest[]; clear: () => void }) {
  return (
    <section className="example-panel" aria-labelledby="request-log-title">
      <h2 id="request-log-title">Server requests</h2>
      {log.length === 0 ? (
        <p className="example-pending">No requests yet.</p>
      ) : (
        <>
          <button type="button" onClick={clear}>
            Clear log
          </button>
          <ol className="example-log">
            {log.map((entry, i) => (
              <li key={`${entry.at}-${String(i)}`}>
                <code>
                  {entry.at} · {entry.reason} · page {entry.query.pagination.pageIndex + 1} ·{' '}
                  {entry.query.sorting.length > 0
                    ? entry.query.sorting
                        .map((s) => `${s.id} ${s.desc ? 'desc' : 'asc'}`)
                        .join(', ')
                    : 'unsorted'}
                  {entry.query.globalFilter ? ` · q="${entry.query.globalFilter}"` : ''}
                  {entry.query.columnFilters.length > 0
                    ? ` · ${entry.query.columnFilters.map((f) => `${f.id}=${JSON.stringify(f.value)}`).join(', ')}`
                    : ''}
                </code>
              </li>
            ))}
          </ol>
        </>
      )}
    </section>
  );
}
```

## @/mock/browser

```ts
import { setupWorker } from 'msw/browser';
import { handlers } from './handlers';

/** Starts the MSW service worker. Unhandled requests (assets, fonts) pass through silently. */
export async function startMockServer(): Promise<void> {
  const worker = setupWorker(...handlers);
  await worker.start({
    serviceWorker: { url: `${import.meta.env.BASE_URL}mockServiceWorker.js` },
    onUnhandledRequest: 'bypass',
    quiet: true,
  });
}
```

## @/mock/config

```ts
/** Runtime knobs for the mock server (changed from the demo UI; read per request). */
export interface MockConfig {
  /** Fixed latency, or a `[min, max]` range in ms. Default 400 ± 200. */
  latencyMs: number | readonly [min: number, max: number];
  /** Probability (0–1) that a request fails with HTTP 500. */
  errorRate: number;
}

const STORAGE_KEY = 'tk-site:mock-config';
const DEFAULT_CONFIG: MockConfig = { latencyMs: [200, 600], errorRate: 0 };

function readInitial(): MockConfig {
  const config: MockConfig = { ...DEFAULT_CONFIG };
  if (typeof window === 'undefined') return config;
  try {
    const stored = window.localStorage.getItem(STORAGE_KEY);
    if (stored) Object.assign(config, JSON.parse(stored) as Partial<MockConfig>);
  } catch {
    // Storage can be unavailable (private mode); defaults are fine.
  }
  // `?mockLatency=0` / `?mockErrorRate=0.5` make e2e runs deterministic.
  const params = new URLSearchParams(window.location.search);
  const latency = params.get('mockLatency');
  if (latency !== null) config.latencyMs = Number(latency);
  const errorRate = params.get('mockErrorRate');
  if (errorRate !== null) config.errorRate = Number(errorRate);
  return config;
}

let current = readInitial();

export function getMockConfig(): MockConfig {
  return current;
}

export function setMockConfig(patch: Partial<MockConfig>): void {
  current = { ...current, ...patch };
  try {
    window.localStorage.setItem(STORAGE_KEY, JSON.stringify(current));
  } catch {
    // ignore
  }
}

export function resolveLatency(config: MockConfig, random: () => number = Math.random): number {
  const { latencyMs } = config;
  if (typeof latencyMs === 'number') return Math.max(0, latencyMs);
  const [min, max] = latencyMs;
  return Math.round(min + random() * (max - min));
}
```

## @/mock/data/accounts

```ts
import { toPlainLatin } from '../format';
import { createRandom, type Random } from '../prng';
import type { Account, Address, Asset, AssetKind, PhoneType, Site } from '../types';

export const ACCOUNT_COUNT = 235;
export const ACCOUNT_SEED = 20240611;

const FIRST_NAMES = [
  'James',
  'Mary',
  'Robert',
  'Patricia',
  'John',
  'Jennifer',
  'Michael',
  'Linda',
  'David',
  'Elizabeth',
  'William',
  'Barbara',
  'Richard',
  'Susan',
  'Joseph',
  'Jessica',
  'Thomas',
  'Sarah',
  'Carlos',
  'Karen',
  'Daniel',
  'Lisa',
  'Matthew',
  'Nancy',
  'Anthony',
  'Betty',
  'Mark',
  'Sandra',
  'Zoë',
  'José',
  'Renée',
  'Bjørn',
  'Chloé',
  'André',
  'Noémi',
  'Gyula',
] as const;

const LAST_NAMES = [
  'Smith',
  'Johnson',
  'Williams',
  'Brown',
  'Jones',
  'Garcia',
  'Miller',
  'Davis',
  'Rodriguez',
  'Martinez',
  'Hernandez',
  'Lopez',
  'Gonzalez',
  'Wilson',
  'Anderson',
  'Thomas',
  'Taylor',
  'Moore',
  'Jackson',
  'Martin',
  'Lee',
  'Perez',
  'Thompson',
  'White',
  'Harris',
  'Sanchez',
  'Clark',
  'Ramirez',
  'Lewis',
  'Robinson',
  'Walker',
  'Young',
  'Allen',
  'King',
  'Wright',
  'Scott',
  'Müller',
  'Núñez',
  'Østergaard',
  'Király',
] as const;

const COMPANY_PREFIXES = [
  'Blue',
  'Crystal',
  'Summit',
  'Northwind',
  'Vertex',
  'Coastal',
  'Desert',
  'Golden',
  'Clear',
  'Apex',
] as const;
const COMPANY_SUFFIXES = [
  'Logistics',
  'Properties',
  'Industries',
  'Cooperative',
  'Holdings',
  'Management',
  'Systems',
  'Networks',
] as const;

const STREETS = [
  'Main St',
  'Oak Ave',
  'Pine Rd',
  'Maple Dr',
  'Cedar Ln',
  'Elm St',
  'Lakeview Blvd',
  'Sunset Blvd',
  'Palm Way',
  'Harbor Dr',
  'Ocean Ave',
  'Canyon Rd',
  'Mesa Dr',
  'Willow Ct',
] as const;

const CITIES: readonly (readonly [city: string, region: string, zipPrefix: number])[] = [
  ['Phoenix', 'AZ', 850],
  ['Scottsdale', 'AZ', 852],
  ['Tucson', 'AZ', 857],
  ['Austin', 'TX', 787],
  ['Houston', 'TX', 770],
  ['Dallas', 'TX', 752],
  ['Miami', 'FL', 331],
  ['Orlando', 'FL', 328],
  ['Tampa', 'FL', 336],
  ['San Diego', 'CA', 921],
  ['Los Angeles', 'CA', 900],
  ['Sacramento', 'CA', 958],
  ['Las Vegas', 'NV', 891],
  ['Henderson', 'NV', 890],
];

const ASSET_KINDS: readonly AssetKind[] = ['Server', 'Switch', 'Sensor', 'Gateway'];
const ENCLOSURES = ['Steel', 'Aluminium', 'Polymer', 'Composite', 'Fibreglass'] as const;
const POWER_SOURCES = ['Mains', 'Solar', 'Battery', 'Generator', 'Hybrid'] as const;
const TIERS = ['Standard', 'Business', 'Critical'] as const;
const PLACEMENTS = ['Indoor', 'Outdoor'] as const;
const MOUNTINGS = ['Rack', 'Wall'] as const;
const COOLING = ['Passive', 'Fan', 'Liquid'] as const;
const VENDORS = ['Northwind', 'Vertex Labs', 'Helios', 'Paddock', 'Meridian'];
const NOTES = [
  'Gate code 4471. Access is through the service corridor, please lock up.',
  'Site contact prefers morning visits.',
  'Power supply was replaced in spring; check the intake pressure.',
  'Throughput dips in late summer; monitor the queue depth closely every visit.',
  'Key under the mat.',
];
const PHONE_TYPES: readonly PhoneType[] = ['Work', 'Mobile', 'Home', 'Unknown'];

function pad(n: number, width: number): string {
  return String(n).padStart(width, '0');
}

function makeAddress(r: Random): Address {
  const [city, region, zipPrefix] = r.pick(CITIES);
  const address: Address = {
    address1: `${r.int(100, 9999)} ${r.pick(STREETS)}`,
    city,
    region,
    postalCode: `${zipPrefix}${pad(r.int(0, 99), 2)}`,
    country: 'US',
  };
  if (r.chance(0.2)) address.address2 = `Unit ${r.int(1, 40)}`;
  return address;
}

function makePhone(r: Random): string {
  return `(${r.int(201, 989)}) ${r.int(200, 999)}-${pad(r.int(0, 9999), 4)}`;
}

let assetSeq = 0;

function makeAsset(r: Random, accountId: string): Asset {
  assetSeq += 1;
  const kind = r.pick(ASSET_KINDS);
  const asset: Asset = {
    id: `as_${pad(assetSeq, 4)}`,
    kind,
    capacity: kind === 'Sensor' ? r.int(3, 12) * 100 : r.int(5, 40) * 1000,
    accountId,
  };
  // Optional fields are sometimes missing on purpose (exercises the "-" fallback).
  if (r.chance(0.5))
    asset.name = r.pick(['Primary node', 'Backup node', 'Edge relay', 'Cold store', 'Front desk']);
  if (r.chance(0.85)) asset.enclosure = r.pick(ENCLOSURES);
  if (r.chance(0.9)) asset.powerSource = r.pick(POWER_SOURCES);
  if (r.chance(0.85)) asset.tier = r.pick(TIERS);
  if (r.chance(0.8)) asset.placement = r.pick(PLACEMENTS);
  if (r.chance(0.8)) asset.mounting = r.pick(MOUNTINGS);
  if (r.chance(0.75)) asset.coolingType = r.pick(COOLING);
  if (r.chance(0.7)) {
    asset.installedAtUTC = new Date(
      Date.UTC(r.int(1985, 2023), r.int(0, 11), r.int(1, 28)),
    ).toISOString();
  }
  if (r.chance(0.6)) asset.vendor = r.pick(VENDORS);
  if (r.chance(0.4)) asset.notes = r.pick(NOTES);
  return asset;
}

function makeSite(r: Random, accountId: string, index: number): Site {
  const assetCount = r.chance(0.1) ? 0 : r.int(1, r.chance(0.15) ? 6 : 2);
  return {
    identifiers: { id: `${accountId}_st${index + 1}` },
    address: makeAddress(r),
    assets: Array.from({ length: assetCount }, () => makeAsset(r, accountId)),
  };
}

function makeAccount(r: Random, index: number): Account {
  const id = `a_${pad(index + 1, 4)}`;
  const firstName = r.pick(FIRST_NAMES);
  const lastName = r.pick(LAST_NAMES);
  const account: Account = {
    identifiers: { id },
    displayName: { firstName, lastName },
  };
  if (r.chance(0.55)) {
    account.displayName.companyName = `${r.pick(COMPANY_PREFIXES)} ${r.pick(COMPANY_SUFFIXES)}`;
  }
  if (r.chance(0.92)) account.billingAddress = makeAddress(r);

  if (r.chance(0.95)) {
    const emailCount = r.chance(0.1) ? 0 : r.int(1, 3);
    const phoneCount = r.chance(0.1) ? 0 : r.int(1, 3);
    const handle = toPlainLatin(`${firstName}.${lastName}`)
      .replace(/[^A-Za-z.]/g, '')
      .toLowerCase();
    account.contactInformation = {
      emailAddresses: Array.from({ length: emailCount }, (_, i) => ({
        email: i === 0 ? `${handle}@example.com` : `${handle}${i + 1}@mail.example.org`,
      })),
      phoneNumbers: Array.from({ length: phoneCount }, () => ({
        number: makePhone(r),
        phoneType: r.pick(PHONE_TYPES),
      })),
    };
  }

  if (r.chance(0.93)) {
    const siteCount = r.int(1, r.chance(0.2) ? 3 : 1);
    account.sites = Array.from({ length: siteCount }, (_, i) => makeSite(r, id, i));
  }
  return account;
}

/** Generates the deterministic account dataset (235 accounts → 24 pages at size 10). */
export function generateAccounts(count = ACCOUNT_COUNT, seed = ACCOUNT_SEED): Account[] {
  const r = createRandom(seed);
  assetSeq = 0;
  return Array.from({ length: count }, (_, i) => makeAccount(r, i));
}

/** All assets of an account, across its sites. */
export function getAccountAssets(account: Account): Asset[] {
  return (account.sites ?? []).flatMap((s) => s.assets);
}

/** "First Last" display name. */
export function formatAccountName(account: Account): string {
  const { firstName, lastName } = account.displayName;
  return [firstName, lastName].filter(Boolean).join(' ');
}
```

## @/mock/data/people

```ts
import { toPlainLatin } from '../format';
import { createRandom } from '../prng';

/** A generic person record for the feature demos. */
export interface DemoPerson {
  id: string;
  firstName: string;
  lastName: string;
  email: string;
  age: number;
  department: 'Engineering' | 'Sales' | 'Support' | 'Marketing' | 'Finance';
  status: 'active' | 'invited' | 'suspended';
  salary: number;
  joined: string;
  remote: boolean;
  city?: string;
}

const FIRST = [
  'Ava',
  'Ben',
  'Chloé',
  'Dev',
  'Emma',
  'Finn',
  'Gyula',
  'Hana',
  'Ivan',
  'Júlia',
  'Kai',
  'Lena',
  'Mateo',
  'Noah',
  'Olivia',
  'Priya',
  'Quinn',
  'Rosa',
  'Sven',
  'Tariq',
  'Uma',
  'Viktor',
  'Wen',
  'Yusuf',
  'Zoë',
] as const;
const LAST = [
  'Anders',
  'Baker',
  'Chen',
  'Dubois',
  'Evans',
  'Fischer',
  'García',
  'Horváth',
  'Ito',
  'Jensen',
  'Kowalski',
  'López',
  'Müller',
  'Nakamura',
  'Okafor',
  'Patel',
  'Rossi',
  'Schmidt',
  'Tanaka',
  'Weber',
] as const;
const DEPARTMENTS = ['Engineering', 'Sales', 'Support', 'Marketing', 'Finance'] as const;
const STATUSES = ['active', 'active', 'active', 'invited', 'suspended'] as const;
const CITIES = ['Budapest', 'Berlin', 'Austin', 'Lisbon', 'Toronto', 'Osaka', 'Nairobi'] as const;

const cache = new Map<number, DemoPerson[]>();

/** Deterministic people (cached per count). */
export function generatePeople(count: number, seed = 42): DemoPerson[] {
  const key = count * 1000 + seed;
  const hit = cache.get(key);
  if (hit) return hit;
  const r = createRandom(seed);
  const people = Array.from({ length: count }, (_, i): DemoPerson => {
    const firstName = r.pick(FIRST);
    const lastName = r.pick(LAST);
    const handle = toPlainLatin(`${firstName}.${lastName}`).toLowerCase();
    const person: DemoPerson = {
      id: `u${String(i + 1).padStart(5, '0')}`,
      firstName,
      lastName,
      email: `${handle}${i}@example.com`,
      age: r.int(19, 67),
      department: r.pick(DEPARTMENTS),
      status: r.pick(STATUSES),
      salary: r.int(38, 190) * 1000,
      joined: new Date(Date.UTC(r.int(2012, 2026), r.int(0, 11), r.int(1, 28)))
        .toISOString()
        .slice(0, 10),
      remote: r.chance(0.4),
    };
    if (r.chance(0.85)) person.city = r.pick(CITIES);
    return person;
  });
  cache.set(key, people);
  return people;
}
```

## @/mock/data/readings

```ts
import { createRandom, seedFromString } from '../prng';
import type { Reading } from '../types';

export const READINGS_PER_ASSET = 57;

/**
 * Deterministic reading history for one asset (57 samples, newest first).
 * Some readings have no report, which exercises the disabled action states.
 */
export function generateReadings(assetId: string): Reading[] {
  const r = createRandom(seedFromString(assetId));
  const start = Date.UTC(2026, 7, 28, 9, 0, 0);
  const readings: Reading[] = [];
  for (let i = 0; i < READINGS_PER_ASSET; i++) {
    const id = `${assetId}_rd${String(i + 1).padStart(3, '0')}`;
    const reading: Reading = {
      id,
      date: new Date(start - i * 7 * 86_400_000 - r.int(0, 8) * 3_600_000).toISOString(),
      loadFactor: r.float(6.8, 8.2, 1),
      inputVoltage: r.float(0, 6, 1),
      outputVoltage: r.float(0, 5, 1),
      throughput: r.int(0, 4200),
      errorRate: r.int(0, 120),
      queueDepth: r.int(40, 180),
      memoryUsed: r.int(100, 600),
      diskUsed: r.int(300, 3500),
      packetLoss: r.int(0, 1500),
      jitter: r.float(0, 1, 2),
      latency: r.float(0, 8, 1),
      uptimeDays: r.int(0, 80),
      fanSpeed: r.float(0, 1, 2),
      powerDraw: r.int(0, 50),
      peakDraw: r.int(0, 100),
      temperature: r.int(55, 95),
    };
    if (r.chance(0.8)) {
      reading.reportId = `rep_${id}`;
      if (r.chance(0.9)) reading.reportUrl = `https://example.com/reports/${id}.pdf`;
    }
    if (r.chance(0.5)) reading.workOrderId = `wo_${id}`;
    readings.push(reading);
  }
  return readings;
}
```

## @/mock/format

```ts
import type { Address, PhoneType } from './types';

const PHONE_ORDER: Record<PhoneType, number> = { Work: 1, Mobile: 2, Home: 3, Unknown: 4 };

/**
 * Phone list formatting: grouped and sorted by type (Work, Mobile, Home, Unknown), and numbered
 * within a type ("Mobile 2") when that type appears more than once.
 */
export function formatPhoneLines(phones: { number: string; phoneType: PhoneType }[]): string[] {
  const sorted = [...phones].sort((a, b) => PHONE_ORDER[a.phoneType] - PHONE_ORDER[b.phoneType]);
  const totals = new Map<PhoneType, number>();
  for (const p of sorted) totals.set(p.phoneType, (totals.get(p.phoneType) ?? 0) + 1);
  const seen = new Map<PhoneType, number>();
  return sorted.map((p) => {
    const n = (seen.get(p.phoneType) ?? 0) + 1;
    seen.set(p.phoneType, n);
    const label = (totals.get(p.phoneType) ?? 0) > 1 ? `${p.phoneType} ${n}` : p.phoneType;
    return `${label}: ${p.number}`;
  });
}

/** Short address format: "City, ST 12345". */
export function getShortFormatAddress(address: Address | undefined): string {
  if (!address) return '';
  return `${address.city}, ${address.region} ${address.postalCode}`;
}

/** Letters that are not a base letter plus a combining mark, so NFD alone leaves them in place. */
const TRANSLITERATE: Record<string, string> = {
  ø: 'o',
  Ø: 'O',
  æ: 'ae',
  Æ: 'Ae',
  œ: 'oe',
  Œ: 'Oe',
  ß: 'ss',
  ł: 'l',
  Ł: 'L',
  đ: 'd',
  Đ: 'D',
  þ: 'th',
  Þ: 'Th',
};

/** Plain Latin letters for a name: "Bjørn Østergaard" becomes "Bjorn Ostergaard". */
export function toPlainLatin(value: string): string {
  return value
    .normalize('NFD')
    .replace(/\p{M}/gu, '')
    .replace(/[øØæÆœŒßłŁđĐþÞ]/g, (letter) => TRANSLITERATE[letter] ?? letter);
}
```

## @/mock/handlers

```ts
import { delay, http, HttpResponse } from 'msw';
import { getMockConfig, resolveLatency } from './config';
import { generateAccounts } from './data/accounts';
import { searchAccounts } from './server/accounts';
import { getReadingHistory } from './server/readings';
import type { ListingCriteria, SearchAccountRequest } from './types';

let accounts: ReturnType<typeof generateAccounts> | undefined;
export const getAccountDb = () => (accounts ??= generateAccounts());

/** Applies the configured latency and returns an error response when failure is injected. */
async function simulateNetwork(): Promise<Response | undefined> {
  const config = getMockConfig();
  const ms = resolveLatency(config);
  if (ms > 0) await delay(ms);
  if (config.errorRate > 0 && Math.random() < config.errorRate) {
    return HttpResponse.json({ message: 'Injected mock server error' }, { status: 500 });
  }
  return undefined;
}

function listingFromSearchParams(params: URLSearchParams): Partial<ListingCriteria> {
  const listing: Partial<ListingCriteria> = {};
  const pageNumber = params.get('pageNumber');
  const pageSize = params.get('pageSize');
  const sortColumn = params.get('sortColumn');
  const ascending = params.get('ascending');
  if (pageNumber !== null) listing.pageNumber = Number(pageNumber);
  if (pageSize !== null) listing.pageSize = Number(pageSize);
  if (sortColumn) listing.sortColumn = sortColumn;
  if (ascending !== null) listing.ascending = ascending === 'true';
  return listing;
}

export const handlers = [
  // Account search: a POST body carrying the query and the listing criteria.
  http.post('*/api/accounts/search', async ({ request }) => {
    const failure = await simulateNetwork();
    if (failure) return failure;
    const body = ((await request.json().catch(() => ({}))) ?? {}) as SearchAccountRequest;
    return HttpResponse.json(searchAccounts(getAccountDb(), body));
  }),

  // Reading history for one asset, with the listing criteria as search parameters.
  http.get('*/api/assets/:assetId/readings', async ({ request, params }) => {
    const failure = await simulateNetwork();
    if (failure) return failure;
    const url = new URL(request.url);
    return HttpResponse.json(
      getReadingHistory(String(params.assetId), listingFromSearchParams(url.searchParams)),
    );
  }),
];
```

## @/mock/prng

```ts
/** mulberry32: a tiny, fast, seedable PRNG. Same seed → same sequence on every machine. */
export function mulberry32(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

/** Small helper API over a PRNG. */
export interface Random {
  next(): number;
  int(min: number, max: number): number;
  float(min: number, max: number, decimals?: number): number;
  pick<T>(items: readonly T[]): T;
  chance(probability: number): boolean;
}

export function createRandom(seed: number): Random {
  const next = mulberry32(seed);
  return {
    next,
    int: (min, max) => Math.floor(next() * (max - min + 1)) + min,
    float: (min, max, decimals = 2) => Number((next() * (max - min) + min).toFixed(decimals)),
    pick: <T>(items: readonly T[]): T => {
      const item = items[Math.floor(next() * items.length)];
      if (item === undefined) throw new Error('pick() on an empty list');
      return item;
    },
    chance: (probability) => next() < probability,
  };
}

/** Derives a stable numeric seed from a string (FNV-1a). */
export function seedFromString(value: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < value.length; i++) {
    hash ^= value.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}
```

## @/mock/ready

```ts
/**
 * Readiness of the MSW mock server.
 *
 * The worker and its data generators are a few hundred kilobytes, so they are not part of the
 * app: the first demo that calls `mockFetch` starts them, and a reader who never opens a
 * server-mode demo never downloads them at all. Everything that talks to `/api/*` must go
 * through `mockFetch` for that reason.
 */
let started: Promise<void> | undefined;

export function whenMockReady(): Promise<void> {
  started ??= import('./browser')
    .then(({ startMockServer }) => startMockServer())
    .catch((error: unknown) => {
      console.warn('[site] mock server failed to start; server-mode demos will not work.', error);
    })
    .finally(() => {
      // The end-to-end tests wait on this flag rather than on a timeout.
      document.documentElement.dataset.mockReady = 'true';
    });
  return started;
}

/** `fetch` that starts the mock server if it is not running yet, then waits for it. */
export async function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
  await whenMockReady();
  return fetch(input, init);
}
```

## @/mock/server/accounts

```ts
import { formatAccountName, getAccountAssets } from '../data/accounts';
import { toPlainLatin } from '../format';
import type { Account, ListingCriteria, PagedResponse, SearchAccountRequest } from '../types';

export const DEFAULT_PAGE_SIZE = 10;
export const MAX_PAGE_SIZE = 100;

/**
 * Normalizes the listing criteria so the server can echo them back.
 * Inputs come from untrusted JSON, so non-finite values fall back to the defaults.
 */
export function normalizeListingCriteria(
  input: Partial<ListingCriteria> | undefined,
): ListingCriteria {
  const requestedPageSize = input?.pageSize ?? DEFAULT_PAGE_SIZE;
  const requestedPageNumber = input?.pageNumber ?? 0;
  const pageSize = Math.min(
    MAX_PAGE_SIZE,
    Math.max(
      1,
      Number.isFinite(requestedPageSize)
        ? Math.trunc(requestedPageSize) || DEFAULT_PAGE_SIZE
        : DEFAULT_PAGE_SIZE,
    ),
  );
  const pageNumber = Number.isFinite(requestedPageNumber)
    ? Math.max(0, Math.trunc(requestedPageNumber))
    : 0;
  const normalized: ListingCriteria = { pageNumber, pageSize };
  if (input?.sortColumn) {
    normalized.sortColumn = input.sortColumn;
    normalized.ascending = input.ascending ?? true;
  }
  return normalized;
}

/** Case- and diacritic-insensitive folding: "ostergaard" finds "Østergaard". */
export function fold(value: string): string {
  return toPlainLatin(value).toLowerCase();
}

function accountSearchText(a: Account): string {
  const parts: string[] = [formatAccountName(a), a.displayName.companyName ?? ''];
  const addr = a.billingAddress;
  if (addr) parts.push(addr.address1, addr.city, addr.region, addr.postalCode);
  for (const e of a.contactInformation?.emailAddresses ?? []) parts.push(e.email);
  for (const p of a.contactInformation?.phoneNumbers ?? []) parts.push(p.number);
  for (const s of a.sites ?? []) parts.push(s.address.address1, s.address.city);
  return fold(parts.join(' '));
}

const sortAccessors: Record<string, (a: Account) => string | number> = {
  id: (a) => a.identifiers.id,
  accountName: (a) => fold(`${a.displayName.lastName} ${a.displayName.firstName}`),
  companyName: (a) => fold(a.displayName.companyName ?? ''),
  assets: (a) => getAccountAssets(a).length,
};

/**
 * `POST /api/accounts/search`:
 * `{ queryCriteria, listingCriteria }` → `{ items, totalItemCount, requestCriteria }`.
 * Multi-word queries match when every word matches (AND).
 */
export function searchAccounts(
  accounts: readonly Account[],
  request: SearchAccountRequest,
): PagedResponse<Account> {
  const criteria = normalizeListingCriteria(request.listingCriteria);
  const words = fold(request.queryCriteria?.trim() ?? '')
    .split(/\s+/)
    .filter(Boolean);

  let matches =
    words.length === 0
      ? [...accounts]
      : accounts.filter((a) => {
          const text = accountSearchText(a);
          return words.every((w) => text.includes(w));
        });

  const accessor =
    criteria.sortColumn && Object.hasOwn(sortAccessors, criteria.sortColumn)
      ? sortAccessors[criteria.sortColumn]
      : undefined;
  if (accessor) {
    const dir = criteria.ascending === false ? -1 : 1;
    // Array.prototype.sort is stable, so ties keep their original order.
    matches = matches.sort((a, b) => {
      const va = accessor(a);
      const vb = accessor(b);
      return va < vb ? -dir : va > vb ? dir : 0;
    });
  }

  const start = criteria.pageNumber * criteria.pageSize;
  return {
    items: matches.slice(start, start + criteria.pageSize),
    totalItemCount: matches.length,
    requestCriteria: criteria,
  };
}
```

## @/mock/server/readings

```ts
import { generateReadings } from '../data/readings';
import type { ListingCriteria, PagedResponse, Reading } from '../types';
import { normalizeListingCriteria } from './accounts';

const cache = new Map<string, Reading[]>();

/** `GET /api/assets/:assetId/readings?pageNumber&pageSize&sortColumn&ascending`. Newest first. */
export function getReadingHistory(
  assetId: string,
  listing: Partial<ListingCriteria>,
): PagedResponse<Reading> {
  let readings = cache.get(assetId);
  if (!readings) {
    readings = generateReadings(assetId);
    cache.set(assetId, readings);
  }
  const criteria = normalizeListingCriteria(listing);
  let rows = readings;
  const key = criteria.sortColumn as keyof Reading | undefined;
  if (key && key !== 'date') {
    const dir = criteria.ascending === false ? -1 : 1;
    rows = [...readings].sort((a, b) => {
      const va = a[key] ?? '';
      const vb = b[key] ?? '';
      return va < vb ? -dir : va > vb ? dir : 0;
    });
  } else if (key === 'date' && criteria.ascending) {
    rows = [...readings].reverse();
  }
  const start = criteria.pageNumber * criteria.pageSize;
  return {
    items: rows.slice(start, start + criteria.pageSize),
    totalItemCount: rows.length,
    requestCriteria: criteria,
  };
}
```

## @/mock/types

```ts
// Domain types for the demo mock server. A generic asset-management domain: accounts own sites,
// sites hold assets, and assets accumulate readings over time.

export interface Address {
  address1: string;
  address2?: string;
  city: string;
  /** State / region. */
  region: string;
  postalCode: string;
  country: string;
}

export type PhoneType = 'Work' | 'Mobile' | 'Home' | 'Unknown';

export type AssetKind = 'Server' | 'Switch' | 'Sensor' | 'Gateway';

export interface Asset {
  id: string;
  name?: string;
  kind: AssetKind;
  /** Rated throughput in units/hour. */
  capacity: number;
  enclosure?: string;
  powerSource?: string;
  tier?: string;
  placement?: string;
  mounting?: string;
  coolingType?: string;
  installedAtUTC?: string;
  vendor?: string;
  notes?: string;
  accountId: string;
}

export interface Site {
  identifiers: { id: string };
  address: Address;
  assets: Asset[];
}

export interface Account {
  identifiers: { id: string };
  displayName: { firstName: string; lastName: string; companyName?: string };
  billingAddress?: Address;
  contactInformation?: {
    emailAddresses: { email: string }[];
    phoneNumbers: { number: string; phoneType: PhoneType }[];
  };
  sites?: Site[];
}

/** One telemetry sample for an asset: 16 numeric metrics plus an optional report. */
export interface Reading {
  id: string;
  date: string;
  reportUrl?: string;
  reportId?: string;
  workOrderId?: string;
  loadFactor: number;
  inputVoltage: number;
  outputVoltage: number;
  throughput: number;
  errorRate: number;
  queueDepth: number;
  memoryUsed: number;
  diskUsed: number;
  packetLoss: number;
  jitter: number;
  latency: number;
  uptimeDays: number;
  fanSpeed: number;
  powerDraw: number;
  peakDraw: number;
  temperature: number;
}

/** The server's paging contract. */
export interface ListingCriteria {
  ascending?: boolean;
  pageNumber: number;
  pageSize: number;
  sortColumn?: string;
}

export interface SearchAccountRequest {
  listingCriteria?: Partial<ListingCriteria>;
  queryCriteria?: string;
}

export interface PagedResponse<T> {
  items: T[];
  totalItemCount: number;
  /** The normalized request, echoed back by the server. */
  requestCriteria: ListingCriteria;
}
```
