# Tool panel

The rail of section tabs at the grid's right edge: column visibility and pinning, the AND/OR filter builder, and grouping with per-column aggregates.


The tool panel is a rail of section tabs docked at the grid's right edge; selecting a tab opens a full-height pane beside it. It is **on by default** — every `<PretableSurface>` and `<Pretable>` renders the rail with no section open — because column control is table-stakes UI a reader expects to find on the grid itself, not something each application should have to rebuild. The rail consumes width from the surface's own box rather than adding to it, so enabling (or opening) it never reflows the surrounding layout.

Three sections ship today, and all three are documented below: **Columns**, which hides, pins, and reorders columns; **Filters**, which builds the query's AND/OR filter tree; and **Grouping**, which manages the group-by levels, expansion, the hide-grouped-columns switch, and per-column aggregates. The rail is not closed at those three — [Custom sections](#custom-sections) covers adding your own panes, hiding built-ins, and reordering the tabs. In the grid below, `defaultActiveSection` opens the columns pane on load — uncheck a row to hide its column, drag a grip to reorder, use the row's ⋮ menu to pin:

### Example: The columns section

The pane opens on load via defaultActiveSection. Hide, pin, and reorder columns and watch the drawn header follow each commit.

Source: https://pretable.ai/examples/tool-panel.md

```tsx ToolPanelGrid.tsx
"use client";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { holdings, type Holding } from "./data";

const VIEWPORT_HEIGHT = 340;

export function ToolPanelGrid() {
  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        The rail is on by default; here <code>defaultActiveSection</code> opens
        the Columns pane too. Uncheck a row to hide its column · drag a grip (or
        focus it and press <kbd>Shift</kbd>+<kbd>↑</kbd>/<kbd>↓</kbd>) to
        reorder · the ⋮ menu pins · <strong>Reset columns</strong> restores the
        mount-time layout.
      </p>
      {/*
        The column layout is deliberately uncontrolled: the panel writes
        order, pinning, and visibility straight into the engine, and a
        controlled `state.columnOrder` would re-impose the prop over every
        commit the panel makes.
      */}
      <PretableSurface<Holding>
        ariaLabel="Holdings"
        columns={columns}
        getRowId={(row) => row.id}
        rows={holdings}
        toolPanel={{ defaultActiveSection: "columns" }}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Holding } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const count = new Intl.NumberFormat("en-US");

// Symbol starts pinned left so the panel's Pinned left subgroup renders from
// the first paint — dragging a row across that subgroup boundary (or pressing
// Shift+Arrow past it) re-pins the column.
export const columns: PretableColumn<Holding>[] = [
  { id: "symbol", header: "Symbol", pinned: "left", widthPx: 90 },
  { id: "desk", header: "Desk", widthPx: 110 },
  { id: "sector", header: "Sector", widthPx: 120 },
  {
    id: "quantity",
    header: "Qty",
    type: "number",
    widthPx: 90,
    format: ({ value }) => count.format(value as number),
  },
  {
    id: "price",
    header: "Price",
    type: "number",
    widthPx: 90,
    format: ({ value }) => usd.format(value as number),
  },
  {
    id: "marketValue",
    header: "Market value",
    type: "number",
    widthPx: 120,
    format: ({ value }) => usd.format(value as number),
  },
];
```

```ts data.ts
export interface Holding {
  id: string;
  symbol: string;
  desk: string;
  sector: string;
  quantity: number;
  price: number;
  marketValue: number;
}

export const holdings: Holding[] = [
  {
    id: "h1",
    symbol: "NVDA",
    desk: "Equities",
    sector: "Technology",
    quantity: 4200,
    price: 122,
    marketValue: 512_400,
  },
  {
    id: "h2",
    symbol: "MSFT",
    desk: "Equities",
    sector: "Technology",
    quantity: 1800,
    price: 416,
    marketValue: 748_900,
  },
  {
    id: "h3",
    symbol: "LLY",
    desk: "Equities",
    sector: "Healthcare",
    quantity: 620,
    price: 784,
    marketValue: 486_100,
  },
  {
    id: "h4",
    symbol: "UNH",
    desk: "Equities",
    sector: "Healthcare",
    quantity: 950,
    price: 528,
    marketValue: 501_300,
  },
  {
    id: "h5",
    symbol: "XOM",
    desk: "Equities",
    sector: "Energy",
    quantity: 3100,
    price: 118,
    marketValue: 364_800,
  },
  {
    id: "h6",
    symbol: "JPM",
    desk: "Credit",
    sector: "Financials",
    quantity: 2400,
    price: 260,
    marketValue: 623_500,
  },
  {
    id: "h7",
    symbol: "GS",
    desk: "Credit",
    sector: "Financials",
    quantity: 780,
    price: 538,
    marketValue: 419_700,
  },
  {
    id: "h8",
    symbol: "CVX",
    desk: "Credit",
    sector: "Energy",
    quantity: 1500,
    price: 159,
    marketValue: 238_200,
  },
  {
    id: "h9",
    symbol: "TLT",
    desk: "Macro",
    sector: "Financials",
    quantity: 5600,
    price: 89,
    marketValue: 497_800,
  },
  {
    id: "h10",
    symbol: "USO",
    desk: "Macro",
    sector: "Energy",
    quantity: 8800,
    price: 70,
    marketValue: 611_600,
  },
  {
    id: "h11",
    symbol: "SMH",
    desk: "Macro",
    sector: "Technology",
    quantity: 1250,
    price: 264,
    marketValue: 329_400,
  },
  {
    id: "h12",
    symbol: "QQQ",
    desk: "Macro",
    sector: "Technology",
    quantity: 900,
    price: 503,
    marketValue: 452_700,
  },
];
```


## Enabling, disabling, configuring

`toolPanel` accepts `boolean | PretableToolPanelConfig`. The default is `true`: rail visible, no pane open. Pass `false` to remove rail and pane both:

```tsx
<PretableSurface
  ariaLabel="Holdings"
  rows={rows}
  columns={columns}
  toolPanel={false}
/>
```

The `<Pretable>` preset forwards `toolPanel` verbatim, so the same two lines of intent work there — the preset is default-on for the same reason the surface is:

```tsx
<Pretable
  ariaLabel="Holdings"
  rows={rows}
  columns={columns}
  toolPanel={false}
/>
```

## Configuration

Passing an object keeps the panel on, chooses which sections the rail carries, and controls which one is open. `ToolPanelSectionId` is the union of shipped section ids — today `"columns"`, `"filters"`, and `"grouping"`. The three active-section fields take the wider `PretableToolPanelSectionId` — the built-in literals plus any [custom section](#custom-sections)'s id — so a consumer-authored pane is nameable everywhere a built-in is.

| Option                  | Type                                                          | Required | Description                                                                                                                                                                              |
| ----------------------- | ------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sections`              | `readonly (ToolPanelSectionId \| PretableToolPanelSection)[]` | no       | The complete rail, in order: built-ins by id, [custom sections](#custom-sections) as descriptors, freely interleaved. Absent, the rail is the three built-ins; `[]` turns the panel off. |
| `defaultActiveSection`  | `PretableToolPanelSectionId \| null`                          | no       | The section open on mount when the surface owns the state. Defaults to `null` — rail visible, nothing open.                                                                              |
| `activeSection`         | `PretableToolPanelSectionId \| null`                          | no       | Present (including `null`, meaning "open nothing") makes the open section fully controlled: tab clicks then only report through `onActiveSectionChange`.                                 |
| `onActiveSectionChange` | `(section: PretableToolPanelSectionId \| null) => void`       | no       | Reports every open/close, controlled or not — the same assert-and-report split as `state` and `onSelectionChange`.                                                                       |

The rail's accessible name and the built-in section tab labels are messages like any other grid string: override `toolPanelLabel`, `toolPanelColumnsLabel`, `toolPanelFiltersLabel` and `toolPanelGroupingLabel` on the `messages` prop to localize them. A [custom section](#custom-sections)'s label is not a message — it is the descriptor's own plain string, localized where the rest of your application is.

## The columns section

The pane lists every data column in **drawn order** — the order the engine actually renders, grouped into Pinned left, unpinned, and Pinned right subgroups. Each row carries:

- **A visibility checkbox.** Unchecking hides the column from the grid. Hidden columns stay listed, dimmed, exactly where they were — a hidden column keeps its place in the order and its pin, so re-showing it puts it back where it came from rather than appending it somewhere surprising.
- **A drag grip.** Dragging a row reorders the column; dragging past a subgroup boundary re-pins it (into Pinned left, Pinned right, or back to unpinned). The commit happens on drop, never mid-drag, and `Escape` mid-drag cancels without committing.
- **A ⋮ menu** with Pin left, Pin right, and Unpin. The menu is also the only way to pin into an _empty_ pinned group: with no rows in a subgroup there is no boundary to drag or arrow across, so the menu is the affordance that creates the first member.
- **Search** filters the list by column label; **Reset columns** restores the order, pinning, and visibility the grid mounted with.

Everything the panel commits writes straight into the engine, so the grid it changes is the same layout header gestures change. That has one consequence worth knowing before you control layout state: a controlled `state.columnOrder` or `state.columnPinned` remains the authority, and it re-imposes the prop's layout over the panel's commits whenever the write-back effect re-runs — any state change reaching the surface is enough. Leave those slices uncontrolled when the panel should own them.

## The filters section

The Filters pane is a builder over the query's **filter tree** — the same `filters` the header funnel writes, and the same nesting a [server-side implementation](/docs/server-data) has to translate. Where the funnel edits one column in isolation, the pane shows the whole tree at once, and it is the only chrome that can nest one.

Add a filter, switch its column to **Sector** and give it a value, and watch the count under the grid drop; open that column's header funnel and find the row you just built already sitting in it; then add a group and flip its **and** to **or** — a filter built _inside_ the group stays the pane's, because a funnel only ever addresses a top-level row:

### Example: The filters section

The Filters pane opens on load via defaultActiveSection. Add a condition, nest a group, flip a list between and and or, and watch the row count follow — then open a header funnel onto the same tree.

Source: https://pretable.ai/examples/tool-panel-filters.md

```tsx FilterBuilderGrid.tsx
"use client";

import { PretableSurface, type PretableTelemetry } from "@pretable/react";
import { useCallback, useState } from "react";

import { columns } from "./columns";
import { holdings, type Holding } from "./data";

const VIEWPORT_HEIGHT = 380;

export function FilterBuilderGrid() {
  const [counts, setCounts] = useState({
    shown: holdings.length,
    total: holdings.length,
  });

  // `rowModelRowCount` is the post-filter row count; `visibleRowCount` would
  // be the viewport's, which changes when you scroll and says nothing about
  // the filter. Set through a value comparison so a telemetry publication
  // that did not move either number cannot re-render.
  const onTelemetryChange = useCallback((telemetry: PretableTelemetry) => {
    setCounts((current) =>
      current.shown === telemetry.rowModelRowCount &&
      current.total === telemetry.totalRowCount
        ? current
        : {
            shown: telemetry.rowModelRowCount,
            total: telemetry.totalRowCount,
          },
    );
  }, []);

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        The Filters pane is open on load. <strong>+ filter</strong> adds a
        condition on the first column — switch its picker to{" "}
        <strong>Sector</strong>, then open that header&apos;s funnel: the filter
        you just built is already in it, and one written there appears here.{" "}
        <strong>+ group</strong> nests a list, and the <strong>and</strong> /{" "}
        <strong>or</strong> button sets the connective for that whole list. A
        filter inside a group has no funnel to appear in — a funnel addresses
        only a top-level row.
      </p>
      {/*
        The query is deliberately uncontrolled — the panel writes filters
        straight into the engine, and the funnel writes into the same tree.
        Owning `query` here would put a third writer in the loop for no gain.
      */}
      <PretableSurface<Holding>
        ariaLabel="Holdings"
        columns={columns}
        getRowId={(row) => row.id}
        onTelemetryChange={onTelemetryChange}
        rows={holdings}
        toolPanel={{ defaultActiveSection: "filters" }}
        viewportHeight={VIEWPORT_HEIGHT}
      />
      <p style={{ margin: "8px 0 0", fontSize: 13 }}>
        Showing <code data-testid="filtered-row-count">{counts.shown}</code> of{" "}
        <code data-testid="total-row-count">{counts.total}</code> holdings.
      </p>
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Holding } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const count = new Intl.NumberFormat("en-US");

// `desk` and `sector` are declared `enum` without `options`, so both the
// header funnel and the panel's row get a checklist derived from the rows
// themselves — the same distinct-value request, from the same row model.
export const columns: PretableColumn<Holding>[] = [
  { id: "symbol", header: "Symbol", widthPx: 90 },
  { id: "desk", header: "Desk", type: "enum", widthPx: 110 },
  { id: "sector", header: "Sector", type: "enum", widthPx: 120 },
  {
    id: "quantity",
    header: "Qty",
    type: "number",
    widthPx: 90,
    format: ({ value }) => count.format(value as number),
  },
  {
    id: "price",
    header: "Price",
    type: "number",
    widthPx: 90,
    format: ({ value }) => usd.format(value as number),
  },
  {
    id: "marketValue",
    header: "Market value",
    type: "number",
    widthPx: 120,
    format: ({ value }) => usd.format(value as number),
  },
];
```

```ts data.ts
export interface Holding {
  id: string;
  symbol: string;
  desk: string;
  sector: string;
  quantity: number;
  price: number;
  marketValue: number;
}

export const holdings: Holding[] = [
  {
    id: "h1",
    symbol: "NVDA",
    desk: "Equities",
    sector: "Technology",
    quantity: 4200,
    price: 122,
    marketValue: 512_400,
  },
  {
    id: "h2",
    symbol: "MSFT",
    desk: "Equities",
    sector: "Technology",
    quantity: 1800,
    price: 416,
    marketValue: 748_900,
  },
  {
    id: "h3",
    symbol: "LLY",
    desk: "Equities",
    sector: "Healthcare",
    quantity: 620,
    price: 784,
    marketValue: 486_100,
  },
  {
    id: "h4",
    symbol: "UNH",
    desk: "Equities",
    sector: "Healthcare",
    quantity: 950,
    price: 528,
    marketValue: 501_300,
  },
  {
    id: "h5",
    symbol: "XOM",
    desk: "Equities",
    sector: "Energy",
    quantity: 3100,
    price: 118,
    marketValue: 364_800,
  },
  {
    id: "h6",
    symbol: "JPM",
    desk: "Credit",
    sector: "Financials",
    quantity: 2400,
    price: 260,
    marketValue: 623_500,
  },
  {
    id: "h7",
    symbol: "GS",
    desk: "Credit",
    sector: "Financials",
    quantity: 780,
    price: 538,
    marketValue: 419_700,
  },
  {
    id: "h8",
    symbol: "CVX",
    desk: "Credit",
    sector: "Energy",
    quantity: 1500,
    price: 159,
    marketValue: 238_200,
  },
  {
    id: "h9",
    symbol: "TLT",
    desk: "Macro",
    sector: "Financials",
    quantity: 5600,
    price: 89,
    marketValue: 497_800,
  },
  {
    id: "h10",
    symbol: "USO",
    desk: "Macro",
    sector: "Energy",
    quantity: 8800,
    price: 70,
    marketValue: 611_600,
  },
  {
    id: "h11",
    symbol: "SMH",
    desk: "Macro",
    sector: "Technology",
    quantity: 1250,
    price: 264,
    marketValue: 329_400,
  },
  {
    id: "h12",
    symbol: "QQQ",
    desk: "Macro",
    sector: "Technology",
    quantity: 900,
    price: 503,
    marketValue: 452_700,
  },
];
```


### Rows, runs, and the join

The pane renders one **row** per leaf of the tree: a column picker, an operator picker, and whatever value control the operator's shape calls for — one field, a min/max pair, a checklist, or nothing at all for `isEmpty` and `isNotEmpty`. Those are the same [operators](/docs/grid/filtering#operators) the funnel offers, resolved the same way from the column's `type` and its `filterOperators`. `+ filter` appends a row, `+ group` appends a nested list with its own add pair, and each row's ✕ removes it.

A list of siblings carries exactly **one** connective. The first row reads `Where`; every row after it carries the list's join, and pressing it flips the connective **for the whole list**, not for that one row. That is the tree's shape rather than a simplification of it — a group _is_ its operator (`PretableFilterNodeFor`'s group arm is `{ op, children }`), so there is nowhere for a per-row connective to live. `A and B or C` is not a tree, and a control that appeared to build one would be writing a query the engine then has to interpret some other way.

The **root list is an implicit AND**, and its join renders as plain text with no button: the query's `filters` is an array rather than a group, so there is no `op` on it to flip. Nesting is therefore the whole grammar — to express an OR, add a group and flip the join inside it.

### An empty group matches every row

A group with no children evaluates **true**, under `and` and `or` alike. That is deliberate, and it is what makes the builder usable at all: you add a group before you have anything to put in it, and a group that evaluated false while empty would blank the grid for exactly as long as it took you to fill it.

The same rule covers a row you have started but not finished. Until a row has a value the engine can act on, it holds its position in the tree **as an empty group** — so a half-typed row constrains nothing, and neither does one you walk away from. That has a visible consequence worth knowing: abandon a row and it is still there next time the pane opens, but as an empty group — a bare rail with an add pair, because the tree kept the position while the unfinished draft did not survive. Remove it with its ✕.

### What commits, and when

Everything the pane commits goes straight into the engine's query — there is no apply button, and the grid re-filters as you build.

- **An operand is debounced ~200 ms**, the same dwell the funnel menu uses. A value that committed on every keystroke would rebuild the row model once per character, which on a large grid is a cost you can feel. The dwell is keyed on the operand _slot_, not on how the value is entered, so a date picked from a `date` field waits exactly as a typed one does.
- **Every discrete change applies immediately**: the column, the operator, an enum checkbox, the join button, adding a row or a group, removing one. Nothing about those is mid-thought, so nothing waits.
- **Nesting is bounded at 64 levels below the root.** That is the engine's own bound — a deeper tree makes `setQuery` reject the query rather than filter with it — so at the bound both add buttons refuse, and each renders the reason as text it points at. A disabled button is not focusable, so a tooltip alone would reach nobody.
- **`+ filter` is refused when there is no column to filter on** — every column opted out with `filterable: false`, or no columns at all — in the same way and for the same reason: a control that cannot act says so.

### One tree, two chromes

The funnel and the pane edit one model, so a filter written in either turns up in the other. Two asymmetries follow from what a funnel can address:

- A funnel writes and edits its column's **top-level leaf** — a row sitting directly in the root list. It has no address for a leaf nested inside a group, so a filter you build inside a group stays the pane's to edit.
- A funnel **lights** when its column appears **anywhere** in the tree, nested or not. The tint answers "is this column constrained", which is true either way; the menu it opens is still the top-level leaf.

Which columns each chrome offers differs too, in the one direction the pane can afford to be more generous. `filterable: false` removes a column from both. But a **hidden** column still appears in the pane's picker — marked as hidden in the picker's accessible name, not by dimming alone, which would be a [WCAG 1.4.1](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) failure — and so does a column that grouping has drawn out of the header. Neither has a header left to hang a funnel on, and filtering by a column you have hidden or grouped by is a real thing to want.

A grouped column carries its own marker — the `toolPanelColumnGroupedMarker` message, default "grouped" — and it appears **only while the column is not drawn**: grouped, with [Hide grouped columns](#hide-grouped-columns) on. The marker's job is to explain why a column you can filter by is absent from the header, and a grouped column that is still drawn needs no explanation. The two markers are distinct — "grouped" is not "hidden" — and where both apply, hidden wins.

### DOM hooks and strings

The parts expose stable hooks, the same way the [funnel menu](/docs/grid/filtering#the-built-in-menu) does: `data-pretable-filter-row` on a row, with `data-pretable-filter-row-column`, `-operator`, `-value`, and `-remove` on its controls; `data-pretable-filter-join` on the connective (a `<button>` where the join can change, a `<span>` where it cannot); `data-pretable-filter-add` on the two add buttons; `data-pretable-filter-rail` on a nested group's rail; `data-pretable-filter-column-hidden` on a row whose column is hidden; `data-pretable-filter-column-grouped` on a row whose column is grouped away (grouped and not drawn); and `data-pretable-filter-empty` on the "no filters" message.

Every string the pane renders comes from the `messages` prop — the `toolPanelFilter*` labels for a row's controls and its join, plus `toolPanelAddFilterLabel`, `toolPanelAddGroupLabel`, `toolPanelRemoveFilterLabel`, `toolPanelNoFiltersMessage`, `toolPanelNoFilterValuesMessage`, and the two refusal sentences (`toolPanelFilterDepthRefusal` and `toolPanelNoFilterColumnsRefusal`) — with one exception. The **operator names** in the operator picker are not messages. They are shared verbatim with the header funnel, which is not on the messages layer yet, and localizing them here alone would show one grid's operators in two languages at once. Until the funnel joins, that list is English.

## The grouping section

The Grouping pane manages [row grouping](/docs/grid/grouping) end to end: which columns group the rows, whether the groups are open, whether a grouped column keeps its place in the body, and what each column's group rows aggregate. Four blocks, top to bottom — group-by, expansion, the hide-grouped switch, aggregates — ordered by how often each is reached for.

Add **Sector** as a second level from **+ Add group**, drag a grip to reorder the two, and watch the strip above the header follow; then change **Market value**'s aggregate from `Default (Sum)` to `None` and watch its group cells blank:

### Example: The grouping section

The Grouping pane opens on load via defaultActiveSection. Add and reorder group-by levels, expand and collapse everything at once, flip hide-grouped-columns, and override a column's aggregate — with the drag-to-group strip reflecting every change.

Source: https://pretable.ai/examples/tool-panel-grouping.md

```tsx GroupingSectionGrid.tsx
"use client";

import { useState, type ComponentProps } from "react";

import { PretableSurface } from "@pretable/react";

import { columns } from "./columns";
import { holdings, type Holding } from "./data";

const VIEWPORT_HEIGHT = 380;

export function GroupingSectionGrid() {
  // The query is controlled only to seed one grouping level on load; the
  // setter hands every later write straight back, so the pane's group-by
  // list, the drag-to-group strip, and this prop stay one model.
  const [query, setQuery] = useState<
    NonNullable<ComponentProps<typeof PretableSurface<Holding>>["query"]>
  >({
    filters: [],
    sort: [],
    rowGroups: [{ columnId: "desk" }],
  });

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        The Grouping pane is open on load, with rows already grouped by{" "}
        <strong>Desk</strong>. <strong>+ Add group</strong> adds{" "}
        <strong>Sector</strong> as a second level; drag a grip to reorder the
        levels, and ✕ removes one — the strip above the header shows every
        change, because both are projections of one model. Flip{" "}
        <strong>Hide grouped columns</strong> to keep the grouped column in the
        body, and change <strong>Market value</strong>&apos;s aggregate — its{" "}
        <strong>Default (Sum)</strong> is the prop&apos;s choice, and{" "}
        <strong>None</strong> blanks the group row&apos;s cell without touching
        the prop.
      </p>
      <PretableSurface<Holding>
        ariaLabel="Holdings"
        columns={columns}
        getRowId={(row) => row.id}
        groupPanel={{ enabled: true }}
        onQueryChange={setQuery}
        query={query}
        rows={holdings}
        toolPanel={{ defaultActiveSection: "grouping" }}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Holding } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

const count = new Intl.NumberFormat("en-US");

// `marketValue` declares `aggregate: "sum"`, so its picker in the pane opens
// on `Default (Sum)`; `quantity` declares nothing, so its picker opens on
// `Default (None)` — the difference between "the prop's choice" and "an
// override" is the thing the pane makes visible.
export const columns: PretableColumn<Holding>[] = [
  { id: "symbol", header: "Symbol", widthPx: 90 },
  { id: "desk", header: "Desk", type: "enum", widthPx: 110 },
  { id: "sector", header: "Sector", type: "enum", widthPx: 120 },
  {
    id: "quantity",
    header: "Qty",
    type: "number",
    widthPx: 90,
    format: ({ value }) => count.format(value as number),
    // NOT `format`. `PretableFormatInput.row` is non-optional and a group row
    // has no row behind it, so the cell formatter above is not a legal
    // aggregate formatter — this is the hook that is.
    formatAggregate: ({ value }) =>
      typeof value === "number" ? count.format(value) : "",
  },
  {
    id: "marketValue",
    header: "Market value",
    type: "number",
    widthPx: 120,
    aggregate: "sum",
    format: ({ value }) => usd.format(value as number),
    formatAggregate: ({ value }) =>
      typeof value === "number" ? usd.format(value) : "",
  },
];
```

```ts data.ts
export interface Holding {
  id: string;
  symbol: string;
  desk: string;
  sector: string;
  quantity: number;
  price: number;
  marketValue: number;
}

export const holdings: Holding[] = [
  {
    id: "h1",
    symbol: "NVDA",
    desk: "Equities",
    sector: "Technology",
    quantity: 4200,
    price: 122,
    marketValue: 512_400,
  },
  {
    id: "h2",
    symbol: "MSFT",
    desk: "Equities",
    sector: "Technology",
    quantity: 1800,
    price: 416,
    marketValue: 748_900,
  },
  {
    id: "h3",
    symbol: "LLY",
    desk: "Equities",
    sector: "Healthcare",
    quantity: 620,
    price: 784,
    marketValue: 486_100,
  },
  {
    id: "h4",
    symbol: "UNH",
    desk: "Equities",
    sector: "Healthcare",
    quantity: 950,
    price: 528,
    marketValue: 501_300,
  },
  {
    id: "h5",
    symbol: "XOM",
    desk: "Equities",
    sector: "Energy",
    quantity: 3100,
    price: 118,
    marketValue: 364_800,
  },
  {
    id: "h6",
    symbol: "JPM",
    desk: "Credit",
    sector: "Financials",
    quantity: 2400,
    price: 260,
    marketValue: 623_500,
  },
  {
    id: "h7",
    symbol: "GS",
    desk: "Credit",
    sector: "Financials",
    quantity: 780,
    price: 538,
    marketValue: 419_700,
  },
  {
    id: "h8",
    symbol: "CVX",
    desk: "Credit",
    sector: "Energy",
    quantity: 1500,
    price: 159,
    marketValue: 238_200,
  },
  {
    id: "h9",
    symbol: "TLT",
    desk: "Macro",
    sector: "Financials",
    quantity: 5600,
    price: 89,
    marketValue: 497_800,
  },
  {
    id: "h10",
    symbol: "USO",
    desk: "Macro",
    sector: "Energy",
    quantity: 8800,
    price: 70,
    marketValue: 611_600,
  },
  {
    id: "h11",
    symbol: "SMH",
    desk: "Macro",
    sector: "Technology",
    quantity: 1250,
    price: 264,
    marketValue: 329_400,
  },
  {
    id: "h12",
    symbol: "QQQ",
    desk: "Macro",
    sector: "Technology",
    quantity: 900,
    price: 503,
    marketValue: 452_700,
  },
];
```


### Group by

The list renders one row per grouping level, in level order — grip, label, ✕ — the columns section's row anatomy on a different model. **+ Add group** opens a menu of every data column not already grouped (there is no opt-in flag; any data column can group), ✕ removes a level, and dragging a grip reorders — the commit happens on drop, never mid-drag, and `Escape` mid-drag cancels without committing. With nothing grouped the list says so and the rest of the pane waits.

The list is a **pure projection of the query's `rowGroups`** — the same model the [drag-to-group strip](/docs/grid/grouping) writes, and the strip stays: strip and pane are two projections of one model, so a level added in either appears in both, and neither keeps a copy that could disagree. A grouped column the columns section has hidden still shows in the list, unmarked — grouping by a hidden column is legal and in effect, and the strip does not mark it either.

### Expand all, collapse all

Two buttons, calling the row model's own expand-all and collapse-all. Both are **disabled while nothing is grouped** — they act on groups, and with none they are noise — with the standard disabled treatment rather than disappearing, so the pane's shape does not jump as grouping comes and goes.

### Hide grouped columns

A labelled switch over the engine's `hideGroupedColumns` — on by default, matching the grid: a grouped column's values are already in the group rows, so drawing its body column too usually just repeats them. Flip it off to keep grouped columns in the body.

One thing to know before you also pass the surface's `hideGroupedColumns` prop: the prop seeds this state at mount **and keeps writing changed values back after mount**. A consumer who keeps driving the prop declaratively retains ownership — the pane and the prop are a two-writer situation the grid does not arbitrate; a consumer who leaves the prop alone after mount cedes the state to the switch.

### Aggregates

One picker per data column, listing:

- **`Default (…)`** — no override: the column follows whatever its prop declares, and the parenthetical shows what that is — `Default (Sum)` for `aggregate: "sum"`, `Default (None)` for no declaration, `Default (Custom)` for a declared custom aggregator. Choosing it clears any override.
- **`None`** — an override meaning "show no aggregate": the row model strips the column's declared `aggregate` before the query compiles, so the group cell renders empty. This is a value, not a clear — which is why it and `Default (…)` both exist.
- **The type-valid builtins** — `Sum`, `Average`, `Min`, `Max`, `Count` for `number` columns; `Count` alone for every other type, mirroring [what the engine accepts](/docs/grid/grouping#aggregates-and-number-formatting). Choosing one writes an engine override.

`Default (…)` is an explicit option so that "no override" and "overridden to the same value" never look alike: a column showing `Default (Sum)` follows its prop, and one showing `Sum` has been pinned there — change the prop and only the first follows. Overrides are per-column configuration, not per-grouping: they persist while nothing is grouped (the block stays visible then, so a configured override stays reachable) and re-apply when grouping returns.

The block renders **in rows mode only**. In explicit-model mode the caller owns their row model and the surface never re-requests its derivations, so an aggregate write would be recorded in engine state and have no effect on what a group row shows — a visible-but-inert picker being the worst outcome, the block is absent there rather than disabled. State an aggregate on the model's own columns instead.

### The grouping section's DOM hooks and strings

The section's container is `data-pretable-tool-grouping`. A group-by row is `data-pretable-tool-group-row` (with `data-pretable-column-id`), its grip `data-pretable-tool-row-grip` and its remove button `data-pretable-tool-group-remove`; the add button is `data-pretable-add-group` and its menu `data-pretable-add-group-menu`. The expansion pair is `data-pretable-expand-all` / `data-pretable-collapse-all`, the switch's input `data-pretable-hide-grouped`, and an aggregate row `data-pretable-aggregate-row` (with `data-pretable-column-id`).

Every string is a message: `toolPanelGroupByLabel`, `toolPanelAddRowGroupLabel`, `toolPanelRemoveGroupLabel`, `toolPanelReorderGroupLabel`, and `toolPanelNoGroupsMessage` for the group-by block; `toolPanelExpandAllLabel` and `toolPanelCollapseAllLabel`; `toolPanelHideGroupedColumnsLabel`; and for the pickers `toolPanelAggregatesLabel`, `toolPanelAggregateColumnLabel`, `toolPanelAggregateDefaultOption`, `toolPanelAggregateNoneOption`, `toolPanelAggregateCustomLabel`, and the five builtin names (`toolPanelAggregateSumLabel` through `toolPanelAggregateCountLabel`). The reorder grip deliberately does **not** reuse the columns section's "Reorder {column}": the two grips coexist in one panel, and identical accessible names would leave a screen-reader user unable to tell reordering a column from reordering a grouping level.

## Custom sections

The rail is not closed at the three built-ins. `toolPanel.sections` is the **complete roster**: present, it states the whole rail in order — built-ins referenced by id, custom sections as `PretableToolPanelSection` descriptors, freely interleaved. One shape subsumes appending a pane, hiding a built-in, reordering the tabs, and slotting your section between two shipped ones:

```tsx
<PretableSurface
  ariaLabel="Trades"
  rows={rows}
  columns={columns}
  toolPanel={{
    // Grouping is dropped, the custom section sits second: a subset,
    // a reorder, and an interleave in one array.
    sections: ["columns", notesSection, "filters"],
  }}
/>
```

Absent, the rail is the three built-ins exactly as shipped. A built-in referenced by id keeps every behavior it has today — its messages, its config coupling — the roster only selects and orders. And `sections: []` is legal and means "no sections": rail hidden, pane closed, the panel effectively off — equivalent to `toolPanel={false}`, but reachable from a dynamic roster without switching prop shapes.

An **invalid roster throws at render**, with a `[pretable] toolPanel.sections:` message naming the id and the rule: a duplicate id, an empty or whitespace id, an unknown built-in reference, or a custom descriptor reusing a built-in id. These are programming errors present from the first render, and a silently dropped tab would be the harder bug to find. The built-in-collision error also states that **replacing a built-in section is not supported** — reusing `"columns"` as a custom descriptor's id is a collision, not an override.

An `activeSection` naming an id the roster does not carry is the one lenient case: the rail renders alone and nothing opens, without throwing — a controlled consumer may set the id a frame before the roster carries it.

In the grid below, the rail carries Columns, a custom **Actions** section, then Filters — press its buttons to scroll the grid or export it through the handle `onGridReady` delivers:

### Example: A custom section

toolPanel.sections states the complete rail: Columns, a consumer-authored Actions section, then Filters, with grouping left off. The custom pane's buttons reach the grid through the handle onGridReady delivers — scroll to a row, or export the grid as CSV.

Source: https://pretable.ai/examples/tool-panel-custom-section.md

```tsx CustomSectionGrid.tsx
"use client";

import { useMemo, useRef } from "react";

import { PretableSurface } from "@pretable/react";
import type {
  PretableColumn,
  PretableSurfaceGrid,
  PretableToolPanelConfig,
} from "@pretable/react";

import { columns } from "./columns";
import { trades, type Trade } from "./data";

const VIEWPORT_HEIGHT = 320;

function ActionsIcon({ className }: { className?: string }) {
  return (
    <svg
      aria-hidden="true"
      className={className}
      fill="none"
      height="16"
      stroke="currentColor"
      strokeWidth="1.5"
      viewBox="0 0 16 16"
      width="16"
    >
      <path d="M8 2v8M4.5 6.5 8 10l3.5-3.5" />
      <path d="M3 13h10" />
    </svg>
  );
}

export function CustomSectionGrid() {
  // The one route to the grid from inside a custom section: `onGridReady`
  // hands the surface's grid handle to a ref, and the section's `render`
  // closes over that ref. No context argument needed — or offered.
  const grid = useRef<PretableSurfaceGrid<
    Trade,
    string,
    readonly PretableColumn<Trade>[]
  > | null>(null);

  // The COMPLETE rail, in order: grouping is dropped, and the custom section
  // sits between the two built-ins it is interleaved with. Held stable in a
  // memo — a roster built inline would only rebuild the descriptor array each
  // render, but stable is the habit worth copying.
  const toolPanel = useMemo<PretableToolPanelConfig>(
    () => ({
      sections: [
        "columns",
        {
          id: "actions",
          icon: ActionsIcon,
          label: "Actions",
          render: () => (
            <div style={{ display: "grid", gap: 8, padding: 4 }}>
              <h3 style={{ fontSize: 13, margin: 0 }}>Actions</h3>
              <button
                onClick={() => grid.current?.scrollToRow("t1")}
                type="button"
              >
                Jump to first trade
              </button>
              <button
                onClick={() => grid.current?.scrollToRow("t28")}
                type="button"
              >
                Jump to last trade
              </button>
              <button onClick={() => grid.current?.exportCsv()} type="button">
                Download CSV
              </button>
            </div>
          ),
        },
        "filters",
      ],
      defaultActiveSection: "actions",
    }),
    [],
  );

  return (
    <div>
      <p style={{ margin: "0 0 8px", fontSize: 13 }}>
        The rail carries <strong>Columns</strong>, a custom{" "}
        <strong>Actions</strong> section, and <strong>Filters</strong> — in that
        order, with the grouping built-in left off the roster. The Actions pane
        is open on load via{" "}
        <code>defaultActiveSection: &quot;actions&quot;</code>; its buttons
        reach the grid through the handle <code>onGridReady</code> delivers —
        jump the viewport to either end, or download the grid as CSV.
      </p>
      <PretableSurface<Trade>
        ariaLabel="Trades"
        columns={columns}
        getRowId={(row) => row.id}
        onGridReady={(ready) => {
          grid.current = ready;
        }}
        rows={trades}
        toolPanel={toolPanel}
        viewportHeight={VIEWPORT_HEIGHT}
      />
    </div>
  );
}
```

```ts columns.ts
import type { PretableColumn } from "@pretable/react";

import type { Trade } from "./data";

const usd = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
});

export const columns: PretableColumn<Trade>[] = [
  { id: "symbol", header: "Symbol", widthPx: 90 },
  { id: "side", header: "Side", type: "enum", widthPx: 80 },
  { id: "desk", header: "Desk", type: "enum", widthPx: 100 },
  { id: "quantity", header: "Qty", type: "number", widthPx: 90 },
  {
    id: "price",
    header: "Price",
    type: "number",
    widthPx: 100,
    format: ({ value }) => usd.format(value as number),
  },
];
```

```ts data.ts
export interface Trade {
  id: string;
  symbol: string;
  side: string;
  desk: string;
  quantity: number;
  price: number;
}

const SYMBOLS = [
  ["NVDA", 122],
  ["MSFT", 416],
  ["AAPL", 213],
  ["LLY", 784],
  ["UNH", 528],
  ["XOM", 118],
  ["JPM", 260],
  ["GS", 538],
  ["CVX", 159],
  ["TLT", 89],
  ["USO", 70],
  ["QQQ", 503],
] as const;

const DESKS = ["Equities", "Credit", "Macro"] as const;

// Deterministic on purpose: a docs example that rendered differently on every
// load would make its own prose wrong.
export const trades: Trade[] = Array.from({ length: 28 }, (_, i) => {
  const [symbol, price] = SYMBOLS[i % SYMBOLS.length] as readonly [
    string,
    number,
  ];
  return {
    id: `t${i + 1}`,
    symbol,
    side: i % 3 === 0 ? "Sell" : "Buy",
    desk: DESKS[i % DESKS.length] as string,
    quantity: 50 + ((i * 37) % 400),
    price,
  };
});
```


### The descriptor

| Field    | Type                                    | Required | Description                                                                                                                                                                                                                                           |
| -------- | --------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id`     | `string`                                | yes      | Non-empty, no whitespace — it is interpolated into DOM ids (the tab's id, the pane's `aria-labelledby`), where whitespace is forbidden. Must not collide with `"columns"`, `"filters"`, or `"grouping"`. Carried verbatim on `data-pretable-section`. |
| `icon`   | `ComponentType<{ className?: string }>` | yes      | The rail tab's icon component.                                                                                                                                                                                                                        |
| `label`  | `string`                                | yes      | The rail tooltip and the tab's accessible name.                                                                                                                                                                                                       |
| `render` | `() => ReactNode`                       | yes      | The pane's content. Takes no arguments — see below for how a section reaches the grid.                                                                                                                                                                |

`label` is a **plain string, not a message key** — deliberately. A custom section is consumer-owned UI, and you localize it where you localize the rest of your application; routing one label through the grid's `messages` prop would split your strings across two localization systems for no gain. The messages layer stays the built-ins'.

`render` takes no arguments; everything the pane needs, it closes over. A section that needs the grid itself holds the handle via the surface's existing `onGridReady` prop:

```tsx
const grid = useRef<PretableSurfaceGrid<
  Trade,
  string,
  readonly PretableColumn<Trade>[]
> | null>(null);

const notesSection: PretableToolPanelSection = {
  id: "notes",
  icon: NotesIcon,
  label: "Notes",
  // The render closure reads the ref when the button is pressed —
  // by then `onGridReady` has long since filled it.
  render: () => (
    <button onClick={() => grid.current?.exportCsv()} type="button">
      Download CSV
    </button>
  ),
};

<PretableSurface
  ariaLabel="Trades"
  rows={rows}
  columns={columns}
  onGridReady={(ready) => {
    grid.current = ready;
  }}
  toolPanel={{ sections: ["columns", notesSection, "filters"] }}
/>;
```

A `sections` array built inline re-creates itself every render, which rebuilds the internal descriptor array — a little work and nothing else: React reconciles the pane's child by position and type, so a re-rendered section is not a remounted one, and its own component state survives. Hold the roster stable (a module constant, or a memo) to skip even that.

### What the shell gives, and what a section owes

A custom pane inherits the shell's accessibility contract with no work on your part: the pane is a `role="tabpanel"` labelled by its tab, `Escape` inside it returns focus to the rail tab, the pane unmounts when closed, and the rail stays one Tab stop however many sections you add.

What the shell **cannot** enforce is the conduct of the content you render into it. Two rules are not optional, and this grid's own history is the argument — the project once shipped a grid that was keyboard-unreachable and a focus trap at once, a WCAG level-A failure on both counts, and it was found by a hard gate rather than a person. Do not rebuild that bug inside a pane:

<Callout type="warning">
  A custom section MUST keep the panel's keyboard contract. **Every interactive
  control in the pane must be reachable by `Tab`, in DOM order** — no positive
  `tabindex`, no controls reachable only by pointer. And the pane must **never
  trap focus**: forward `Tab` from the pane's last control must leave the panel
  and continue into the page. A pane that swallows `Tab` makes the whole grid a
  cage for a keyboard user, which is a [WCAG 2.1.2 (No Keyboard
  Trap)](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html)
  failure — level A, the floor.
</Callout>

Ordinary form controls in ordinary DOM order satisfy both rules with no further effort — the shell's own e2e suite walks a consumer-authored pane exactly this way to prove the contract holds.

On a [server-rendered grid](/docs/grid#knowing-when-a-server-rendered-grid-is-interactive), a custom section's tab is painted but inert until React hydrates, like every other grid control: gate on `data-pretable-hydrated="true"` before driving it from a test.

## Keyboard

The rail is one Tab stop, however many sections it grows.

| Key                               | Action                                                                                                                                                                                                              |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Tab`                             | Reach the rail (one stop). From the open rail tab, `Shift+Tab` enters the pane at its **last** control — the pane precedes the rail in tab order.                                                                   |
| `↑` / `↓` (rail)                  | Move between section tabs. Focus only — nothing opens.                                                                                                                                                              |
| `Enter` / `Space` (rail)          | Open the focused section; on the already-open section, close it.                                                                                                                                                    |
| `Escape` (anywhere in the pane)   | Dismiss the innermost open thing first — a mid-drag reorder is cancelled (see above), an open ⋮ menu closes back to its kebab; with nothing open, focus returns to the rail tab.                                    |
| `Shift+↑` / `Shift+↓` (on a grip) | Move the column — or, in the grouping pane, the grouping level — one position. Crossing a columns-section subgroup boundary re-pins; at the list's ends nothing moves. Focus follows the row, so the chord repeats. |

Each row's grip is its own Tab stop, so the chord is reachable without a pointer. The two panes' grips carry different accessible names — "Reorder {column}" in the columns section, "Reorder grouping by {column}" in the grouping section — because both can be in the panel at once. Pinning into an empty pinned group is the one operation the chord cannot express — that is the columns row's ⋮ menu.

The grouping pane's remaining controls are ordinary form controls — the expansion buttons, the switch, one `<select>` per aggregate row — and its **+ Add group** menu is the same menu keyboard the filters pane's pickers use: arrows move, `Enter` selects, `Escape` closes back to the button.

The filters pane needs no chords of its own: its controls are ordinary form controls, walked in the order the tree reads — column, operator, value, remove, then the run's add pair, and the same again inside each group. Two things are not stops there. The **join** is focusable only where it can actually change anything: on the second and later rows of a nested run it is a button, and everywhere else — every run's first row, and every row of the root run, whose implicit AND has nothing to flip — it is plain text. And the plain buttons may be skipped by the browser: WebKit leaves a `<button>` out of the Tab order unless macOS's "Tab moves between all controls" is on, which is a platform preference rather than something a grid can set.

The rail tab is a stop in every browser, and it is the last one inside the panel. Whichever section is open, one more forward `Tab` from there leaves the grid entirely — the panel is not a trap.

## What's ahead

The rail is built to hold more sections, and `ToolPanelSectionId` names exactly the ones that ship — [custom sections](#custom-sections) are how you add your own today, and a new built-in would join that closed union. Replacing a built-in section by reusing its id is deliberately not supported; if it ever is, it will be designed, not implied.

## Where to go next

- [Column layout](/docs/grid/column-layout) — the header-gesture and controlled-state side of order, pinning, and widths.
- [Filtering](/docs/grid/filtering) — the header funnel, the operator families, and the controlled query the filters section writes into.
- [Grouping](/docs/grid/grouping) — the row-grouping model itself: the query's `rowGroups`, aggregates, expansion, and the drag-to-group strip.
- [Keyboard](/docs/grid/keyboard) — the grid's own navigation model the panel sits beside.
- [`<PretableSurface>`](/docs/grid/pretable-surface) — every configuration surface in one place.
