<!-- Protocore Design System — DataTable -->
# DataTable

- **Category:** Data Display (`data-display`)
- **Slug:** `data-display/data-table`
- **Status:** stable
- **Platforms:** web
- **Import:** `import { DataTable } from "@protocore/pds";`
- **Docs:** https://pds.protocore.io/components/data-display/data-table

> The server-driven console workhorse — TanStack v8 in manual mode with sorting, pagination, selection, and five body states.

## When to use it

Reach for **DataTable** for every list screen backed by a server: transactions, events, API keys, audit logs — anything paginated, sortable, selectable, or fetched. It bakes in the console doctrine so you don't re-implement it per screen: the none→desc→asc sort cycle, roving-tabindex keyboard navigation, the selection bar, and the loading/empty/error/data states with a copyable debug id.

Use the lower-level **Table** primitive instead for small, static, hand-rendered grids you fully control. Use **DefinitionList** for a single entity's fields (key→value), not a two-column table. Because sorting and pagination are manual, DataTable pairs naturally with your data layer's query state — the component holds *no* opinion about how you fetch.

## Selection needs a stable row id

Whenever you pass `selection`, also pass `getRowId`. Without it, selection keys by page-local index, so flipping the page silently re-targets the current selection onto different rows (the component warns about this in development). A stable id from your data — a transaction id, a key id — keeps selection correct across pages.

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `bare` | `boolean` | no | `false` | Drop the outer border + surface so the table sits flush inside another frame. |
| `className` | `string` | no | — | Extra className on the outer container. |
| `columns` | `ColumnDef<TData, unknown>[]` | yes | — | TanStack column definitions. Use `meta.numeric` for amount columns; display columns need no accessor. |
| `data` | `TData[]` | yes | — | The current page of rows (already fetched by the server). |
| `emptySlot` | `ReactNode` | no | — | Purposeful empty content shown when there are zero rows. |
| `error` | `DataTableError \| null` | no | `null` | Failure payload — renders the error panel (with retry + debugId) below the header. |
| `getRowId` | `((row: TData, index: number) => string)` | no | — | Stable row identity — required whenever `selection` is used. |
| `loading` | `boolean` | no | `false` | Show skeleton rows instead of data. Loading wins over `error`. |
| `minWidth` | `number` | no | `960` | The table's natural min width; the panel scrolls horizontally below it. |
| `onRowClick` | `((row: TData) => void)` | no | — | Open a row. Enables keyboard row navigation (roving tabindex, ↑/↓ wrap, Enter). |
| `onSortingChange` | `((next: SortingState) => void)` | no | — | Called with the next sort state on a header click (none → desc → asc → none). |
| `pagination` | `DataTablePagination` | no | — | Pagination footer wiring — omit to hide the footer. |
| `pinFirstColumn` | `boolean` | no | `true` | Freeze the first data column while the body scrolls horizontally. |
| `selection` | `DataTableSelection` | no | — | Row-selection wiring — adds a checkbox column and the selection bar. |
| `skeletonRows` | `number` | no | `6` | How many skeleton rows to draw while `loading`. |
| `sorting` | `SortingState` | no | — | Server-driven sort state — the component never sorts client-side. |
| `summary` | `ReactNode` | no | — | Toolbar left side — a count summary (e.g. "1,328 events"). Use `<b>` for emphasised figures. |
| `toolbarActions` | `ReactNode` | no | — | Toolbar right side — filters, refresh, export, etc. |

## Examples

### Basics

At its floor DataTable takes just `columns` (TanStack column defs) and `data`. You get the console-styled grid — sticky sunken header, pinned first column, horizontal scroll — plus its built-in body states, with no sorting or pagination wired.

```tsx
import { DataTable, Badge } from "@protocore/pds";
import type { DataTableProps } from "@protocore/pds";

interface Event {
  id: string;
  kind: string;
  source: string;
  status: "ok" | "retry" | "dropped";
}

const columns: DataTableProps<Event>["columns"] = [
  { accessorKey: "id", header: "Event" },
  { accessorKey: "kind", header: "Kind" },
  { accessorKey: "source", header: "Source" },
  {
    accessorKey: "status",
    header: "Status",
    cell: (ctx) => {
      const s = ctx.row.original.status;
      const tone = s === "ok" ? "success" : s === "retry" ? "warning" : "danger";
      return <Badge tone={tone}>{s}</Badge>;
    },
  },
];

const data: Event[] = [
  { id: "evt_7f21", kind: "ledger.settled", source: "settle-worker", status: "ok" },
  { id: "evt_7f20", kind: "fx.quoted", source: "fx-gateway", status: "retry" },
  { id: "evt_7f1f", kind: "webhook.sent", source: "notifier", status: "dropped" },
];

// The floor: columns + data. No sorting, pagination, or selection wired — just
// the console-styled grid with its built-in states.
export default function DataTableMinimal() {
  return <DataTable<Event> columns={columns} data={data} minWidth={480} />;
}
```

### Server-driven console

The flagship pattern. DataTable is **fully manual**: it never sorts or paginates client-side — it reports *intent* (`onSortingChange`, `pagination.onNext/onPrev`) and you feed back the page the server returned. Here an in-memory slice stands in for that server. Row **selection** is keyed by `getRowId`, and the selection bar hosts capability-gated bulk actions. Click a header to cycle none → descending → ascending; select rows to reveal the bar.

```tsx
import * as React from "react";
import { DataTable, Badge, Button, CodeRef, MoneyAmount } from "@protocore/pds";
import type { DataTableProps } from "@protocore/pds";

interface Txn {
  id: string;
  account: string;
  method: string;
  status: "settled" | "pending" | "reversed";
  amount: number; // minor units (cents)
}

const TONE = { settled: "success", pending: "warning", reversed: "danger" } as const;

// A stand-in for the whole table you'd page over on the server.
const ALL: Txn[] = [
  {
    id: "txn_9f2c1a7e",
    account: "acct_eu_00417",
    method: "sepa",
    status: "settled",
    amount: 128_40,
  },
  {
    id: "txn_9f2c1a6b",
    account: "acct_eu_00418",
    method: "card",
    status: "pending",
    amount: 4_120_00,
  },
  {
    id: "txn_9f2c1a52",
    account: "acct_eu_00419",
    method: "sepa",
    status: "reversed",
    amount: -980_00,
  },
  {
    id: "txn_9f2c1a3d",
    account: "acct_eu_00417",
    method: "wire",
    status: "settled",
    amount: 42_500_00,
  },
  {
    id: "txn_9f2c1a28",
    account: "acct_eu_00420",
    method: "card",
    status: "settled",
    amount: 57_80,
  },
  {
    id: "txn_9f2c1a19",
    account: "acct_eu_00418",
    method: "sepa",
    status: "pending",
    amount: 1_204_00,
  },
  {
    id: "txn_9f2c1a04",
    account: "acct_eu_00421",
    method: "wire",
    status: "settled",
    amount: 9_900_00,
  },
  {
    id: "txn_9f2c19f1",
    account: "acct_eu_00419",
    method: "card",
    status: "reversed",
    amount: -212_35,
  },
  {
    id: "txn_9f2c19e7",
    account: "acct_eu_00417",
    method: "sepa",
    status: "settled",
    amount: 3_015_00,
  },
  {
    id: "txn_9f2c19d0",
    account: "acct_eu_00422",
    method: "wire",
    status: "pending",
    amount: 76_000_00,
  },
];

const PAGE_SIZE = 4;

type Sorting = NonNullable<DataTableProps<Txn>["sorting"]>;
type Selection = NonNullable<DataTableProps<Txn>["selection"]>["state"];

const columns: DataTableProps<Txn>["columns"] = [
  {
    accessorKey: "id",
    header: "Transaction",
    enableSorting: false,
    cell: (ctx) => <CodeRef full={ctx.row.original.id} />,
  },
  { accessorKey: "account", header: "Account" },
  { accessorKey: "method", header: "Method", enableSorting: false },
  {
    accessorKey: "status",
    header: "Status",
    cell: (ctx) => {
      const s = ctx.row.original.status;
      return <Badge tone={TONE[s]}>{s}</Badge>;
    },
  },
  {
    accessorKey: "amount",
    header: "Amount",
    meta: { numeric: true },
    cell: (ctx) => (
      <MoneyAmount amount={ctx.row.original.amount} minorUnits={2} currency="EUR" sign="always" />
    ),
  },
];

// A server-driven console table: the component reports sort + page intent, the
// "server" (here, an in-memory slice) responds. Selection is keyed by row id.
export default function DataTableConsole() {
  const [sorting, setSorting] = React.useState<Sorting>([]);
  const [page, setPage] = React.useState(1);
  const [selected, setSelected] = React.useState<Selection>({});

  // Stand in for the server query: sort the full set, then slice the page.
  const sorted = React.useMemo(() => {
    const s = sorting[0];
    if (!s) return ALL;
    const key = s.id as keyof Txn;
    return [...ALL].sort((a, b) => {
      const av = a[key] as string | number;
      const bv = b[key] as string | number;
      const cmp = av === bv ? 0 : (av as never) < (bv as never) ? -1 : 1;
      return s.desc ? -cmp : cmp;
    });
  }, [sorting]);

  const pageCount = Math.ceil(sorted.length / PAGE_SIZE);
  const rows = sorted.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
  const selectedCount = Object.values(selected).filter(Boolean).length;

  return (
    <DataTable<Txn>
      columns={columns}
      data={rows}
      getRowId={(t) => t.id}
      summary={
        <>
          <b>{sorted.length}</b> transactions
        </>
      }
      sorting={sorting}
      onSortingChange={(next) => {
        setSorting(next);
        setPage(1);
      }}
      pagination={{
        page,
        pageCount,
        totalRows: sorted.length,
        onPrev: () => setPage((p) => Math.max(1, p - 1)),
        onNext: () => setPage((p) => Math.min(pageCount, p + 1)),
      }}
      selection={{
        state: selected,
        onChange: setSelected,
        actions: (
          <Button variant="secondary" size="sm" onClick={() => setSelected({})}>
            Reverse {selectedCount}
          </Button>
        ),
      }}
      onRowClick={(t) => console.log("open", t.id)}
      minWidth={720}
    />
  );
}
```

## Usage

**Do**

- Drive `sorting` and `pagination` from your query state — DataTable reports intent, the server returns the page.
- Always pair `selection` with `getRowId` so selection survives page changes.
- Give the error state a `debugId` so operators can copy it straight into a support ticket.
- Mark amount columns `meta.numeric` so they right-align in tabular mono.

**Don't**

- Don't expect client-side sorting or paging — the component is manual by design.
- Don't use it for a fixed handful of static rows; that's the Table primitive.
- Don't gate bulk actions in the UI only — the selection bar renders whatever you pass, so capability-check the actions themselves.
- Don't omit `getRowId` when selecting; index-keyed selection breaks on a page flip.

## Accessibility

**Keyboard**

| Keys | Action |
| --- | --- |
| `Tab` | Moves into the table; with `onRowClick` set, a single row is a roving tabindex stop. |
| `↑ / ↓` | Moves the active row up/down (wraps at the ends) when row navigation is enabled. |
| `Enter` | Opens the focused row (fires `onRowClick`). |
| `Space` | Toggles the row's selection checkbox when focus is on it. |
| `Enter / Space (header)` | Activates a sortable header, cycling none → descending → ascending. |

**Notes**

- Sortable headers expose `aria-sort` (`none`/`ascending`/`descending`) so screen readers announce the current sort.
- The table sets `aria-busy` while loading; the error panel is a `role="alert"` region.
- The header select-all checkbox reflects an indeterminate state as a DOM property when only some rows are selected.
- In-row controls keep their own keyboard behaviour — row navigation only acts when the row element itself holds focus.

## Related

`table`, `pagination`, `empty-state`, `badge`

---

© Protocore. All rights reserved. Use of the Protocore Design System requires prior written authorization from Protocore (contact@protocore.io). These machine-readable materials must not be ingested into ML-training datasets or derivative design systems. See https://pds.protocore.io/legal/
