Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3af7c56
docs: spec the tool panel — shell + columns section first
blove Aug 24, 2026
6a675d2
docs: plan tool panel SP1 — shell + columns section
blove Aug 24, 2026
fdf99c8
feat(grid-core): column visibility with persistent width and pin state
blove Aug 24, 2026
5dc27f6
fix(grid-core): unpinning a hidden column no longer reveals it
blove Aug 24, 2026
3101c20
fix(grid-core): setColumns respects visibility-only changes, honest r…
blove Aug 24, 2026
03ffc7b
fix(grid-core): keyboard navigation skips hidden columns
blove Aug 24, 2026
7a5ce43
feat(react): hidden columns leave the drawn order everywhere at once
blove Aug 24, 2026
5b14f41
fix(grid-core,react): setColumns cancels edits on newly hidden column…
blove Aug 24, 2026
e6c9fd0
chore: api reports and changesets for column visibility
blove Aug 24, 2026
825dc66
feat(ui): tool panel rail, pane, and columns-section styling
blove Aug 24, 2026
84b9bf4
fix(ui): selected tool tab composes with hover; coarse-pointer target…
blove Aug 24, 2026
90d131b
feat(react): tool panel shell — rail, pane, section contract
blove Aug 24, 2026
4e5870d
refactor(react): shared tab-focus helper, rover simplification, blur-…
blove Aug 24, 2026
e0f5383
feat(react): tool panel on the surface, on by default
blove Aug 24, 2026
2719ace
fix(react,ui): prettier, and the seam-surrender rule gets its guard
blove Aug 24, 2026
8205a7d
feat(react): columns section — visibility, search, reset
blove Aug 25, 2026
15bab51
test(react): reset survives columns added or removed since mount
blove Aug 25, 2026
54f9af6
feat(react): per-column pin menu in the columns section
blove Aug 25, 2026
96ab592
test(react): pin the kebab menu to the portal
blove Aug 25, 2026
a80472b
fix(react): the pin menu follows its row through scroll and filtering
blove Aug 25, 2026
8126966
refactor(react): ColumnPinMenu to its own file
blove Aug 25, 2026
7f40c35
feat(react): drag and keyboard reorder in the columns section
blove Aug 25, 2026
0c4e026
fix(react): capture the grip's pointer at pointerdown, so small-targe…
blove Aug 25, 2026
9a51b49
fix(website): reducedMotion belongs in contextOptions
blove Aug 25, 2026
08ca008
docs(grid): the tool panel, and the preset's updated limitations
blove Aug 25, 2026
c5e35df
docs(grid): layered dismissal and effect-rerun wording, honest preset…
blove Aug 25, 2026
8e824f0
chore: prettier-format the tool panel spec
blove Aug 25, 2026
427bf9a
chore: changeset accuracy at branch close
blove Aug 25, 2026
612f544
test(website): the tab-exit guard learns the tool panel's rail stop
blove Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/column-visibility.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@pretable/core": minor
---

Column visibility: `hidden` on column config, `setColumnVisible` on the grid
model.

`PretableGridUiColumn` and `PretableGridUiColumnLayout` gain an optional
`hidden` flag, and the grid model gains `setColumnVisible(columnId, visible)`.
A hidden column stays in the column model — its width, pin state, and relative
order are preserved — but is excluded from the drawn order the renderer and
span-resolving consumers see.
30 changes: 30 additions & 0 deletions .changeset/tool-panel-columns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"@pretable/react": minor
"@pretable/ui": minor
---

Tool panel: a rail-and-pane shell on `PretableSurface`, on by default, opening
with a columns section.

The rail is a strip of section tabs docked at the grid's right edge, inside the
card; selecting a tab opens a 264px pane between the body viewport and the
rail. The rail borrows the header's surface and the pane the toolbar's, so the
panel reads as chrome, not content. It ships enabled — `toolPanel={false}`
removes it — and `PretableToolPanelConfig` drives the open section either way:
`activeSection`/`onActiveSectionChange` controlled, `defaultActiveSection`
uncontrolled. The `<Pretable>` preset passes the prop through, which retires
its documented "no configuration UI" limitation.

The columns section lists every column, subgrouped by pin state: a checkbox
toggles visibility (the engine's new `hidden` flag and `setColumnVisible`,
released alongside in `@pretable/core`, so width, pin state and relative order
survive a round trip), a search box filters the list, "Reset columns" restores
the mount-time configuration, and a per-row kebab menu offers the three pin
placements. Rows reorder by dragging the grip or with Shift+ArrowUp/Down on it;
Escape abandons an in-flight drag or keyboard move without touching the engine.

In `@pretable/ui`, the card chrome — border, radius, shadow — moves up from the
scroll viewport onto a layout wrapper that encloses viewport, pane and rail, so
the docked panel sits inside the card rather than bolted onto it; the boxes
inside surrender their own copies and meet at hairlines. A grid rendered
without the panel paints identically to before.
1 change: 1 addition & 0 deletions apps/website/app/docs/_nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const docsNav: DocsNavSection[] = [
href: "/docs/grid/number-formatting",
},
{ title: "Column layout", href: "/docs/grid/column-layout" },
{ title: "Tool panel", href: "/docs/grid/tool-panel" },
{ title: "Cell renderers", href: "/docs/grid/cell-renderers" },
{
title: "Cell presentations",
Expand Down
2 changes: 2 additions & 0 deletions apps/website/content/docs/grid/pretable-component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ The grid uses the active theme's surface tones, gridlines, semantic colors, and

The preset intentionally does not expose a custom viewport height, filter or grouping UI, controlled state, telemetry, custom cell/header components, or explicit row-model mode. Those belong to [`<PretableSurface>`](/docs/grid/pretable-surface). Drop to the [headless engine](/docs/headless) only when you also need to own rendering.

One piece of configuration UI ships on by default: the [tool panel](/docs/grid/tool-panel), the rail at the grid's right edge whose Columns section hides, pins, and reorders columns. The preset forwards `toolPanel` verbatim — pass `toolPanel={false}` to remove it, or a `PretableToolPanelConfig` to control which section is open.

## Where to go next

- [`<PretableSurface>`](/docs/grid/pretable-surface) — the complete indexed React surface.
Expand Down
1 change: 1 addition & 0 deletions apps/website/content/docs/grid/pretable-surface.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ The modes are mutually exclusive: do not pass `rows` or `getRowId` with `model`.
| Editing | rows mode: `onRowChange`; model mode: `beforeRowChange`; shared: `onPaste` |
| Clipboard | `onCopy`, `copyToClipboard`, `copyWithHeaders`, `messages` |
| Grouping | `groupPanel`, `groupColumn`, `hideGroupedColumns`, `initialExpansion` |
| Tool panel | `toolPanel` — on by default; see [Tool panel](/docs/grid/tool-panel) |
| Observation | `onGridReady`, `onTelemetryChange` |

Rows-mode edit callbacks are proposals: update your `rows` value and let the next prop reconcile. Explicit-model edits validate the batch and publish one row-model transaction.
Expand Down
82 changes: 82 additions & 0 deletions apps/website/content/docs/grid/tool-panel.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
title: Tool panel
description: "The rail of section tabs at the grid's right edge, and its columns section: visibility, pinning, and reorder without leaving the grid."
nav: Grid
---

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.

One section ships today: **Columns**. In the grid below, `defaultActiveSection` opens that pane on load — uncheck a row to hide its column, drag a grip to reorder, use the row's ⋮ menu to pin:

<Example id="tool-panel" />

## 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 and controls which section is open. `ToolPanelSectionId` is the union of shipped section ids — today just `"columns"`.

| Option | Type | Required | Description |
| ----------------------- | ----------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `defaultActiveSection` | `ToolPanelSectionId \| null` | no | The section open on mount when the surface owns the state. Defaults to `null` — rail visible, nothing open. |
| `activeSection` | `ToolPanelSectionId \| null` | no | Present (including `null`, meaning "open nothing") makes the open section fully controlled: tab clicks then only report through `onActiveSectionChange`. |
| `onActiveSectionChange` | `(section: ToolPanelSectionId \| 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 columns tab's label are messages like any other grid string: override `toolPanelLabel` and `toolPanelColumnsLabel` on the `messages` prop to localize them.

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

## 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 one position. Crossing a subgroup boundary re-pins it; at the list's ends nothing moves. Focus follows the row, so the chord repeats. |

Each row's grip is its own Tab stop with the accessible name "Reorder {column}", so the chord is reachable without a pointer. Pinning into an empty pinned group is the one operation the chord cannot express — that is the row's ⋮ menu.

## What's ahead

The rail is built to hold more sections — filtering and grouping panes are planned — but only Columns exists today, and `ToolPanelSectionId` says so honestly.

## Where to go next

- [Column layout](/docs/grid/column-layout) — the header-gesture and controlled-state side of order, pinning, and widths.
- [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.
36 changes: 36 additions & 0 deletions apps/website/content/examples/tool-panel/ToolPanelGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"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>
);
}
41 changes: 41 additions & 0 deletions apps/website/content/examples/tool-panel/columns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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),
},
];
120 changes: 120 additions & 0 deletions apps/website/content/examples/tool-panel/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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,
},
];
5 changes: 5 additions & 0 deletions apps/website/content/examples/tool-panel/demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { ToolPanelGrid } from "./ToolPanelGrid";

export default function Demo() {
return <ToolPanelGrid />;
}
8 changes: 8 additions & 0 deletions apps/website/content/examples/tool-panel/example.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { defineExample } from "../../../lib/docs/examples/define";

export default defineExample({
title: "The columns section",
description:
"The pane opens on load via defaultActiveSection. Hide, pin, and reorder columns and watch the drawn header follow each commit.",
files: ["ToolPanelGrid.tsx", "columns.ts", "data.ts"],
});
Loading