diff --git a/.changeset/column-visibility.md b/.changeset/column-visibility.md new file mode 100644 index 000000000..109f80780 --- /dev/null +++ b/.changeset/column-visibility.md @@ -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. diff --git a/.changeset/tool-panel-columns.md b/.changeset/tool-panel-columns.md new file mode 100644 index 000000000..97693de92 --- /dev/null +++ b/.changeset/tool-panel-columns.md @@ -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 `` 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. diff --git a/apps/website/app/docs/_nav.ts b/apps/website/app/docs/_nav.ts index 59401af90..f2cd11821 100644 --- a/apps/website/app/docs/_nav.ts +++ b/apps/website/app/docs/_nav.ts @@ -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", diff --git a/apps/website/content/docs/grid/pretable-component.mdx b/apps/website/content/docs/grid/pretable-component.mdx index 4c655f4c4..929ed387a 100644 --- a/apps/website/content/docs/grid/pretable-component.mdx +++ b/apps/website/content/docs/grid/pretable-component.mdx @@ -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 [``](/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 - [``](/docs/grid/pretable-surface) — the complete indexed React surface. diff --git a/apps/website/content/docs/grid/pretable-surface.mdx b/apps/website/content/docs/grid/pretable-surface.mdx index 0417cfe49..5d771a3dc 100644 --- a/apps/website/content/docs/grid/pretable-surface.mdx +++ b/apps/website/content/docs/grid/pretable-surface.mdx @@ -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. diff --git a/apps/website/content/docs/grid/tool-panel.mdx b/apps/website/content/docs/grid/tool-panel.mdx new file mode 100644 index 000000000..a21b530e9 --- /dev/null +++ b/apps/website/content/docs/grid/tool-panel.mdx @@ -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 `` and `` 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: + + + +## 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 + +``` + +The `` 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 + +``` + +## 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. +- [``](/docs/grid/pretable-surface) — every configuration surface in one place. diff --git a/apps/website/content/examples/tool-panel/ToolPanelGrid.tsx b/apps/website/content/examples/tool-panel/ToolPanelGrid.tsx new file mode 100644 index 000000000..07ce9cd1c --- /dev/null +++ b/apps/website/content/examples/tool-panel/ToolPanelGrid.tsx @@ -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 ( +
+

+ The rail is on by default; here defaultActiveSection opens + the Columns pane too. Uncheck a row to hide its column · drag a grip (or + focus it and press Shift+/) to + reorder · the ⋮ menu pins · Reset columns restores the + mount-time layout. +

+ {/* + 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. + */} + + ariaLabel="Holdings" + columns={columns} + getRowId={(row) => row.id} + rows={holdings} + toolPanel={{ defaultActiveSection: "columns" }} + viewportHeight={VIEWPORT_HEIGHT} + /> +
+ ); +} diff --git a/apps/website/content/examples/tool-panel/columns.ts b/apps/website/content/examples/tool-panel/columns.ts new file mode 100644 index 000000000..b236ce16c --- /dev/null +++ b/apps/website/content/examples/tool-panel/columns.ts @@ -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[] = [ + { 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), + }, +]; diff --git a/apps/website/content/examples/tool-panel/data.ts b/apps/website/content/examples/tool-panel/data.ts new file mode 100644 index 000000000..7455b2a91 --- /dev/null +++ b/apps/website/content/examples/tool-panel/data.ts @@ -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, + }, +]; diff --git a/apps/website/content/examples/tool-panel/demo.tsx b/apps/website/content/examples/tool-panel/demo.tsx new file mode 100644 index 000000000..6e436a4e2 --- /dev/null +++ b/apps/website/content/examples/tool-panel/demo.tsx @@ -0,0 +1,5 @@ +import { ToolPanelGrid } from "./ToolPanelGrid"; + +export default function Demo() { + return ; +} diff --git a/apps/website/content/examples/tool-panel/example.ts b/apps/website/content/examples/tool-panel/example.ts new file mode 100644 index 000000000..94988c927 --- /dev/null +++ b/apps/website/content/examples/tool-panel/example.ts @@ -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"], +}); diff --git a/apps/website/e2e/grid-tab-wrap-rows.spec.ts b/apps/website/e2e/grid-tab-wrap-rows.spec.ts index e64fa53c6..9e42b2467 100644 --- a/apps/website/e2e/grid-tab-wrap-rows.spec.ts +++ b/apps/website/e2e/grid-tab-wrap-rows.spec.ts @@ -25,6 +25,16 @@ import { waitForGridReady } from "./helpers"; * Every count below is EXACT, not a ceiling. A trap regression re-clamps at a * corner, and a generous `toBeLessThan` bound would be satisfied by a walk * that spent its slack looping — the number is the assertion. + * + * Since the tool panel (tool-panel SP1, 2026-08-24) the surface renders a rail + * of section tabs after the grid, on by default, and the rail is deliberately + * ONE stop in the sequential order — a roving tablist, closed pane, no pane + * controls in this fixture. A forward release therefore lands on the rail tab + * first, and exactly one more press reaches the document. The forward walks + * below name that stop explicitly rather than absorbing it into a count, so a + * rail that grows a second stop — or becomes a trap — fails here loudly. + * Backward exits never meet the rail: it sits between the grid and + * `#after-grid`, not before the grid. */ const FIXTURE = "/fixtures/tab-wrap-rows"; @@ -59,6 +69,9 @@ function focusLocation(page: Page): Promise { // `undefined`, which is not `null` — and a walk parked on `` would // then report itself as still inside the grid forever. if (active === null || active === document.body) return "NONE"; + // Before the id branch: the rail tab carries a React-generated id, and the + // walks reason about it by ROLE, not by that unstable string. + if (active.closest("[data-pretable-tool-tab]") !== null) return "rail:tab"; if (active.id !== "") return `#${active.id}`; const cell = active.closest("[data-pretable-cell]"); if (cell !== null) { @@ -122,6 +135,17 @@ async function arrowTo(page: Page, moves: string[]) { for (const move of moves) await page.keyboard.press(move); } +/** + * A forward release's second half: focus is on the rail tab, and exactly ONE + * more press reaches the document. Asserted as a step, not folded into a + * count, so a rail that gained a second tab stop fails on the landing element + * — the extra press would land `rail:tab` again, never `#after-grid`. + */ +async function assertOnePressPastRail(page: Page) { + await page.keyboard.press("Tab"); + expect(await focusLocation(page)).toBe("#after-grid"); +} + test.describe("wrap-rows exit — WCAG 2.1.2 No Keyboard Trap", () => { test("Tab walks to the bottom-right corner and releases, in exactly rows x columns presses", async ({ page, @@ -133,12 +157,13 @@ test.describe("wrap-rows exit — WCAG 2.1.2 No Keyboard Trap", () => { // The worst case for this configuration, and the number the docs quote: // from the top-left cell, forward release costs rows x columns presses — // 11 to walk the 12 cells, and a 12th that the grid hands back to the - // browser. Anything more is a clamp; anything less means the walk skipped - // cells. + // browser, landing on the rail's designed stop. Anything more is a clamp; + // anything less means the walk skipped cells. expect({ presses, landedOn }).toEqual({ presses: CELLS, - landedOn: "#after-grid", + landedOn: "rail:tab", }); + await assertOnePressPastRail(page); // The positive twin: a wrap-rows that released EVERYWHERE — i.e. did // nothing at all — would leave the grid on press 1 and is the opposite @@ -194,11 +219,12 @@ test.describe("wrap-rows exit — WCAG 2.1.2 No Keyboard Trap", () => { const { presses, landedOn } = await walkOut(page, "Tab"); // From (row index 1, column index 1): two rows of remaining cells and one - // more in this row, then the release. + // more in this row, then the release onto the rail stop. expect({ presses, landedOn }).toEqual({ presses: 2 * COLUMNS + 1 + 1, - landedOn: "#after-grid", + landedOn: "rail:tab", }); + await assertOnePressPastRail(page); }); }); @@ -222,8 +248,9 @@ test.describe("wrap-rows exit — from the column header", () => { const { presses, landedOn } = await walkOut(page, "Tab"); expect({ presses, landedOn }).toEqual({ presses: 1, - landedOn: "#after-grid", + landedOn: "rail:tab", }); + await assertOnePressPastRail(page); }); test(`Shift+Tab leaves in one press from the ${column} header`, async ({ @@ -250,7 +277,14 @@ test.describe("wrap-rows round trip", () => { await arrowTo(page, ["ArrowDown", "ArrowRight"]); const { landedOn } = await walkOut(page, "Tab"); - expect(landedOn).toBe("#after-grid"); + expect(landedOn).toBe("rail:tab"); + await assertOnePressPastRail(page); + + // The mirror of the forward exit: one Shift+Tab from the document lands + // the rail's single stop, and the next enters the grid — the pane is + // closed in this fixture, so nothing else intervenes. + await page.keyboard.press("Shift+Tab"); + expect(await focusLocation(page)).toBe("rail:tab"); await page.keyboard.press("Shift+Tab"); // The grid remembers where it LEFT — the bottom-right corner it released diff --git a/apps/website/e2e/tool-panel.spec.ts b/apps/website/e2e/tool-panel.spec.ts new file mode 100644 index 000000000..378dfcd23 --- /dev/null +++ b/apps/website/e2e/tool-panel.spec.ts @@ -0,0 +1,356 @@ +import { expect, test, type Locator, type Page } from "@playwright/test"; + +import { waitForGridReady, waitForStablePosition } from "./helpers"; + +/** + * The tool panel's columns section, driven with a real pointer and a real + * keyboard — the halves jsdom cannot express. The insertion-index math + * itself is unit-tested (`tool-panel-drop-target.test.ts`); what this file + * proves is that the measured DOM the handlers feed it, the engine commits + * on drop, and the panel's tab order all behave on a live page. + * + * Target: the keyboard-navigation example on /docs/grid/keyboard. Its grid + * ships the tool panel default-on with an UNCONTROLLED column layout (the + * column-layout example controls `columnOrder`/`columnPinned`, which would + * re-impose the prop state over the panel's engine writes), and it declares + * ID pinned left + Status pinned right — so both pinned subgroups render, + * which is what makes cross-boundary drops reachable. + * + * Column roster: id (left) | time account symbol side quantity price | status + * (right). + */ + +/** + * Every in-page jump on the docs site is SMOOTH-scrolled (globals.css), and + * two of them fire around this spec's setup: the scrollIntoView that mounts + * the lazy example, and an intermittent focus-reveal scroll right after the + * rail tab is clicked. Coordinates measured mid-animation are stale by the + * time the mouse presses, which is exactly where a 14px drag handle punishes + * it. The site gates smooth scrolling on prefers-reduced-motion (a supported + * user mode, not a test hook), so the spec declares it and every scroll + * lands in one frame. + */ +test.use({ contextOptions: { reducedMotion: "reduce" } }); + +const KEYBOARD_DOCS = "/docs/grid/keyboard"; + +async function mountExample(page: Page) { + await page.goto(KEYBOARD_DOCS, { waitUntil: "domcontentloaded" }); + // Demos mount lazily on "in view AND selected", so the figure has to be + // scrolled into view before the grid exists to be waited on. + await page + .locator("figure") + .first() + .evaluate((el) => el.scrollIntoView({ block: "center" })); + await waitForGridReady(page); + // The demo replacing its placeholder reflows the figure after the grid is + // already "ready", so hold for the layout to stop moving before anything + // below measures a coordinate or presses a small control. + await waitForStablePosition(railTab(page)); +} + +function railTab(page: Page): Locator { + return page.locator( + '[data-pretable-tool-tab][data-pretable-section="columns"]', + ); +} + +async function openColumnsPane(page: Page): Promise { + const pane = page.locator("[data-pretable-tool-pane]"); + // Retry the click, bounded: on a page still settling, a press can land + // beside the 28px tab (the same dropped-press family the helpers document). + // Toggle-safe: the pane mounts synchronously with the activation, so "no + // pane after the wait" means the click never landed — a re-click cannot be + // closing a pane that was actually opened. + for (let attempt = 0; attempt < 3; attempt++) { + await railTab(page).click(); + try { + await expect(pane).toBeVisible({ timeout: 1_500 }); + return; + } catch { + // fall through to re-click + } + } + await expect(pane).toBeVisible(); +} + +function panelRow(page: Page, columnId: string): Locator { + return page.locator( + `[data-pretable-tool-column-row][data-pretable-column-id="${columnId}"]`, + ); +} + +function grip(page: Page, columnId: string): Locator { + return panelRow(page, columnId).locator("[data-pretable-tool-row-grip]"); +} + +/** Drawn header order, by id — only the columns actually rendered. */ +function headerIds(page: Page): Promise<(string | null)[]> { + return page + .locator("[data-pretable-header-cell]") + .evaluateAll((cells) => + cells.map((cell) => cell.getAttribute("data-pretable-column-id")), + ); +} + +/** Whether DOM focus is inside the tool-panel card (pane or rail included). */ +function focusInPanel(page: Page): Promise { + return page.evaluate(() => { + const active = document.activeElement; + const panel = document.querySelector("[data-pretable-tool-pane]"); + const rail = document.querySelector("[data-pretable-tool-rail]"); + return Boolean( + (panel && active && panel.contains(active)) || + (rail && active && rail.contains(active)), + ); + }); +} + +/** + * Press the grip and cross the drag threshold, VERIFIED: the dragging + * attribute is the component's own statement that the gesture armed. Under + * load the page can still drift a couple of px between measuring and + * pressing (waitForStablePosition is best-effort by design), which lands + * the press beside the ~16px handle — so a missed acquire is released and + * retried against fresh geometry rather than failing the whole test on a + * press that never landed. + */ +async function beginGripDrag( + page: Page, + columnId: string, +): Promise<{ x: number; y: number }> { + for (let attempt = 0; attempt < 3; attempt++) { + const box = await grip(page, columnId).boundingBox(); + if (!box) continue; + const x = box.x + box.width / 2; + const y = box.y + box.height / 2; + await page.mouse.move(x, y); + await page.mouse.down(); + // Cross the 5px slop in two moves — a single jump can be coalesced into + // one event that both crosses the threshold and lands. + await page.mouse.move(x, y + 6, { steps: 2 }); + await page.mouse.move(x, y + 10, { steps: 2 }); + try { + await expect(panelRow(page, columnId)).toHaveAttribute( + "data-pretable-tool-row-dragging", + "", + { timeout: 1_000 }, + ); + return { x, y }; + } catch { + await page.mouse.up(); // missed the handle: release and re-acquire + } + } + throw new Error(`could not arm a drag on the ${columnId} grip`); +} + +test("drag a row two positions down reorders the drawn header", async ({ + page, +}) => { + await mountExample(page); + await openColumnsPane(page); + + // The docs page keeps settling for a beat after the grid hydrates (lazy + // content above the figure), and a scroll between measuring these boxes + // and dragging puts the drop a page-shift away from the aim point — + // measured, not hypothetical: idRect drifted ~170px in one probed run. + await waitForStablePosition(panelRow(page, "id")); + + // Time sits first in the unpinned subgroup: time, account, symbol, ... + // The travel target is measured AFTER the gesture is armed — the arming + // loop is what proves the page has actually stopped moving. + const start = await beginGripDrag(page, "time"); + const sideBox = (await panelRow(page, "side").boundingBox())!; + // Just under "side"'s top edge: past symbol's midpoint, before side's — + // the slot after symbol, two positions down from where time started. + await page.mouse.move(start.x, sideBox.y + 4, { steps: 8 }); + + // Mid-drag: the row is marked, the indicator is drawn, and NOTHING has + // committed yet (commit on drop, never mid-move). + await expect(panelRow(page, "time")).toHaveAttribute( + "data-pretable-tool-row-dragging", + "", + ); + await expect( + page.locator("[data-pretable-tool-drop-indicator]"), + ).toBeVisible(); + const before = await headerIds(page); + expect(before.indexOf("time")).toBeLessThan(before.indexOf("account")); + + await page.mouse.up(); + + const after = await headerIds(page); + expect(after.indexOf("account")).toBeLessThan(after.indexOf("time")); + expect(after.indexOf("symbol")).toBeLessThan(after.indexOf("time")); + expect(after.indexOf("time")).toBeLessThan(after.indexOf("side")); + // The panel list mirrors the commit. + await expect( + page.locator("[data-pretable-tool-column-row]").nth(3), + ).toHaveAttribute("data-pretable-column-id", "time"); +}); + +test("drag across the Pinned-left boundary pins the column", async ({ + page, +}) => { + await mountExample(page); + await openColumnsPane(page); + + // Same settling wait as the reorder drag above — an unsettled page turns + // the aim point into a different row's territory. + await waitForStablePosition(panelRow(page, "id")); + + const start = await beginGripDrag(page, "account"); + const idBox = (await panelRow(page, "id").boundingBox())!; + // The lower third of the ID row: past its midpoint (so the slot is "after + // ID") but above the subgroup gap's split — the Pinned-left side of the + // boundary. + await page.mouse.move(start.x, idBox.y + idBox.height - 4, { steps: 8 }); + await page.mouse.up(); + + // The drawn header shows the pin — the engine regrouped the column into + // the left-pinned strip, right after ID. + const accountHeader = page.locator( + '[data-pretable-header-cell][data-pretable-column-id="account"]', + ); + await expect(accountHeader).toHaveAttribute("data-pretable-pinned", "left"); + const ids = await headerIds(page); + expect(ids.indexOf("account")).toBe(ids.indexOf("id") + 1); + // And the panel row moved into the Pinned left subgroup: it now renders + // before the first unpinned row. + const rowIds = await page + .locator("[data-pretable-tool-column-row]") + .evaluateAll((rows) => + rows.map((row) => row.getAttribute("data-pretable-column-id")), + ); + expect(rowIds.slice(0, 2)).toEqual(["id", "account"]); +}); + +test("keyboard: Shift+ArrowDown on a focused grip moves the row", async ({ + page, +}) => { + await mountExample(page); + await openColumnsPane(page); + + await grip(page, "time").focus(); + + // Precondition, same as the drag test above: time starts BEFORE account, + // so the swap below is a real move — roster drift cannot make the + // post-press assertion pass vacuously. + const before = await headerIds(page); + expect(before.indexOf("time")).toBeLessThan(before.indexOf("account")); + + await page.keyboard.press("Shift+ArrowDown"); + + const ids = await headerIds(page); + expect(ids.indexOf("account")).toBeLessThan(ids.indexOf("time")); + // Focus followed the row — the chord can be pressed again without + // re-acquiring the grip. + await expect(grip(page, "time")).toBeFocused(); +}); + +test("keyboard walk: one rail stop, Enter opens, the pane is traversable, forward-Tab exits, Escape returns", async ({ + page, +}) => { + await mountExample(page); + + // Park focus before the grid (the example figure's own Preview tab), then + // Tab forward: the walk must REACH the rail — through the grid, which is + // its own bounded set of stops — and reach it as ONE stop. + const previewTab = page + .locator("figure") + .first() + .getByRole("tab", { name: "Preview" }); + await previewTab.focus(); + + let reachedRail = false; + for (let i = 0; i < 40; i++) { + await page.keyboard.press("Tab"); + if ( + await page.evaluate( + () => + document.activeElement?.hasAttribute("data-pretable-tool-tab") ?? + false, + ) + ) { + reachedRail = true; + break; + } + } + expect(reachedRail).toBe(true); + + // Roving tabindex: however many sections the rail grows, it is one stop. + await expect( + page.locator('[data-pretable-tool-tab][tabindex="0"]'), + ).toHaveCount(1); + + // Arrows move within the rail without leaving it (SP1 ships one section, + // so the move wraps onto itself — the invariant is "still on a rail tab"). + await page.keyboard.press("ArrowDown"); + expect( + await page.evaluate(() => + document.activeElement?.hasAttribute("data-pretable-tool-tab"), + ), + ).toBe(true); + + // Enter opens the pane. + await page.keyboard.press("Enter"); + await expect(railTab(page)).toHaveAttribute("aria-selected", "true"); + await expect(page.locator("[data-pretable-tool-pane]")).toBeVisible(); + + // Tab order runs [grid][pane][rail][out] — the pane PRECEDES the rail in + // the DOM (visual order, pane left of rail), so from the rail tab one + // backward Tab enters the pane at its last control... + await page.keyboard.press("Shift+Tab"); + expect( + await page.evaluate(() => + Boolean( + document + .querySelector("[data-pretable-tool-pane]") + ?.contains(document.activeElement), + ), + ), + ).toBe(true); + + // ...and from that last control, forward-Tab EXITS the panel — through the + // rail tab, out the far side, no trap. Two presses, bounded and exact. + await page.keyboard.press("Tab"); + expect( + await page.evaluate(() => + document.activeElement?.hasAttribute("data-pretable-tool-tab"), + ), + ).toBe(true); + await page.keyboard.press("Tab"); + expect(await focusInPanel(page)).toBe(false); + + // Escape from inside the pane hands focus back to the rail tab. + await page.locator("[data-pretable-tool-search]").click(); + await page.keyboard.press("Escape"); + await expect(railTab(page)).toBeFocused(); +}); + +test("narrow viewport: the grid area shrinks and the rail stays inside the card", async ({ + page, +}) => { + // The `minWidth: 0` pin owed from Task 6: a flex item's automatic minimum + // is its content's min-content size, and the viewport's content carries + // `minWidth: totalWidth` (780px of columns here) — without the override + // the grid area cannot shrink and shoves the rail out of the card. + await page.setViewportSize({ width: 520, height: 900 }); + await mountExample(page); + + const layoutBox = (await page + .locator("[data-pretable-tool-layout]") + .boundingBox())!; + const railBox = (await page + .locator("[data-pretable-tool-rail]") + .boundingBox())!; + + // The rail's right edge sits inside the card... + expect(railBox.x + railBox.width).toBeLessThanOrEqual( + layoutBox.x + layoutBox.width + 1, + ); + // ...and the card itself fits the viewport: the grid area shrank rather + // than pushing the rail out. + expect(layoutBox.x + layoutBox.width).toBeLessThanOrEqual(521); + expect(railBox.x + railBox.width).toBeLessThanOrEqual(521); +}); diff --git a/apps/website/lib/docs/__tests__/docs-api-surface.test.ts b/apps/website/lib/docs/__tests__/docs-api-surface.test.ts index 91aee1627..8489fc6ed 100644 --- a/apps/website/lib/docs/__tests__/docs-api-surface.test.ts +++ b/apps/website/lib/docs/__tests__/docs-api-surface.test.ts @@ -1322,6 +1322,10 @@ const SLICE_OF = (type: string, owner: string): string => * cannot slip in unbound and a deleted one cannot leave a stale entry. */ const TABLES: Record = { + "grid/tool-panel.mdx#Configuration": { + types: [{ pkg: "react", name: "PretableToolPanelConfig" }], + complete: true, + }, "grid/paste.mdx#The payload": { types: [{ pkg: "react", name: "PastePayload" }], complete: true, @@ -1449,6 +1453,7 @@ const TABLES: Record = { */ const MEMBER_TABLE_OPTIONALITY: Record = { "grid/export.mdx#Options": true, + "grid/tool-panel.mdx#Configuration": true, // The four cell-presentation tables are the live consumers. Each `Required` // cell is held against the interface's own `?`, so documenting `tone` as // optional on a status (it is not) or `secondary` as required on an entity @@ -1495,6 +1500,7 @@ const MEMBER_TABLE_OPTIONALITY: Record = { */ const MEMBER_TABLE_TYPES: Record = { "grid/export.mdx#Options": true, + "grid/tool-panel.mdx#Configuration": true, "grid/paste.mdx#The payload": true, "grid/paste.mdx#The payload (table 2)": true, "grid/clipboard.mdx#Building your own serializer": true, @@ -1696,6 +1702,12 @@ const STRING_UNIONS: Record = { "react/PretableStatusTone": { page: "grid/cell-presentations.mdx" }, "react/PretableBadgeTone": { page: "grid/cell-presentations.mdx" }, + // The tool-panel page's configuration section states the union is one + // member today — the "one section ships, more are planned" stance is the + // page's honesty claim, so a second section id appearing in the union must + // make the sentence (and the stance around it) fail until both are updated. + "react/ToolPanelSectionId": { page: "grid/tool-panel.mdx" }, + // The body-state kinds, spelled out on the lifecycle page's // `renderBodyState` paragraph. The sentence counts them out loud — "one of // four" — so a fifth kind makes the prose wrong in two ways at once, and the diff --git a/apps/website/lib/docs/examples/demos.generated.ts b/apps/website/lib/docs/examples/demos.generated.ts index 3a28c9f8b..07072dbfe 100644 --- a/apps/website/lib/docs/examples/demos.generated.ts +++ b/apps/website/lib/docs/examples/demos.generated.ts @@ -44,6 +44,7 @@ import streamingChatGridDemo from "../../../content/examples/streaming-chat-grid import themingCompositionOrderDemo from "../../../content/examples/theming-composition-order/demo"; import tokenAwareToolbarDemo from "../../../content/examples/token-aware-toolbar/demo"; import tokenOverrideLiveDemo from "../../../content/examples/token-override-live/demo"; +import toolPanelDemo from "../../../content/examples/tool-panel/demo"; import weightedAverageAggregatorDemo from "../../../content/examples/weighted-average-aggregator/demo"; export const exampleDemos: Partial> = { @@ -84,5 +85,6 @@ export const exampleDemos: Partial> = { "theming-composition-order": themingCompositionOrderDemo, "token-aware-toolbar": tokenAwareToolbarDemo, "token-override-live": tokenOverrideLiveDemo, + "tool-panel": toolPanelDemo, "weighted-average-aggregator": weightedAverageAggregatorDemo, }; diff --git a/apps/website/lib/docs/examples/registry.generated.ts b/apps/website/lib/docs/examples/registry.generated.ts index 5c2a5a47c..5c9c4fb7d 100644 --- a/apps/website/lib/docs/examples/registry.generated.ts +++ b/apps/website/lib/docs/examples/registry.generated.ts @@ -41,6 +41,7 @@ import streamingChatGrid from "../../../content/examples/streaming-chat-grid/exa import themingCompositionOrder from "../../../content/examples/theming-composition-order/example"; import tokenAwareToolbar from "../../../content/examples/token-aware-toolbar/example"; import tokenOverrideLive from "../../../content/examples/token-override-live/example"; +import toolPanel from "../../../content/examples/tool-panel/example"; import weightedAverageAggregator from "../../../content/examples/weighted-average-aggregator/example"; export const exampleRegistry = { @@ -196,6 +197,10 @@ export const exampleRegistry = { meta: tokenOverrideLive, hasDemo: true, }, + "tool-panel": { + meta: toolPanel, + hasDemo: true, + }, "weighted-average-aggregator": { meta: weightedAverageAggregator, hasDemo: true, diff --git a/docs/superpowers/plans/2026-08-24-tool-panel-sp1-shell-columns.md b/docs/superpowers/plans/2026-08-24-tool-panel-sp1-shell-columns.md new file mode 100644 index 000000000..ec8f35c9d --- /dev/null +++ b/docs/superpowers/plans/2026-08-24-tool-panel-sp1-shell-columns.md @@ -0,0 +1,365 @@ +# Tool Panel SP1 — Shell + Columns Section Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship the tool panel's rail-and-pane shell on `PretableSurface`, on by default, with the columns section (visibility, pinning, reorder, search, reset) — plus the one engine capability it needs, column visibility. + +**Architecture:** Column visibility lands in `grid-core`'s `columnLayout` as a `hidden` flag, filtered out at the react side's drawn-order projections so the span-resolving consumers inherit correctness. The shell is React-owned chrome (`{ activeSection }`) rendered as a vertical `tablist` rail plus a full-height pane docked at the surface's right edge; sections register through an internal typed descriptor so SP2/SP3 slot in. All styling is new `data-pretable-tool-*` attributes in `grid.css`, `:where()`-wrapped in `@layer pretable`, reusing the existing 50-token contract. + +**Tech Stack:** TypeScript, React 19, vanilla CSS in `@pretable/ui`, vitest + jsdom, Playwright, API Extractor, changesets. + +**Spec:** `docs/superpowers/specs/2026-08-24-tool-panel-design.md` — read it first; decisions there are settled and not re-litigated here. + +--- + +## File map + +| File | Responsibility | +|---|---| +| `packages/grid-core/src/types.ts` | `hidden?: boolean` on `PretableGridUiColumn` + `PretableGridUiColumnLayout` (~:773-786); `setColumnVisible` on the model interface (beside `setColumnPinned`, ~:924) | +| `packages/grid-core/src/create-grid-ui-core.ts` | normalize `hidden` at layout intake (~:187); `setColumnVisible` implementation beside `setColumnPinned` (~:946); focus/selection repair on hide | +| `packages/grid-core/src/__tests__/column-visibility.test.ts` | new — engine visibility behavior | +| `packages/react/src/pretable-surface.tsx` | `toolPanel` prop; horizontal layout wrapper (grid area · pane · rail); drawn-order projection filters `hidden` (~:3147); audit every `columnLayout`/`setColumnOrder` reader (~:2034, :2387, :2610, :3821) | +| `packages/react/src/pretable-model.ts` | `getColumns()` excludes hidden | +| `packages/react/src/tool-panel/sections.ts` | new — internal `ToolPanelSectionDescriptor` contract | +| `packages/react/src/tool-panel/ToolPanel.tsx` | new — shell: pane container + section render | +| `packages/react/src/tool-panel/Rail.tsx` | new — vertical tablist, roving tabindex | +| `packages/react/src/tool-panel/ColumnsSection.tsx` | new — list, search, reset, checkboxes, kebab menu, drag | +| `packages/react/src/tool-panel/index.ts` | new — internal barrel (nothing public beyond config types) | +| `packages/react/src/icons.tsx` | `ColumnsIcon` (rail tab glyph) | +| `packages/react/src/public_api.ts` | `PretableToolPanelConfig`, `ToolPanelSectionId` | +| `packages/react/src/pretable.tsx` | preset forwards `toolPanel` (default-on reaches it automatically) | +| `packages/ui/src/grid.css` | tool rail/pane/section/row rules | +| `packages/ui/src/__tests__/css-cascade.test.ts` | structural guards for the new rules | +| `packages/react/src/__tests__/tool-panel.test.tsx` | new — shell + columns section jsdom suite | +| `packages/react/src/__tests__/attribute-contract.test.tsx` | extend for `data-pretable-tool-*` | +| `apps/website/e2e/tool-panel.spec.ts` | new — keyboard walk, drag, hidden-span e2e | +| `apps/website/content/docs/grid/tool-panel.mdx` | new docs page | +| `.changeset/*` | core (minor: visibility), react (minor: tool panel), ui (patch: css) | + +## Standing rules for every task + +- **TDD.** Write the failing test, watch it fail, implement, watch it pass, commit. Copy the harness of the nearest existing test file rather than inventing one. +- **Prettier before trusting a test result** — it rewrites regex literals, long arrays, and markdown tables in ways that have repeatedly broken assertions here. `pnpm exec prettier --write ` in the owning package. +- **Drawn order, never the `columns` prop**, for anything resolving a column span. Seven consumers got this wrong once; the invariant is test-pinned. +- **`pnpm build` before `pnpm api`** — a stale `dist/` silently strips exports and `api:check` will not catch it. +- **No new tokens, no unprefixed styling attributes.** If you believe a token is genuinely needed, stop and flag it — that is a contract change with its own test/theme/docs ritual. +- SSR'd controls are inert until `data-pretable-hydrated`; interactive tests must gate on it (the e2e helpers in `apps/website/e2e/helpers.ts` already do). + +--- + +### Task 1: Engine — `hidden` on the column layout, `setColumnVisible` + +**Files:** `packages/grid-core/src/types.ts`, `packages/grid-core/src/create-grid-ui-core.ts`, create `packages/grid-core/src/__tests__/column-visibility.test.ts` + +Read `create-grid-ui-core.ts:180-200` (layout normalization) and `:946-995` (`setColumnPinned` / `setColumnOrder`) before writing anything — the new setter must be a sibling in shape: same `command()` wrapper, same freeze discipline, same no-op early-return when the value is unchanged. + +**Design decisions this task encodes (from the spec):** +- Hidden columns **stay in `columnLayout`** with `hidden: true` — width and pin state persist so re-showing restores them. They are filtered out downstream (Task 2), not removed here. +- `setColumnOrder` **continues to require every column in `columnLayout` exactly once, hidden included.** Its error message says "every visual column" — reword to "every column in the layout, hidden included" so the contract is explicit. +- Hiding the focused or selection-anchor column repairs focus/selection to the nearest still-visible neighbor (left first, then right), following the repair discipline already used for eviction — read `packages/grid-core/src/indexed-focus.ts` and reuse its helpers rather than writing new repair math. + +- [ ] **Step 1: Write the failing tests.** In the new test file, using the same store-construction harness as the existing `create-grid-ui-core` tests (find them with `ls packages/grid-core/src/__tests__/`): + - `hidden: true` in the initial column config survives normalization into `columnLayout`. + - `setColumnVisible("qty", false)` sets `hidden: true` on that entry; width and pin state are untouched; the entry keeps its position. + - `setColumnVisible` with an unchanged value publishes nothing (subscribe and count emissions — one engine emission per command is itself a pinned invariant). + - `setColumnVisible("qty", true)` removes the flag (entry returns to `{ id, widthPx }` shape, matching how `setColumnPinned(null)` strips rather than writing `pinned: undefined`). + - `setColumnOrder` omitting a hidden column's id throws `invalid-ui-state`. + - Hiding the column that currently holds focus moves focus to the nearest visible neighbor; hiding a non-focused column leaves focus alone. +- [ ] **Step 2:** `pnpm --filter @pretable-internal/grid-core test -- column-visibility` — confirm every test fails for the right reason (missing method / missing field), not a harness error. +- [ ] **Step 3: Implement.** `hidden?: boolean` on both interfaces in `types.ts`; `setColumnVisible(columnId, visible)` on the model interface and in `create-grid-ui-core.ts`, shaped exactly like `setColumnPinned` (strip-when-clearing, freeze, `orderPinnedColumns` re-run not needed — visibility does not reorder). Add the focus/selection repair inside the same `command()`. +- [ ] **Step 4:** Tests pass. Run the whole package: `pnpm --filter @pretable-internal/grid-core test` — nothing else regressed. +- [ ] **Step 5: Commit** `feat(grid-core): column visibility with persistent width and pin state`. + +--- + +### Task 2: React — hidden columns leave the drawn order, and every consumer survives + +**Files:** `packages/react/src/pretable-surface.tsx`, `packages/react/src/pretable-model.ts`, extend the existing drawn-order/span tests in `packages/react/src/__tests__/` + +This is the load-bearing task. The drawn-order projection at `pretable-surface.tsx:3147` (`indexedSnapshot.columnLayout.flatMap>`) and `pretable-model.ts`'s `getColumns` must filter `hidden` — then every span consumer (copy, paste, selection, announcements) inherits correctness because they already read the drawn order. + +**The trap:** direct `columnLayout` readers bypass that projection. Known sites: `pretable-surface.tsx:2034`, `:2387`, `:2610`, `:3821` — and grep for more (`grep -n "columnLayout" packages/react/src/*.tsx packages/react/src/*.ts`). Each one must be audited: does it want *all* columns (a `setColumnOrder` call needs hidden ids too) or *drawn* columns (anything computing geometry or spans)? Record the verdict per site in a code comment where it isn't obvious. + +**Assert the old behavior survives:** header drag-reorder calls `setColumnOrder`. With a hidden column present, a naive caller passes only visible ids and the engine now throws. The header-drag caller must splice hidden ids back in at their prior relative positions. + +- [ ] **Step 1: Failing tests.** Extend the existing span/drawn-order test files (find the test that pinned the seven-consumer invariant: `grep -rln "getColumns" packages/react/src/__tests__/`): + - A surface with a `hidden: true` column renders no header cell and no body cells for it. + - Copying a range that visually spans across where the hidden column *would* be produces clipboard text without the hidden column's values. + - `getColumns()` on the model excludes it; the full layout is still reachable for the panel (decide the accessor here: expose `columnLayout` via the existing state access rather than adding public API). + - **Header drag-reorder still reorders correctly while a column is hidden** — this is the survives-test; simulate the drag the way the existing reorder tests do. +- [ ] **Step 2:** Run, confirm failures. +- [ ] **Step 3:** Implement the filter at `:3147` and in `getColumns`; fix the audited direct readers; fix the header-drag `setColumnOrder` caller (splice hidden ids at prior positions). +- [ ] **Step 4:** `pnpm --filter @pretable/react test` — full suite, not just the new file. Local flake note: 1–2 random timeouts per full run are known; re-run before believing a failure. +- [ ] **Step 5: Commit** `feat(react): hidden columns leave the drawn order everywhere at once`. + +--- + +### Task 3: API reports and changesets for the engine change + +- [ ] **Step 1:** `pnpm build && pnpm api` (build first — mandatory), then `pnpm api:check`. Expect real diffs in `core.api.md` and `react.api.md` (the `PretableGridUiColumn` shapes and the new setter flow through the public façade). +- [ ] **Step 2:** Changesets: `@pretable/core` minor ("column visibility: `hidden` on column config, `setColumnVisible` on the grid model"), `@pretable/react` minor (accumulates the SP1 feature; write it now, it ships with the branch). +- [ ] **Step 3: Commit** `chore: api reports and changesets for column visibility`. + +--- + +### Task 4: CSS — the rail, pane, and columns-section rules + +**Files:** `packages/ui/src/grid.css`, `packages/ui/src/__tests__/css-cascade.test.ts` + +Every selector `:where()`-wrapped, inside `@layer pretable`, tokens only from the existing 50. **Pseudo-element trap:** `:where(x::before)` is invalid and silently matches nothing — write `:where(x)::before`. A paren-depth guard exists; keep it passing. + +- [ ] **Step 1: Failing structural guards** in `css-cascade.test.ts`, mutation-tested per the house rule (delete the rule under test, watch the guard fail, restore): + - a `[data-pretable-tool-rail]` rule exists and reads `--pretable-bg-header`; + - a `[data-pretable-tool-pane]` rule exists and reads `--pretable-bg-toolbar`; + - no `data-pretable-tool-*` rule introduces a `var(--` name outside the contract (reuse the file's existing token-extraction helper); + - the hidden-row label rule reads `--pretable-text-dim` and does **not** use `opacity` (the entity-secondary precedent — every opacity-dimmed secondary here has failed AA). +- [ ] **Step 2:** Run, confirm failures. +- [ ] **Step 3: Add the rules.** New section at the end of `grid.css`, commented in the file's voice (explain *why*, not *what*): + +```css + /* ── Tool panel ───────────────────────────────────────────────────────── + A rail of section tabs at the grid's right edge plus a full-height pane. + The rail borrows the header's surface and the pane the toolbar's, so the + panel reads as chrome, not content — same plane vocabulary as the rest + of the grid. Docked planes meet at hairlines, not shadows. */ + :where([data-pretable-tool-rail]) { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + inline-size: 36px; + padding-block-start: 6px; + background: var(--pretable-bg-header); + border-inline-start: var(--pretable-rule-width) solid var(--pretable-rule); + } + :where([data-pretable-tool-tab]) { + display: flex; + align-items: center; + justify-content: center; + inline-size: 28px; + block-size: 28px; + border: 0; + border-radius: var(--pretable-radius-control); + background: transparent; + color: var(--pretable-text-header); + cursor: pointer; + } + :where([data-pretable-tool-tab]:hover) { + background-image: linear-gradient(var(--pretable-bg-hover), var(--pretable-bg-hover)); + } + :where([data-pretable-tool-tab][aria-selected="true"]) { + background: var(--pretable-bg-toolbar); + color: var(--pretable-accent); + } + :where([data-pretable-tool-tab]:focus-visible) { + outline: 2px solid var(--pretable-focus-ring); + outline-offset: -2px; + } + /* Pane width is a plain px, not a token: nothing themes it yet, and the + flattened (0,0,0) specificity means a consumer can override it with any + single attribute selector. It becomes a token when a theme needs it. */ + :where([data-pretable-tool-pane]) { + inline-size: 264px; + display: flex; + flex-direction: column; + min-block-size: 0; + background: var(--pretable-bg-toolbar); + border-inline-start: var(--pretable-rule-width) solid var(--pretable-rule); + } + :where([data-pretable-tool-section]) { + display: flex; + flex-direction: column; + min-block-size: 0; + flex: 1; + padding: 8px; + overflow-y: auto; + } + :where([data-pretable-tool-column-row]) { + display: flex; + align-items: center; + gap: 7px; + block-size: 28px; + padding-inline: 7px; + border-radius: var(--pretable-radius-control); + } + :where([data-pretable-tool-column-row]:hover) { + background-image: linear-gradient(var(--pretable-bg-hover), var(--pretable-bg-hover)); + } + /* Hidden columns dim by COLOR, never by opacity: --pretable-text-dim holds + a computed contrast; an opacity multiplies it away below AA. */ + :where([data-pretable-tool-column-row][data-pretable-column-hidden="true"]) { + color: var(--pretable-text-dim); + } + :where([data-pretable-tool-group-label]) { + font-size: 0.72em; + letter-spacing: 0.07em; + text-transform: uppercase; + color: var(--pretable-text-dim); + padding: 8px 2px 4px; + } + :where([data-pretable-tool-drop-indicator]) { + block-size: 2px; + border-radius: 1px; + background: var(--pretable-reorder-drop-indicator); + } +``` + + Adjust to what the section markup actually needs, but hold the constraints: contract tokens only, hairline seams, no opacity dimming, focus as `outline`. +- [ ] **Step 4:** `pnpm --filter @pretable/ui test` — guards pass, contract test still green (it asserts every `var(--pretable-*)` in `grid.css` resolves in all three themes; a typo'd token name fails here). +- [ ] **Step 5: Commit** `feat(ui): tool panel rail, pane, and columns-section styling`. + +--- + +### Task 5: Rail + shell — descriptor contract, tablist a11y, chrome state + +**Files:** create `packages/react/src/tool-panel/sections.ts`, `Rail.tsx`, `ToolPanel.tsx`, `index.ts`; modify `packages/react/src/icons.tsx`; test `packages/react/src/__tests__/tool-panel.test.tsx` + +**`sections.ts` — the internal contract (verbatim):** + +```ts +import type { ComponentType, ReactNode } from "react"; + +/** Section ids are a closed union today; SP2 adds "filters", SP3 "grouping". + * Nothing in the shell may assume the union is closed at runtime — the + * future composable story widens this to consumer-supplied ids. */ +export type ToolPanelSectionId = "columns"; + +export interface ToolPanelSectionDescriptor { + readonly id: ToolPanelSectionId; + readonly icon: ComponentType<{ className?: string }>; + readonly label: string; + /** Props are baked in by the surface when it constructs descriptors — + * the shell renders sections without knowing what they need. */ + readonly render: () => ReactNode; +} +``` + +**`ColumnsIcon`** in `icons.tsx`: three vertical bars on the shared 16px grid, 1.5px stroke, `currentColor`, `data-pretable-icon` — copy the `Glyph` pattern the other nine use. Internal, not exported from `public_api.ts`. + +**Rail behavior (the a11y contract from the spec):** `role="tablist"` `aria-orientation="vertical"`; each tab `role="tab"`, `aria-selected`, `aria-controls` pointing at the pane id; roving tabindex so the rail is **one** tab stop (active or first tab holds `tabIndex=0`, rest `-1`); ArrowUp/ArrowDown move focus between tabs; Enter/Space toggle; activating the already-active tab closes the pane. Pane: `role="tabpanel"`, `aria-labelledby` its tab, rendered only while open. Escape inside the pane returns focus to the pane's rail tab — implement as a keydown listener on the pane container. + +- [ ] **Step 1: Failing tests** (jsdom, React Testing Library — copy the harness from an existing surface test): + - rail renders one tab per descriptor with `role="tab"` and the label as accessible name; + - no pane in the DOM when `activeSection` is null; clicking a tab renders the pane with `role="tabpanel"` wired via `aria-controls`/`aria-labelledby`; + - clicking the active tab closes the pane; + - ArrowDown from tab 1 moves DOM focus to tab 2 without changing `aria-selected` (activation is explicit, not focus-follows); + - only one tab has `tabIndex=0` at any time; + - Escape inside the pane moves focus to the active tab. +- [ ] **Step 2:** Run, confirm failures. +- [ ] **Step 3:** Implement `Rail.tsx` and `ToolPanel.tsx` (shell = rail + conditional pane; chrome state lives one level up, passed as props — the shell is controlled by its parent, full stop; the surface owns uncontrolled fallback in Task 6). +- [ ] **Step 4:** Tests pass; commit `feat(react): tool panel shell — rail, pane, section contract`. + +--- + +### Task 6: Surface integration — `toolPanel` prop, layout, default-on + +**Files:** `packages/react/src/pretable-surface.tsx`, `packages/react/src/public_api.ts`, extend `packages/react/src/__tests__/tool-panel.test.tsx` and `attribute-contract.test.tsx` + +**Public config type (in `public_api.ts`, `@public`-tagged):** + +```ts +export interface PretableToolPanelConfig { + readonly defaultActiveSection?: ToolPanelSectionId | null; + readonly activeSection?: ToolPanelSectionId | null; + readonly onActiveSectionChange?: (section: ToolPanelSectionId | null) => void; +} +// on PretableSurfaceProps: +// toolPanel?: boolean | PretableToolPanelConfig; // default: true +``` + +Controlled/uncontrolled resolution follows the surface's existing convention (`state` + `onSelectionChange` precedent): `activeSection` present → controlled; else internal state seeded by `defaultActiveSection ?? null`. + +**Layout:** today the surface stacks group panel / header / viewport vertically. Wrap that stack and the tool panel in a horizontal flex row *inside* the surface's card: `[existing vertical stack, flex:1 min-width:0] [pane?] [rail]`. The viewport's width-resize path already handles reflow — verify by reading how width is observed before assuming (`grep -n "ResizeObserver" packages/react/src/pretable-surface.tsx`). The rail/pane must sit inside the card so the card's border and shadow wrap them. + +**Hydration:** rail tabs must be inert pre-hydration exactly like the surface's other controls — find how existing controls gate on `data-pretable-hydrated` and use the same mechanism, not a new one. + +- [ ] **Step 1: Failing tests:** + - default render (no `toolPanel` prop) shows the rail, no pane — **on by default**; + - `toolPanel={false}` renders neither; + - uncontrolled: `defaultActiveSection: "columns"` opens the pane at mount; + - controlled: `activeSection` pins the pane; clicking a tab fires `onActiveSectionChange` without changing the DOM until the prop changes; + - attribute contract: every new `data-pretable-tool-*` attribute is registered — extend `attribute-contract.test.tsx`'s allowlist and confirm the test FAILS before the allowlist edit (mutation-proof it sees the new attrs). +- [ ] **Step 2:** Run, confirm failures. +- [ ] **Step 3:** Implement. Surface constructs the descriptor array (just columns for now) with props baked in, resolves chrome state, renders `` in the new layout row. +- [ ] **Step 4:** Full `pnpm --filter @pretable/react test`. The website suite will exercise default-on across every docs example — run `pnpm --filter @pretable/app-website test` too and triage: examples that snapshot DOM may need updates; that churn is expected and belongs in this commit. +- [ ] **Step 5: Commit** `feat(react): tool panel on the surface, on by default`. + +--- + +### Task 7: Columns section — list, visibility, search, reset + +**Files:** `packages/react/src/tool-panel/ColumnsSection.tsx`, extend the tool-panel test file + +List source: the full `columnLayout` (hidden included — the panel is the one place hidden columns remain visible), projected against the surface's column definitions for labels, **in layout order** (which is drawn order plus hidden entries in place). Excludes the derived group column and the selection column — find how the group column is marked in the layout (`grep -rn "group" packages/grid-core/src/create-grid-ui-core.ts | head`) rather than assuming an id convention. + +Subgroups: **Pinned left / Columns / Pinned right**, from each entry's `pinned`. Empty subgroups render no label. Search filters by case-insensitive label substring; subgroup labels hide when their group empties. Reset restores the initial prop-declared order, pin, and visibility — capture the initial layout once at mount of the *surface* (not the section, which unmounts when the pane closes) and replay it via `setColumnOrder` + `setColumnPinned` + `setColumnVisible`. + +Row: grip (`GripIcon`, drag handle, Task 9) · checkbox (native input, checked = visible, toggles `setColumnVisible`) · label (ellipsized) · kebab (Task 8). Hidden rows: unchecked, `data-pretable-column-hidden="true"` (dims via the Task 4 rule). + +- [ ] **Step 1: Failing tests:** + - rows appear in layout order, subgrouped by pin state, hidden rows present and marked; + - the derived group column and selection column do not appear; + - unchecking calls `setColumnVisible(id, false)`; the row stays, dimmed; the grid loses the column (assert via the drawn-order test helpers from Task 2); + - search narrows rows and hides emptied subgroup labels; + - reset after hide+pin+reorder restores the initial state (assert on the engine's layout, not the DOM). +- [ ] **Step 2:** Run, confirm failures. **Step 3:** Implement. **Step 4:** Pass, full react suite. +- [ ] **Step 5: Commit** `feat(react): columns section — visibility, search, reset`. + +--- + +### Task 8: Kebab menu — pin left / pin right / unpin + +**Files:** `packages/react/src/tool-panel/ColumnsSection.tsx`, test file + +Use `OverlayPortal` (`packages/react/src/overlay/OverlayPortal.tsx`) exactly as `FilterMenu.tsx:287-392` does — **`contain: content` on the grid traps and clips `position: fixed`**, which is the documented reason that portal exists; do not position the menu inline. Trigger: `OverflowIcon` button, `aria-haspopup="menu"`, `aria-expanded`. Menu: `role="menu"`, items `role="menuitem"` — Pin left / Pin right / Unpin, current state disabled. Escape closes and returns focus to the trigger; so does selecting. + +- [ ] **Step 1: Failing tests:** menu opens with three items; the row's current pin state is disabled; choosing "Pin right" calls `setColumnPinned(id, "right")` and the row moves to the Pinned-right subgroup; Escape returns focus to the kebab. +- [ ] **Step 2–4:** Fail → implement → pass. +- [ ] **Step 5: Commit** `feat(react): per-column pin menu in the columns section`. + +--- + +### Task 9: Drag reorder, and cross-boundary re-pin + +**Files:** `packages/react/src/tool-panel/ColumnsSection.tsx`, test file, create `apps/website/e2e/tool-panel.spec.ts` + +Pointer-event drag on the grip (the header drag is the in-repo precedent — read its handler before writing this one; note its rule: **commit on drop, never on drag-leave**). While dragging: the dragged row at reduced emphasis, a `data-pretable-tool-drop-indicator` line at the insertion point. On drop within a subgroup: `setColumnOrder` with the full layout id list (hidden ids in place — Task 2's contract). On drop across a subgroup boundary: `setColumnPinned` to the target group's pin value, then order. Keyboard alternative (a11y hard gate — drag must not be the only path): with focus on the grip, ArrowUp/ArrowDown with a modifier moves the row; document the chosen chord in the docs page. + +jsdom cannot express real pointer geometry — unit-test the pure insertion-index math (extract `dropTargetForPointer(y, rowRects, groups)` as a pure function and test it directly; the jsdom vacuous-scroll-test trap is the precedent for why), and prove the real interaction in Playwright. + +- [ ] **Step 1: Failing unit tests** for the insertion math: mid-row boundaries, first/last positions, cross-group targets, hidden rows occupying slots. +- [ ] **Step 2–3:** Fail → implement (pure function + pointer handlers + keyboard moves). +- [ ] **Step 4: Playwright spec** (`apps/website/e2e/tool-panel.spec.ts`, using `helpers.ts` and the hydration gate): open panel on a showcase grid; drag a row two positions, assert header order changed; drag across the Pinned-left boundary, assert the column pinned (the header shows `data-pretable-pinned`); keyboard walk: Tab reaches the rail once, arrows move, Enter opens, Tab proceeds into the pane, forward-Tab from the last control **exits the panel** (no trap), Escape returns to the tab. +- [ ] **Step 5:** Run e2e per the local recipe: build + `next start`, root playwright binary from inside `apps/website`, `BASE_URL`, `--workers=1`. +- [ ] **Step 6: Commit** `feat(react): drag and keyboard reorder in the columns section`. + +--- + +### Task 10: Preset + docs + +**Files:** `packages/react/src/pretable.tsx`, create `apps/website/content/docs/grid/tool-panel.mdx`, modify `grid/pretable-component.mdx` (Limitations), `grid/pretable-surface.mdx` (Configuration table), `.changeset/` + +- [ ] **Step 1:** Preset: `toolPanel` flows through the explicit prop list (`pretable.tsx:196` spreads a *named* list — add it; the file's comment explains why nothing passes implicitly). Default-on therefore reaches the preset with no further work; test: preset renders the rail, `toolPanel={false}` removes it. +- [ ] **Step 2:** `grid/pretable-component.mdx`'s Limitations paragraph currently promises no config UI — rewrite to reflect that the tool panel is on by default and how to disable it. +- [ ] **Step 3:** Write `grid/tool-panel.mdx`: what it is, the default-on stance, the config type, the columns section's operations, keyboard reference (including the reorder chord), and disabling. **Every prop table you add must be registered in the docs guard's rosters** (`apps/website/lib/docs/__tests__/docs-api-surface.test.ts` — it fails closed, and it checks member names AND optionality against the `.api.md` reports). Run `pnpm --filter @pretable/app-website test -- docs-api-surface` and satisfy it honestly — fix the docs, never the guard. +- [ ] **Step 4:** `pnpm --filter @pretable/app-website test` full; commit `docs(grid): the tool panel, and the preset's updated limitations`. + +--- + +### Task 11: Full verification + +- [ ] `pnpm --filter` each: grid-core, core, react, ui, app-website tests — all green. +- [ ] `pnpm typecheck`, `pnpm lint`, `pnpm format`. +- [ ] `pnpm build && pnpm api && pnpm api:check` — reports fresh. +- [ ] Website e2e, full suite, production build, `--workers=1`. +- [ ] The Playwright cascade gate (`apps/bench/tests/cascade-override.spec.ts`) still green — the new css rules are in its jurisdiction. +- [ ] **Prove the pixel:** in a real browser, on the website's showcase grid, assert computed styles on the rendered rail (`background-color` resolves to the theme's header surface) — a matching selector and resolving token are not proof anything paints. +- [ ] **Look at it** under all three themes (`pretable`, forced `excel`, forced `material`) and in `pretable` dark. Report what the rail actually looks like in each — Excel's 0-radius sharp aesthetic will style the tabs differently, and that should look *intentional*, not broken. Screenshot each. +- [ ] Changesets present: core, react, ui. + +## Self-review + +**Spec coverage:** every spec section maps — engine change (T1–3), shell/API/a11y (T5–6), columns section incl. all four operations (T7–9), DOM/theming (T4), preset + docs (T10), verification incl. prove-the-pixel and no-trap (T11). Deferred items (custom sections, autosize, width resize) appear in no task, correctly. + +**Two judgment calls made here, flagged for the reviewer:** (1) hidden columns stay in `columnLayout` and `setColumnOrder` keeps its all-ids contract, pushing a splice onto header-drag — chosen because one filter point beats a tolerant setter with reinsertion heuristics; (2) the reorder keyboard chord is left to the implementer to pick against existing grid chords, but its *existence* is a hard requirement with an e2e assertion. diff --git a/docs/superpowers/specs/2026-08-24-tool-panel-design.md b/docs/superpowers/specs/2026-08-24-tool-panel-design.md new file mode 100644 index 000000000..d9c932f49 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-tool-panel-design.md @@ -0,0 +1,216 @@ +# Tool Panel — Design + +**Status:** approved direction, SP1 specced in full; SP2/SP3 outlined. +**Decided with:** visual companion session 2026-08-24 (mockups under `.superpowers/brainstorm/`). + +## What this is + +A rail-and-pane tool panel docked at the grid's right edge — the AG-Grid-class +answer to "where does advanced configuration live." Full scope is three +stacked capabilities: column management, an advanced filter builder over the +typed operator model, and grouping configuration. It ships as a **library +feature** in `@pretable/react`, themed by the token contract, **on by +default**. + +This is the payoff of two prior threads: the design-system project (the panel +is the first large chrome surface built entirely on the house theme), and the +sidebar-precursor note that the hero's stacked control sidebar was rehearsing +this panel. + +## Decomposition + +Three sub-projects, each its own spec → plan → PR cycle: + +- **SP1 — shell + columns section** (this spec, in full). The rail/pane + container, its API, a11y and theming, plus the simplest real section. + Ships with one engine addition: column visibility. +- **SP2 — filter builder section.** The full typed operator vocabulary + (`between`, `before`/`after`, `isAnyOf`, `isEmpty`, date ranges, text + operators) as a query builder: every active filter visible together, + add/edit/remove across columns, typed value editors reusing the shipped + cell-editor set (enum combobox, date calendar, number steppers). +- **SP3 — grouping section.** Group columns, expand defaults, and aggregate + choices as a section; decides coexistence vs. replacement for the + drag-to-group strip. + +Ordering argument: the shell must exist before any deep section; the columns +section proves the shell's hardest generic interaction (drag inside a panel) +without inventing new state models; filters are the largest design surface +and deserve a settled shell; grouping is mostly rehoming. + +## Decisions locked (and why) + +1. **Library component, not a website pattern.** The hero already played + precursor once; the capability belongs to every consumer. +2. **Full tool panel is the destination; filters-only was rejected** as + underselling the panel architecture. +3. **Form: icon rail + one section at a time** (chosen over stacked + collapsible sections and over an overlay drawer, from mockups). The rail + advertises the capability even when the pane is closed; each section gets + full height — which the SP2 filter builder genuinely needs; one visible + section keeps each section's keyboard scope simple. +4. **On by default, configured by a prop, composable escape hatch later.** + Applies to `PretableSurface` _and_ the `` preset. The preset's + "Limitations" doc text currently promises no config UI and must be + updated in SP1. Pre-1.0 with no external consumers, so default-on costs + nothing now and buys discoverability forever. +5. **React-owned chrome state; engine-owned operations.** The only new state + is `{ activeSection }`. Engine state would be dead surface for headless + consumers, who cannot render the chrome anyway. All operations flow + through engine methods and render from the snapshot subscription. +6. **Columns section carries all four operations** — visibility, pinning, + reorder, search + reset. +7. **Row anatomy: quiet row + per-row kebab menu** (chosen over an inline + three-state pin control, from mockups). Rows are grip + checkbox + + label + kebab; pinning lives in the menu, which also gives future per-column + actions (autosize, SP3 group-by) a home without another row redesign. + Pin _state_ is communicated by which subgroup a row sits in. +8. **Zero new tokens.** Rail reuses `--pretable-bg-header`, pane + `--pretable-bg-toolbar`, separators `--pretable-rule`, focus + `--pretable-focus-ring`, drag indicator + `--pretable-reorder-drop-indicator`. If implementation finds a real gap, + adding a token is a deliberate contract change (contract test, all three + themes, token reference — the docs guard now enforces all of it), never a + quiet extra. + +## SP1 in detail + +### Architecture + +`packages/react/src/tool-panel/`: `ToolPanel.tsx` (shell), `Rail.tsx`, +`ColumnsSection.tsx`, `sections.ts` (internal descriptor contract), +`index.ts`. The surface renders the tool panel as a right-edge column inside +its card; the grid area reflows width when the pane opens (the virtualizer +already observes resize). + +Sections register via an internal typed descriptor: + +```ts +interface ToolPanelSectionDescriptor { + readonly id: ToolPanelSectionId; // "columns" in SP1 + readonly icon: ComponentType; // from the internal icon set + readonly label: string; // rail tooltip + tab aria-label + readonly render: (ctx: SectionContext) => ReactNode; +} +``` + +SP2/SP3 add descriptors. The "composable customization" future is: export +the descriptor type and accept custom descriptors through the `toolPanel` +prop. SP1 designs for that (nothing in the shell may assume a closed id +union at runtime) but does not build or export it. + +### Public API + +```ts +toolPanel?: boolean | { + defaultActiveSection?: "columns" | null; // uncontrolled initial + activeSection?: "columns" | null; // controlled + onActiveSectionChange?: (s: "columns" | null) => void; +} +``` + +Default `true`: rail visible, pane closed. `false` removes rail and pane. +Controlled/uncontrolled per the surface's existing convention. New engine +surface (below) is the only other public API movement. + +### Engine change: column visibility (the one real one) + +Column visibility does not exist in the engine today (verified against +`core.api.md`: `setColumnOrder` and `setColumnPinned` exist; nothing for +visibility). SP1 adds: + +- `PretableColumn.hidden?: boolean` — initial visibility. +- `setColumnVisible(columnId, visible)` on the grid model. +- Hidden columns are **removed from the drawn order** (`getColumns()`). + +Placement is load-bearing: every consumer that resolves a column span — +copy, paste, selection, announcements, seven at last count — reads the drawn +order, so they inherit hidden-column correctness from the same source of +truth, exactly as grouping's derived column already does. A hidden column's +widths/pin state persist so re-showing restores it. + +Focus/selection repair: hiding the focused or selection-anchor column moves +focus to the nearest visible neighbor (same repair discipline as eviction). + +### DOM, theming + +New attributes, extending the `data-pretable-*` contract (attribute-contract +test extended in the same commit): `data-pretable-tool-rail`, +`data-pretable-tool-tab` (+ `data-pretable-section`), +`data-pretable-tool-pane`, `data-pretable-tool-section`, state via +`data-pretable-open` / `aria-selected`. All styling in `grid.css`, +`:where()`-wrapped inside `@layer pretable`; token reuse per decision 8; the +pane/grid seam is a hairline, not a shadow (docked planes, not floating +ones). Pane width is a plain px in `grid.css` — overridable by consumers at +(0,0,0) specificity, not a token until someone needs it themed. + +### Accessibility and keyboard + +- Rail is `role="tablist"` `aria-orientation="vertical"`; tabs are + `role="tab"` with roving tabindex — the rail is **one** tab stop. +- Up/Down moves between tabs; Enter/Space toggles; activating the active tab + closes the pane. Pane is `role="tabpanel"` labelled by its tab. +- Escape anywhere in the pane returns focus to the pane's rail tab. +- Focus ring is the `outline` pattern (composes with shadows — the box-shadow + slot lesson). +- Rail controls are inert until `data-pretable-hydrated` (the SSR signal); + the e2e helpers already gate on it. +- The panel must never trap Tab: forward-Tab from the last pane control + exits the panel entirely (the WCAG-A history makes this a hard gate, not a + nice-to-have). + +### Columns section + +- List source: `grid.getColumns()` — the **drawn** order, never the + `columns` prop. Excludes the derived group column and the selection + column. +- Subgrouped **Pinned left / Columns / Pinned right** with uppercase labels. +- Row: drag grip · visibility checkbox · label (ellipsized) · kebab menu. + Hidden rows keep their position, unchecked, label dimmed via + `--pretable-text-dim` (never opacity). +- Kebab menu (v1): Pin left / Pin right / Unpin. Rendered through the + existing overlay/portal machinery — `contain: content` clips + `position: fixed` inside the viewport, so no naive positioning. +- Drag within a subgroup reorders (`setColumnOrder` with the full drawn-order + id list); drag across a subgroup boundary re-pins (`setColumnPinned`) — + mirroring the header-drag behavior the showcase e2e already pins. Drop + indicator uses `--pretable-reorder-drop-indicator`; drop commits on + release, never on drag-leave. +- Search filters the list by label match; subgroup headers hide when empty. +- **Reset columns** restores the initial prop-declared order, pin state, and + visibility. + +### Verification + +- **jsdom:** chrome state (controlled + uncontrolled), descriptor contract, + ARIA wiring, attribute contract extension, visibility-in-drawn-order unit + coverage in core. +- **Playwright (required, not optional — jsdom cannot see most of this):** + rail keyboard walk (one stop in, arrows within, no trap out), pane + open/close reflow, drag-reorder with the indicator, cross-boundary re-pin, + hidden-column copy/paste span correctness, focus repair on hiding the + focused column. +- **Docs:** `grid/tool-panel.mdx` plus the surface configuration-table + update and the preset's Limitations correction. Every new member table + must be registered in the docs guard's rosters — it fails closed. +- **Changesets:** core (visibility), react (feature), ui (css). +- Assert the old behavior survives: header drag-reorder and drag-to-group + still work with the panel open. + +## Traps carried forward (do not relearn) + +- Column spans resolve against the **drawn** order (`getColumns()`), never + the `columns` prop. +- `setQuery` settles asynchronously — subscribe to the snapshot, not + `getState`. +- No token ships without a consumer; no styling attribute ships unprefixed. +- Prettier rewrites regex/table assertions — format before trusting a test. +- Prove the pixel: a resolving token and a matching selector are not proof + anything paints; assert computed longhands on the real component. +- SSR'd controls are painted but inert until `data-pretable-hydrated`. + +## Out of scope for SP1 + +Filter builder (SP2), grouping section (SP3), custom/composable sections +(designed-for, not built), panel width resizing, column autosize action in +the kebab (menu is built to take it later), any saved-views concept. diff --git a/packages/core/core.api.md b/packages/core/core.api.md index 7d0adb164..8bbc0098c 100644 --- a/packages/core/core.api.md +++ b/packages/core/core.api.md @@ -697,6 +697,7 @@ export interface PretableFormatInput { + readonly hidden?: boolean; // (undocumented) readonly id: TColumnId; // (undocumented) @@ -707,6 +708,7 @@ export interface PretableGridUiColumn { // @public export interface PretableGridUiColumnLayout { + readonly hidden?: boolean; // (undocumented) readonly id: TColumnId; // (undocumented) @@ -753,6 +755,7 @@ export interface PretableGridUiCore void; readonly setColumns: (columns: readonly PretableGridUiColumn[]) => void; + readonly setColumnVisible: (columnId: TColumnId, visible: boolean) => void; // (undocumented) readonly setColumnWidth: (columnId: TColumnId, width: number) => void; // (undocumented) diff --git a/packages/grid-core/src/__tests__/column-visibility.test.ts b/packages/grid-core/src/__tests__/column-visibility.test.ts new file mode 100644 index 000000000..5572c579c --- /dev/null +++ b/packages/grid-core/src/__tests__/column-visibility.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, test, vi } from "vitest"; + +import { + createColumnHelper, + createLocalRowModel, +} from "@pretable-internal/row-model"; + +import { createGridUiCore } from "../create-grid-ui-core"; + +interface Row { + readonly id: number; + readonly name: string; + readonly quantity: number; + readonly price: number; +} + +const helper = createColumnHelper(); +const modelColumns = [ + helper.accessor("name", { type: "text" }), + helper.accessor("quantity", { type: "number" }), + helper.accessor("price", { type: "number" }), +] as const; + +function make( + visualColumns: readonly { + readonly id: "name" | "quantity" | "price"; + readonly widthPx?: number; + readonly pinned?: "left" | "right"; + readonly hidden?: boolean; + }[] = [ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100, pinned: "right" }, + { id: "price", widthPx: 120 }, + ], +) { + const rowModel = createLocalRowModel({ + rows: [ + { id: 1, name: "one", quantity: 1, price: 10 }, + { id: 2, name: "two", quantity: 2, price: 20 }, + ], + columns: modelColumns, + }); + return { + rowModel, + grid: createGridUiCore({ rowModel, columns: visualColumns }), + }; +} + +describe("column visibility", () => { + test("hidden: true in the initial column config survives normalization", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100, hidden: true }, + { id: "price", widthPx: 120 }, + ]); + + expect(grid.getState().columnLayout).toEqual([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100, hidden: true }, + { id: "price", widthPx: 120 }, + ]); + }); + + test("setColumnVisible(false) sets hidden and leaves width, pin and position alone", () => { + const { grid } = make(); + + grid.setColumnVisible("quantity", false); + + // The pinned "quantity" column sits at the pinned-right end of the + // normalized layout, and hiding it must not move it back. + expect(grid.getState().columnLayout).toEqual([ + { id: "name", widthPx: 180 }, + { id: "price", widthPx: 120 }, + { id: "quantity", widthPx: 100, pinned: "right", hidden: true }, + ]); + }); + + test("an unchanged visibility publishes nothing", () => { + const { grid } = make(); + const listener = vi.fn(); + grid.subscribe(listener); + const before = grid.getState(); + + grid.setColumnVisible("quantity", true); + expect(grid.getState()).toBe(before); + expect(listener).not.toHaveBeenCalled(); + + grid.setColumnVisible("quantity", false); + expect(listener).toHaveBeenCalledTimes(1); + const hiddenState = grid.getState(); + + grid.setColumnVisible("quantity", false); + expect(grid.getState()).toBe(hiddenState); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("setColumnVisible(true) strips the flag rather than writing hidden: undefined", () => { + const { grid } = make(); + + grid.setColumnVisible("price", false); + grid.setColumnVisible("price", true); + + const price = grid + .getState() + .columnLayout.find((column) => column.id === "price")!; + expect(price).toEqual({ id: "price", widthPx: 120 }); + expect("hidden" in price).toBe(false); + }); + + test("setColumns publishes a visibility-only change", () => { + const { grid } = make(); + const listener = vi.fn(); + grid.subscribe(listener); + + grid.setColumns([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100, pinned: "right", hidden: true }, + { id: "price", widthPx: 120 }, + ]); + + expect(listener).toHaveBeenCalledTimes(1); + expect(grid.getState().columnLayout).toEqual([ + { id: "name", widthPx: 180 }, + { id: "price", widthPx: 120 }, + { id: "quantity", widthPx: 100, pinned: "right", hidden: true }, + ]); + + // And the reflexive half: replaying the same visibility is still a no-op. + grid.setColumns([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100, pinned: "right", hidden: true }, + { id: "price", widthPx: 120 }, + ]); + expect(listener).toHaveBeenCalledTimes(1); + }); + + test("unpinning a hidden column does not reveal it, and re-showing keeps its pin", () => { + const { grid } = make(); + + grid.setColumnVisible("quantity", false); + grid.setColumnPinned("quantity", null); + + // The clear path rebuilds the entry; every OTHER optional key survives. + // The unpinned column keeps its slot at the end — `orderPinnedColumns` + // preserves relative order and unpinning has never moved a column back. + expect(grid.getState().columnLayout).toEqual([ + { id: "name", widthPx: 180 }, + { id: "price", widthPx: 120 }, + { id: "quantity", widthPx: 100, hidden: true }, + ]); + + // The mirror image: the show path rebuilds the entry too, and must keep + // the pin it strips `hidden` alongside. + grid.setColumnPinned("quantity", "right"); + grid.setColumnVisible("quantity", true); + expect(grid.getState().columnLayout).toEqual([ + { id: "name", widthPx: 180 }, + { id: "price", widthPx: 120 }, + { id: "quantity", widthPx: 100, pinned: "right" }, + ]); + }); + + test("setColumnOrder must name every column in the layout, hidden included", () => { + const { grid } = make(); + + grid.setColumnVisible("price", false); + + expect(() => grid.setColumnOrder(["name", "quantity"])).toThrowError( + expect.objectContaining({ code: "invalid-ui-state" }), + ); + // The full roster, hidden included, is still accepted. + grid.setColumnOrder(["price", "name", "quantity"]); + expect(grid.getState().columnLayout.map((column) => column.id)).toEqual([ + "price", + "name", + "quantity", + ]); + }); + + test("hiding the focused column re-seats focus onto the left neighbor first", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setFocus({ ref: { kind: "data", rowId: 1 }, columnId: "quantity" }); + const listener = vi.fn(); + grid.subscribe(listener); + + grid.setColumnVisible("quantity", false); + + // One atomic wake: the layout change and the focus repair publish together. + expect(listener).toHaveBeenCalledTimes(1); + expect(grid.getState().focus).toEqual({ + ref: { kind: "data", rowId: 1 }, + columnId: "name", + }); + }); + + test("hiding the focused first column falls back to the right neighbor", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setFocus({ ref: { kind: "data", rowId: 1 }, columnId: "name" }); + + grid.setColumnVisible("name", false); + + expect(grid.getState().focus).toEqual({ + ref: { kind: "data", rowId: 1 }, + columnId: "quantity", + }); + }); + + test("a left neighbor that is itself hidden is skipped over", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setColumnVisible("quantity", false); + grid.setFocus({ ref: { kind: "data", rowId: 1 }, columnId: "price" }); + + grid.setColumnVisible("price", false); + + expect(grid.getState().focus).toEqual({ + ref: { kind: "data", rowId: 1 }, + columnId: "name", + }); + }); + + test("hiding a non-focused column leaves focus alone", () => { + const { grid } = make(); + grid.setFocus({ ref: { kind: "data", rowId: 1 }, columnId: "name" }); + const focusBefore = grid.getState().focus; + + grid.setColumnVisible("price", false); + + expect(grid.getState().focus).toBe(focusBefore); + }); + + test("hiding the last visible column clears focus rather than pointing at a hidden cell", () => { + const { grid } = make([{ id: "name", widthPx: 180 }]); + grid.setFocus({ ref: { kind: "data", rowId: 1 }, columnId: "name" }); + + grid.setColumnVisible("name", false); + + expect(grid.getState().focus).toEqual({ ref: null, columnId: null }); + }); + + test("hiding the selection-anchor column re-seats the anchor onto a visible neighbor", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setSelection({ + rows: { kind: "explicit", rowIds: new Set() }, + ranges: [ + { + start: { rowId: 1, columnId: "quantity" }, + end: { rowId: 1, columnId: "quantity" }, + }, + ], + anchor: { rowId: 1, columnId: "quantity" }, + }); + + grid.setColumnVisible("quantity", false); + + expect(grid.getState().selection.anchor).toEqual({ + rowId: 1, + columnId: "name", + }); + }); + + test("hiding the anchor's first column falls back to the right neighbor", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setSelection({ + rows: { kind: "explicit", rowIds: new Set() }, + ranges: [], + anchor: { rowId: 1, columnId: "name" }, + }); + + grid.setColumnVisible("name", false); + + expect(grid.getState().selection.anchor).toEqual({ + rowId: 1, + columnId: "quantity", + }); + }); + + test("hiding the last visible column clears the anchor", () => { + const { grid } = make([{ id: "name", widthPx: 180 }]); + grid.setSelection({ + rows: { kind: "explicit", rowIds: new Set() }, + ranges: [], + anchor: { rowId: 1, columnId: "name" }, + }); + + grid.setColumnVisible("name", false); + + expect(grid.getState().selection.anchor).toBeNull(); + }); + + test("arrow-key focus movement skips a hidden column in both directions", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setColumnVisible("quantity", false); + grid.setFocus({ ref: { kind: "data", rowId: 1 }, columnId: "name" }); + + grid.moveFocus("right"); + expect(grid.getState().focus.columnId).toBe("price"); + + grid.moveFocus("left"); + expect(grid.getState().focus.columnId).toBe("name"); + }); + + test("hiding the column under an open edit session cancels the edit atomically", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.observeRowModelRevision(0); + grid.beginEdit({ rowId: 1, columnId: "quantity", value: 1 }); + const listener = vi.fn(); + grid.subscribe(listener); + + grid.setColumnVisible("quantity", false); + + // One atomic wake: the layout change and the edit repair publish together. + expect(listener).toHaveBeenCalledTimes(1); + expect(grid.getState().editing).toBeNull(); + }); + + test("hiding a different column leaves an open edit session alone", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.observeRowModelRevision(0); + grid.beginEdit({ rowId: 1, columnId: "quantity", value: 1 }); + const editingBefore = grid.getState().editing; + + grid.setColumnVisible("price", false); + + expect(grid.getState().editing).toBe(editingBefore); + }); + + test("setColumns hiding the edited column cancels the edit like removal does", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.observeRowModelRevision(0); + grid.beginEdit({ rowId: 1, columnId: "quantity", value: 1 }); + + grid.setColumns([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100, hidden: true }, + { id: "price", widthPx: 120 }, + ]); + + expect(grid.getState().editing).toBeNull(); + }); + + test("setColumns hiding a different column leaves the edit session alone", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.observeRowModelRevision(0); + grid.beginEdit({ rowId: 1, columnId: "quantity", value: 1 }); + const editingBefore = grid.getState().editing; + + grid.setColumns([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120, hidden: true }, + ]); + + expect(grid.getState().editing).toBe(editingBefore); + }); + + test("beginEdit refuses a hidden column", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.observeRowModelRevision(0); + grid.setColumnVisible("quantity", false); + + expect(() => + grid.beginEdit({ rowId: 1, columnId: "quantity", value: 1 }), + ).toThrowError(expect.objectContaining({ code: "invalid-ui-state" })); + }); + + test("hiding a column that is not the anchor leaves the selection untouched", () => { + const { grid } = make([ + { id: "name", widthPx: 180 }, + { id: "quantity", widthPx: 100 }, + { id: "price", widthPx: 120 }, + ]); + grid.setSelection({ + rows: { kind: "explicit", rowIds: new Set() }, + ranges: [], + anchor: { rowId: 1, columnId: "name" }, + }); + const selectionBefore = grid.getState().selection; + + grid.setColumnVisible("price", false); + + expect(grid.getState().selection).toBe(selectionBefore); + }); +}); diff --git a/packages/grid-core/src/create-grid-ui-core.ts b/packages/grid-core/src/create-grid-ui-core.ts index c3969d453..141f40e6d 100644 --- a/packages/grid-core/src/create-grid-ui-core.ts +++ b/packages/grid-core/src/create-grid-ui-core.ts @@ -182,6 +182,10 @@ function normalizeColumns( id: column.id, widthPx, ...(column.pinned === undefined ? {} : { pinned: column.pinned }), + // Strip-when-false, matching `setColumnVisible`: a visible entry never + // carries the key, so the two ways of arriving at "visible" are + // byte-identical. + ...(column.hidden === true ? { hidden: true } : {}), }); }); return Object.freeze(orderPinnedColumns(normalized)); @@ -197,6 +201,24 @@ function orderPinnedColumns( ]; } +/** + * The still-visible column nearest to `hiddenIndex` in layout order: every + * column to its left is tried first, nearest outward, then every column to + * its right — `null` when every other column is hidden too. + */ +function nearestVisibleColumnId( + layout: readonly Readonly>[], + hiddenIndex: number, +): TColumnId | null { + for (let index = hiddenIndex - 1; index >= 0; index -= 1) { + if (layout[index]!.hidden !== true) return layout[index]!.id; + } + for (let index = hiddenIndex + 1; index < layout.length; index += 1) { + if (layout[index]!.hidden !== true) return layout[index]!.id; + } + return null; +} + function copySelection< TRow extends object, TRowId extends PretableRowId, @@ -548,8 +570,13 @@ export function createGridUiCore< const navigationColumnIds = (): readonly TColumnId[] => { if (cachedNavigationLayout !== state.columnLayout) { cachedNavigationLayout = state.columnLayout; + // Drawn columns only: keyboard movement over the full layout would let + // an arrow key land focus on a hidden column — exactly the state + // `setColumnVisible` repairs its way out of. cachedNavigationColumnIds = Object.freeze( - state.columnLayout.map((column) => column.id), + state.columnLayout + .filter((column) => column.hidden !== true) + .map((column) => column.id), ); } return cachedNavigationColumnIds; @@ -829,7 +856,11 @@ export function createGridUiCore< const editColumnId: string = input.columnId; if ( !visible || - !state.columnLayout.some((column) => column.id === editColumnId) + !state.columnLayout.some( + // A hidden column has no cell on screen to host an editor, so it + // is no more editable than a column outside the layout. + (column) => column.id === editColumnId && column.hidden !== true, + ) ) { throw new PretableGridUiError( "invalid-ui-state", @@ -885,14 +916,17 @@ export function createGridUiCore< return ( current?.id === column.id && current.widthPx === column.widthPx && - current.pinned === column.pinned + current.pinned === column.pinned && + current.hidden === column.hidden ); }); if (same) return; // `Set`, not `Set`: `editing.columnId` below is a - // SCHEMA id, and the drawn vocabulary is a different parameter. The - // membership question is the same one `beginEdit` asks — is this - // column still drawn — and it is answerable only over `string`. + // SCHEMA id, and the drawn vocabulary is a different parameter, so + // membership is answerable only over `string`. This set is LAYOUT + // membership — hidden entries included — which is what focus and + // selection repair against; editing repairs against the stricter + // drawn set below. const ids = new Set(nextLayout.map((column) => column.id)); const focus = state.focus.columnId === null || ids.has(state.focus.columnId) @@ -911,13 +945,22 @@ export function createGridUiCore< ranges === state.selection.ranges && anchor === state.selection.anchor ? state.selection : Object.freeze({ ...state.selection, ranges, anchor }); + // Editing is stricter than focus and selection: `beginEdit` demands a + // DRAWN column, so a column the incoming config hides cancels the + // session exactly like one it removes — the same repair + // `setColumnVisible` applies. + const drawnIds = new Set( + nextLayout.flatMap((column) => + column.hidden === true ? [] : [column.id], + ), + ); publish({ ...state, columnLayout: nextLayout, focus, selection, editing: - state.editing !== null && ids.has(state.editing.columnId) + state.editing !== null && drawnIds.has(state.editing.columnId) ? state.editing : null, }); @@ -952,9 +995,16 @@ export function createGridUiCore< if (current === undefined || (current.pinned ?? null) === pinned) return; const next = state.columnLayout.slice(); + // Clearing rebuilds the entry to strip `pinned` (never writes + // `pinned: undefined`), but every OTHER optional key must survive the + // rebuild — unpinning a hidden column must not reveal it. next[index] = Object.freeze( pinned === null - ? { id: current.id, widthPx: current.widthPx } + ? { + id: current.id, + widthPx: current.widthPx, + ...(current.hidden === true ? { hidden: true } : {}), + } : { ...current, pinned }, ); publish({ @@ -963,12 +1013,72 @@ export function createGridUiCore< }); }); }, + setColumnVisible(columnId, visible) { + command(() => { + const index = state.columnLayout.findIndex( + (column) => column.id === columnId, + ); + const current = state.columnLayout[index]; + if (current === undefined || (current.hidden !== true) === visible) + return; + const next = state.columnLayout.slice(); + next[index] = Object.freeze( + visible + ? { + id: current.id, + widthPx: current.widthPx, + ...(current.pinned === undefined + ? {} + : { pinned: current.pinned }), + } + : { ...current, hidden: true }, + ); + // Visibility does not reorder — hidden entries hold their place so + // re-showing restores it — so `orderPinnedColumns` need not re-run. + const columnLayout = Object.freeze(next); + if (visible) { + publish({ ...state, columnLayout }); + return; + } + // The cursor and the anchor cannot keep addressing a hidden column: + // re-seat each onto the nearest still-visible neighbor in layout + // order, left first, then right — inside this same command so the + // layout change and its repairs publish as one wake. + const neighbor = nearestVisibleColumnId(next, index); + const focus = + state.focus.columnId !== columnId + ? state.focus + : neighbor === null + ? Object.freeze({ ref: null, columnId: null }) + : Object.freeze({ ref: state.focus.ref, columnId: neighbor }); + const anchor = + state.selection.anchor !== null && + state.selection.anchor.columnId === columnId + ? neighbor === null + ? null + : Object.freeze({ ...state.selection.anchor, columnId: neighbor }) + : state.selection.anchor; + const selection = + anchor === state.selection.anchor + ? state.selection + : Object.freeze({ ...state.selection, anchor }); + // An open edit session on the hidden column loses its cell, so it is + // cancelled — the same repair `setColumns` applies when the edited + // column leaves the layout entirely. + const editing = + state.editing !== null && + (state.editing.columnId as string) === (columnId as string) + ? null + : state.editing; + publish({ ...state, columnLayout, focus, selection, editing }); + }); + }, setColumnOrder(nextColumnIds) { command(() => { if (nextColumnIds.length !== state.columnLayout.length) { throw new PretableGridUiError( "invalid-ui-state", - "Column order must contain every visual column exactly once.", + "Column order must contain every column in the layout, hidden included, exactly once.", ); } const byId = new Map( @@ -988,7 +1098,7 @@ export function createGridUiCore< if (byId.size > 0) { throw new PretableGridUiError( "invalid-ui-state", - "Column order must contain every visual column exactly once.", + "Column order must contain every column in the layout, hidden included, exactly once.", ); } const ordered = orderPinnedColumns(next); diff --git a/packages/grid-core/src/types.ts b/packages/grid-core/src/types.ts index b794b8498..221ca0d32 100644 --- a/packages/grid-core/src/types.ts +++ b/packages/grid-core/src/types.ts @@ -774,6 +774,12 @@ export interface PretableGridUiColumn { readonly id: TColumnId; readonly widthPx?: number; readonly pinned?: "left" | "right"; + /** + * A hidden column STAYS in the layout — its width, pin state and position + * persist so re-showing restores them — and is filtered out of the drawn + * order by the presentation layer, never removed here. + */ + readonly hidden?: boolean; } /** Normalized visual-only column layout published by the UI store. @public */ @@ -781,6 +787,8 @@ export interface PretableGridUiColumnLayout { readonly id: TColumnId; readonly widthPx: number; readonly pinned?: "left" | "right"; + /** Present only when `true` — see {@link PretableGridUiColumn.hidden}. */ + readonly hidden?: boolean; } /** A correlated, data-row-only editing session. @public */ @@ -925,6 +933,15 @@ export interface PretableGridUiCore< columnId: TColumnId, pinned: "left" | "right" | null, ) => void; + /** + * Show or hide a column WITHOUT removing it from the layout: width, pin + * state and position all persist, so re-showing restores the column exactly + * as it was. Hiding the column under the focus cursor or the selection + * anchor re-seats onto the nearest still-visible neighbor in layout order, + * left first, then right. Idempotent: applying the visibility a column + * already has publishes nothing. + */ + readonly setColumnVisible: (columnId: TColumnId, visible: boolean) => void; readonly setColumnOrder: (columnIds: readonly TColumnId[]) => void; /** @internal Called only after renderer geometry for this exact revision exists. */ readonly observeRowModelRevision: (revision: number) => void; diff --git a/packages/react/react.api.md b/packages/react/react.api.md index 56322bee5..02e57f2bf 100644 --- a/packages/react/react.api.md +++ b/packages/react/react.api.md @@ -431,6 +431,7 @@ export interface PretableBaseProps["rowSelectionColumn"]; // (undocumented) tabBehavior?: PretableSurfaceSharedProps["tabBehavior"]; + toolPanel?: PretableSurfaceSharedProps["toolPanel"]; } // @public @@ -1174,6 +1175,7 @@ export type PretableFormulaEscapePredicate = (value: string, input: PretableForm // @public export interface PretableGridUiColumn { + readonly hidden?: boolean; // (undocumented) readonly id: TColumnId; // (undocumented) @@ -1184,6 +1186,7 @@ export interface PretableGridUiColumn { // @public export interface PretableGridUiColumnLayout { + readonly hidden?: boolean; // (undocumented) readonly id: TColumnId; // (undocumented) @@ -1230,6 +1233,7 @@ export interface PretableGridUiCore void; readonly setColumns: (columns: readonly PretableGridUiColumn[]) => void; + readonly setColumnVisible: (columnId: TColumnId, visible: boolean) => void; // (undocumented) readonly setColumnWidth: (columnId: TColumnId, width: number) => void; // (undocumented) @@ -1699,6 +1703,7 @@ export type PretableReactGrid void; readonly setColumnWidth: (columnId: TColumnId, width: number) => void; readonly setColumnPinned: (columnId: TColumnId, pinned: "left" | "right" | null) => void; + readonly setColumnVisible: (columnId: TColumnId, visible: boolean) => void; readonly setColumnOrder: (columnIds: readonly TColumnId[]) => void; readonly autosizeColumns: () => void; readonly measureRow: (ref: PretableVisibleRowRef, height: number) => void; @@ -2149,6 +2154,8 @@ export interface PretableSurfaceMessages { selectAllLabel?: (args: { scope: "all" | "loaded"; }) => string; + toolPanelColumnsLabel?: () => string; + toolPanelLabel?: () => string; } // @public @@ -2308,6 +2315,7 @@ export interface PretableSurfaceSharedProps | null; tabBehavior?: "wrap-rows" | "exit"; + toolPanel?: boolean | PretableToolPanelConfig; // (undocumented) viewportHeight: number; // (undocumented) @@ -2361,6 +2369,16 @@ export interface PretableTelemetry { }; } +// @public +export interface PretableToolPanelConfig { + // (undocumented) + readonly activeSection?: ToolPanelSectionId | null; + // (undocumented) + readonly defaultActiveSection?: ToolPanelSectionId | null; + // (undocumented) + readonly onActiveSectionChange?: (section: ToolPanelSectionId | null) => void; +} + // @public (undocumented) export interface PretableTransaction { // (undocumented) @@ -2517,6 +2535,9 @@ export interface SerializeRangesArgs { expect( container.querySelector("[data-pretable-row-select-header]"), ).not.toBeNull(); + // The tool panel is on by default, so its rail attributes are part of what + // this sweep walks — assert they are actually mounted so the guard covers + // them non-vacuously. + expect(container.querySelector("[data-pretable-tool-rail]")).not.toBeNull(); + expect(container.querySelector("[data-pretable-tool-tab]")).not.toBeNull(); const ALLOWED = new Set(["data-testid"]); const offenders = new Set(); @@ -102,5 +107,9 @@ describe("attribute contract", () => { // Guard against the assertion going vacuous: the controls this gate exists // for really are in the server output. expect(html).toContain("data-pretable-filter-funnel"); + // The rail tabs are in the SSR output too — painted, clickable, and inert + // until hydration attaches their handlers, exactly like the funnels. The + // hydration attribute above is the signal that covers them. + expect(html).toContain("data-pretable-tool-tab"); }); }); diff --git a/packages/react/src/__tests__/column-order-invariant.test.tsx b/packages/react/src/__tests__/column-order-invariant.test.tsx index 0725c6afe..0f58f6151 100644 --- a/packages/react/src/__tests__/column-order-invariant.test.tsx +++ b/packages/react/src/__tests__/column-order-invariant.test.tsx @@ -1,6 +1,12 @@ import "@testing-library/jest-dom/vitest"; -import { act, cleanup, render } from "@testing-library/react"; -import { afterEach, describe, expect, it } from "vitest"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ROW_SELECT_COLUMN_ID } from "../constants"; import { PretableSurface } from "../pretable-surface"; @@ -32,7 +38,11 @@ interface Row extends Record { const ROWS: Row[] = [{ id: "r1", a: "1", b: "2", c: "3", d: "4" }]; -function mount(columns: PretableColumn[], withRowSelect = false) { +function mount( + columns: PretableColumn[], + withRowSelect = false, + copyToClipboard?: (payload: { readonly text: string }) => void, +) { type Grid = PretableSurfaceGrid[]>; let captured: Grid | null = null; const view = render( @@ -45,6 +55,7 @@ function mount(columns: PretableColumn[], withRowSelect = false) { }} rows={ROWS} {...(withRowSelect ? { rowSelectionColumn: { enabled: true } } : {})} + {...(copyToClipboard ? { copyToClipboard } : {})} viewportHeight={200} />, ); @@ -62,6 +73,7 @@ function mount(columns: PretableColumn[], withRowSelect = false) { : el.getAttribute("data-pretable-column-id"), ), engine: () => captured!.getState().columnLayout.map((c) => c.id), + view, }; } @@ -128,6 +140,94 @@ describe("engine column order is the drawn order", () => { expectAgreement(h); }); + it("a hidden column leaves the drawn order but stays in the engine layout", () => { + const h = mount([ + { id: "a", header: "A" }, + { id: "b", header: "B" }, + { id: "c", header: "C" }, + { id: "d", header: "D" }, + ]); + act(() => h.grid.setColumnVisible("b", false)); + + // No header cell and no body cells for the hidden column. + expect(h.drawn()).toEqual(["a", "c", "d"]); + expect( + h.view.container.querySelectorAll('[data-pretable-column-id="b"]'), + ).toHaveLength(0); + + // The full layout — the roster a columns panel lists — is still reachable + // through the existing state access, width and position intact. + expect(h.engine()).toEqual(["a", "b", "c", "d"]); + const hiddenEntry = h.grid + .getState() + .columnLayout.find((column) => column.id === "b"); + expect((hiddenEntry as { hidden?: boolean } | undefined)?.hidden).toBe( + true, + ); + + // Re-showing restores the drawn cell in place. + act(() => h.grid.setColumnVisible("b", true)); + expect(h.drawn()).toEqual(["a", "b", "c", "d"]); + }); + + it("copy across a hidden column excludes its values from the payload", async () => { + const copyToClipboard = vi.fn(); + const h = mount( + [ + { id: "a", header: "A" }, + { id: "b", header: "B" }, + { id: "c", header: "C" }, + { id: "d", header: "D" }, + ], + false, + copyToClipboard, + ); + act(() => h.grid.setColumnVisible("b", false)); + + // A range that visually spans where "b" would be: a → c. + act(() => + h.grid.setSelection({ + rows: { kind: "explicit", rowIds: new Set() }, + ranges: [ + { + start: { rowId: "r1", columnId: "a" }, + end: { rowId: "r1", columnId: "c" }, + }, + ], + anchor: { rowId: "r1", columnId: "a" }, + } as never), + ); + const cell = h.view.container.querySelector( + '[data-pretable-column-id="a"][data-pretable-cell]', + ); + expect(cell).not.toBeNull(); + fireEvent.keyDown(cell!, { key: "c", metaKey: true }); + + await waitFor(() => expect(copyToClipboard).toHaveBeenCalledOnce()); + const payload = copyToClipboard.mock.calls[0]![0] as { text: string }; + expect(payload.text).toBe("1\t3"); + }); + + it("hidden columns are absent from the drawn order every span consumer reads", () => { + const h = mount([ + { id: "a", header: "A" }, + { id: "b", header: "B" }, + { id: "c", header: "C" }, + { id: "d", header: "D" }, + ]); + act(() => h.grid.setColumnVisible("c", false)); + + // The engine order minus hidden entries IS the drawn order — the + // hidden-column refinement of `expectAgreement` above. + const visibleEngine = h.grid + .getState() + .columnLayout.filter( + (column) => (column as { hidden?: boolean }).hidden !== true, + ) + .map((column) => column.id); + expect(h.drawn()).toEqual(visibleEngine); + }); + it("holds with the synthetic row-select column present", () => { const h = mount( [ diff --git a/packages/react/src/__tests__/column-reorder-scroll.test.tsx b/packages/react/src/__tests__/column-reorder-scroll.test.tsx index 69fa90dfe..9e96528be 100644 --- a/packages/react/src/__tests__/column-reorder-scroll.test.tsx +++ b/packages/react/src/__tests__/column-reorder-scroll.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; import * as React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -258,3 +258,48 @@ describe("column reorder under horizontal scroll", () => { ]); }); }); + +describe("column reorder while a column is hidden", () => { + it("drops where the cursor is and keeps the hidden id at its prior relative position", () => { + const onColumnOrderChange = vi.fn(); + type Grid = { + setColumnVisible: (columnId: string, visible: boolean) => void; + getState: () => { columnLayout: readonly { id: string }[] }; + }; + let captured: Grid | null = null; + const view = render( + + ariaLabel="hidden-reorder-grid" + columns={columns} + getRowId={(row: Row) => row.id} + onColumnOrderChange={onColumnOrderChange} + onGridReady={(g) => { + captured = g as unknown as Grid; + }} + overscan={0} + rows={rows} + viewportHeight={200} + />, + ); + act(() => captured!.setColumnVisible("c", false)); + + // With "c" hidden the visible content layout is pin [0,100), b [100,300), + // d [300,500), note sticky at viewport 300…400. Viewport x 340 is inside + // the sticky "note", left of its midpoint (350), so "b" lands between "d" + // and "note" — the same drop the unhidden test above resolves for "c". + const header = dragHeaderTo(view, "B", 340); + fireEvent.pointerUp(header, { pointerId: 1, clientX: 340, clientY: 10 }); + + // The reported order is the DRAWN vocabulary — no hidden ids. + expect(onColumnOrderChange).toHaveBeenCalledWith(["pin", "d", "b", "note"]); + // The engine still names every column: hidden "c" holds its place among + // the columns that did not move. + expect(captured!.getState().columnLayout.map((c) => c.id)).toEqual([ + "pin", + "c", + "d", + "b", + "note", + ]); + }); +}); diff --git a/packages/react/src/__tests__/controlled-state-write-back.test.tsx b/packages/react/src/__tests__/controlled-state-write-back.test.tsx index 3eef734f5..d754b221c 100644 --- a/packages/react/src/__tests__/controlled-state-write-back.test.tsx +++ b/packages/react/src/__tests__/controlled-state-write-back.test.tsx @@ -1,5 +1,5 @@ import "@testing-library/jest-dom/vitest"; -import { cleanup, render, waitFor } from "@testing-library/react"; +import { act, cleanup, render, waitFor } from "@testing-library/react"; import * as React from "react"; import { afterEach, describe, expect, it } from "vitest"; @@ -203,6 +203,51 @@ describe("controlled write-back", () => { }); }); + it("applies state.columnOrder in the drawn vocabulary while a column is hidden", async () => { + type Grid = { + setColumnVisible: (columnId: string, visible: boolean) => void; + getState: () => { columnLayout: readonly { id: string }[] }; + }; + let captured: Grid | null = null; + const mounted = ( + state: PretableSurfaceState, + ): React.ReactElement => ( + row.id} + onGridReady={(g) => { + captured = g as unknown as Grid; + }} + overscan={0} + rows={rows} + state={state} + viewportHeight={300} + /> + ); + const { container, rerender } = render(mounted({})); + await waitFor(() => { + expect(headerIds(container)).toEqual(["name", "city", "score"]); + }); + act(() => captured!.setColumnVisible("city", false)); + await waitFor(() => { + expect(headerIds(container)).toEqual(["name", "score"]); + }); + + // The controlled vocabulary is DRAWN: hidden ids are not named. The + // write-back splices them back at their prior relative positions before + // the engine — which requires the full roster — sees the order. + rerender(mounted({ columnOrder: ["score", "name"] })); + await waitFor(() => { + expect(headerIds(container)).toEqual(["score", "name"]); + }); + expect(captured!.getState().columnLayout.map((c) => c.id)).toEqual([ + "score", + "name", + "city", + ]); + }); + it("applies state.columnWidths to the engine", async () => { const { container } = renderControlled({ columnWidths: { city: 240 } }); await waitFor(() => { diff --git a/packages/react/src/__tests__/group-panel.test.tsx b/packages/react/src/__tests__/group-panel.test.tsx index fa4916e0f..7f45824d4 100644 --- a/packages/react/src/__tests__/group-panel.test.tsx +++ b/packages/react/src/__tests__/group-panel.test.tsx @@ -43,6 +43,7 @@ interface GridProps { onQueryChange?: ( query: PretableQueryFor>, ) => void; + toolPanel?: boolean; viewportHeight?: number; } @@ -53,6 +54,7 @@ function Grid({ query, onQueryChange, state, + toolPanel, viewportHeight, }: GridProps) { return ( @@ -62,6 +64,7 @@ function Grid({ getRowId={(row: Holding) => row.id} groupPanel={groupPanel} overscan={0} + {...(toolPanel === undefined ? {} : { toolPanel })} rows={rows} state={state} query={ @@ -86,14 +89,20 @@ const panel = (view: { container: HTMLElement }) => view.container.querySelector("[data-pretable-group-panel]")!; describe("group panel — wrapper and height accounting", () => { - it("without groupPanel, the root is still the scroll viewport", () => { - const view = renderGrid(); + // These three pin the VERTICAL stack's shape, so they opt out of the tool + // panel — its default-on row wrapper is its own contract, pinned in + // tool-panel.test.tsx. + it("without groupPanel, the root of the vertical stack is the scroll viewport", () => { + const view = renderGrid({ toolPanel: false }); const root = view.container.firstElementChild!; expect(root).toHaveAttribute("data-pretable-scroll-viewport"); }); it("with groupPanel, the viewport is wrapped and keeps every attribute", () => { - const view = renderGrid({ groupPanel: { enabled: true } }); + const view = renderGrid({ + groupPanel: { enabled: true }, + toolPanel: false, + }); const root = view.container.firstElementChild!; expect(root).toHaveAttribute("data-pretable-group-panel-wrapper"); const viewport = root.querySelector("[data-pretable-scroll-viewport]")!; @@ -104,7 +113,7 @@ describe("group panel — wrapper and height accounting", () => { it("the panel consumes from viewportHeight rather than adding to it", () => { // The component must occupy exactly `viewportHeight` either way, so a // consumer's layout does not shift when they enable the panel. - const plain = renderGrid({ viewportHeight: 400 }); + const plain = renderGrid({ viewportHeight: 400, toolPanel: false }); const plainVp = plain.container.querySelector( "[data-pretable-scroll-viewport]", ) as HTMLElement; @@ -114,6 +123,7 @@ describe("group panel — wrapper and height accounting", () => { const panelled = renderGrid({ viewportHeight: 400, groupPanel: { enabled: true }, + toolPanel: false, }); const wrapper = panelled.container.firstElementChild as HTMLElement; const vp = panelled.container.querySelector( diff --git a/packages/react/src/__tests__/pretable.test.tsx b/packages/react/src/__tests__/pretable.test.tsx index 6a0af39ca..d6882f44d 100644 --- a/packages/react/src/__tests__/pretable.test.tsx +++ b/packages/react/src/__tests__/pretable.test.tsx @@ -611,3 +611,37 @@ it("downgrades aria-rowcount and warns when the total claims fewer records than expect(viewport).toHaveAttribute("aria-rowcount", "3"); expect(warnSpy).toHaveBeenCalled(); }); + +it("renders the tool panel rail by default, and toolPanel={false} removes it", () => { + const columns = [{ id: "name", header: "Name" }] as const; + const rows = [{ id: "a", name: "Ada" }]; + + const withDefault = render( + row.id} + />, + ); + expect( + withDefault.container.querySelector("[data-pretable-tool-rail]"), + ).toBeInTheDocument(); + withDefault.unmount(); + + // The prop must actually reach the surface: the forward is one spread, but + // the interface member is what admits `toolPanel` at the type level — this + // asserts the runtime value lands too, not just that the prop typechecks. + const disabled = render( + row.id} + toolPanel={false} + />, + ); + expect( + disabled.container.querySelector("[data-pretable-tool-rail]"), + ).not.toBeInTheDocument(); +}); diff --git a/packages/react/src/__tests__/tool-panel-drop-target.test.ts b/packages/react/src/__tests__/tool-panel-drop-target.test.ts new file mode 100644 index 000000000..6028d77d6 --- /dev/null +++ b/packages/react/src/__tests__/tool-panel-drop-target.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; + +import { + dropTargetForPointer, + type ToolDropGroup, + type ToolRowRect, +} from "../tool-panel/tool-panel-drop-target"; + +/** + * Geometry fixtures. Rows are 28px tall, stacked with a 12px group gap where + * a subgroup label sits — deliberately non-zero, because the boundary rule + * splits that gap and a zero gap could not disprove a wrong split. + * + * Layout (tops): + * group 0 (pinned left): a [0..28) + * group 1 (unpinned): b [40..68) c [68..96) d [96..124) + * group 2 (pinned right): e [136..164) + */ +const GROUPS: readonly ToolDropGroup[] = [ + { pinned: "left" }, + { pinned: null }, + { pinned: "right" }, +]; + +function row( + id: string, + top: number, + groupIndex: number, + height = 28, +): ToolRowRect { + return { id, top, height, groupIndex }; +} + +const ROWS: readonly ToolRowRect[] = [ + row("a", 0, 0), + row("b", 40, 1), + row("c", 68, 1), + row("d", 96, 1), + row("e", 136, 2), +]; + +describe("dropTargetForPointer", () => { + it("returns null for an empty row list", () => { + expect(dropTargetForPointer(50, [], GROUPS)).toBeNull(); + }); + + it("targets before a row while the pointer is above its midpoint", () => { + // c spans [68..96), midpoint 82. Just under it: before c. + expect(dropTargetForPointer(81, ROWS, GROUPS)).toEqual({ + beforeRow: 2, + groupIndex: 1, + indicatorY: 68, + }); + }); + + it("targets after a row once the pointer passes its midpoint", () => { + // Past c's midpoint but before d's (110): before d. + expect(dropTargetForPointer(83, ROWS, GROUPS)).toEqual({ + beforeRow: 3, + groupIndex: 1, + indicatorY: 96, + }); + }); + + it("targets the very first position when the pointer is above every midpoint", () => { + expect(dropTargetForPointer(-10, ROWS, GROUPS)).toEqual({ + beforeRow: 0, + groupIndex: 0, + indicatorY: 0, + }); + }); + + it("targets after the last row when the pointer passes every midpoint", () => { + // e's midpoint is 150; below it appends to the last group. + expect(dropTargetForPointer(400, ROWS, GROUPS)).toEqual({ + beforeRow: 5, + groupIndex: 2, + indicatorY: 164, + }); + }); + + it("splits a group gap: the upper half appends to the group above", () => { + // Gap between a (bottom 28) and b (top 40); split at 34. Above the + // split but past a's midpoint (14): end of group 0 — same insertion + // slot as "before b", but the PIN of group 0. + expect(dropTargetForPointer(33, ROWS, GROUPS)).toEqual({ + beforeRow: 1, + groupIndex: 0, + indicatorY: 28, + }); + }); + + it("splits a group gap: the lower half prepends to the group below", () => { + expect(dropTargetForPointer(35, ROWS, GROUPS)).toEqual({ + beforeRow: 1, + groupIndex: 1, + indicatorY: 40, + }); + }); + + it("crosses into a trailing pinned group below the last unpinned row", () => { + // Gap between d (bottom 124) and e (top 136); split at 130. + expect(dropTargetForPointer(131, ROWS, GROUPS)).toEqual({ + beforeRow: 4, + groupIndex: 2, + indicatorY: 136, + }); + expect(dropTargetForPointer(129, ROWS, GROUPS)).toEqual({ + beforeRow: 4, + groupIndex: 1, + indicatorY: 124, + }); + }); + + it("treats hidden rows as ordinary slots — ids play no part in the math", () => { + // The same geometry with different ids must produce the same target: + // hidden columns are rendered rows here, so they hold boundaries too. + const withHidden = ROWS.map((r, i) => + i === 2 ? { ...r, id: "hidden-col" } : r, + ); + expect(dropTargetForPointer(81, withHidden, GROUPS)).toEqual({ + beforeRow: 2, + groupIndex: 1, + indicatorY: 68, + }); + }); + + it("handles a single-group list (no pinned subgroups rendered)", () => { + const rows = [row("x", 0, 0), row("y", 28, 0)]; + const groups: ToolDropGroup[] = [{ pinned: null }]; + expect(dropTargetForPointer(27, rows, groups)).toEqual({ + beforeRow: 1, + groupIndex: 0, + indicatorY: 28, + }); + expect(dropTargetForPointer(100, rows, groups)).toEqual({ + beforeRow: 2, + groupIndex: 0, + indicatorY: 56, + }); + }); + + it("lands exactly on a midpoint: the boundary belongs to the position after", () => { + // c's midpoint is 82; the >= side goes after c (before d). + expect(dropTargetForPointer(82, ROWS, GROUPS)).toEqual({ + beforeRow: 3, + groupIndex: 1, + indicatorY: 96, + }); + }); +}); diff --git a/packages/react/src/__tests__/tool-panel.test.tsx b/packages/react/src/__tests__/tool-panel.test.tsx new file mode 100644 index 000000000..f0d55520d --- /dev/null +++ b/packages/react/src/__tests__/tool-panel.test.tsx @@ -0,0 +1,1180 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { GROUP_COLUMN_ID } from "@pretable/core"; +import { + act, + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; +import { useState } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { ROW_SELECT_COLUMN_ID } from "../constants"; +import { PretableSurface } from "../public_api"; +import type { PretableColumn, PretableToolPanelConfig } from "../public_api"; +import type { PretableSurfaceGrid } from "../pretable-surface"; +import { ToolPanel } from "../tool-panel"; +import type { + ToolPanelSectionDescriptor, + ToolPanelSectionId, +} from "../tool-panel"; + +afterEach(() => { + cleanup(); +}); + +/* Task 7 builds the real columns section; the shell must not care what a + section renders, so these tests exercise it with throwaway descriptors. + The second id is outside today's closed union on purpose — the contract + says the shell may not assume the union is closed at runtime. */ +const FakeIcon = ({ className }: { className?: string }) => ( + +); + +function makeSections(): ToolPanelSectionDescriptor[] { + return [ + { + id: "columns", + icon: FakeIcon, + label: "Columns", + render: () =>
, + }, + { + id: "filters" as ToolPanelSectionId, + icon: FakeIcon, + label: "Filters", + render: () =>
, + }, + ]; +} + +/** The shell is controlled; this harness plays the part of Task 6's surface. */ +function Host({ + initial = null, + onChange, +}: { + initial?: ToolPanelSectionId | null; + onChange?: (next: ToolPanelSectionId | null) => void; +}) { + const [active, setActive] = useState(initial); + return ( + { + onChange?.(next); + setActive(next); + }} + /> + ); +} + +describe("ToolPanel shell", () => { + it("renders one rail tab per descriptor with role=tab and the label as accessible name", () => { + const { getByRole, getAllByRole } = render(); + expect(getByRole("tablist")).toHaveAttribute( + "aria-orientation", + "vertical", + ); + const tabs = getAllByRole("tab"); + expect(tabs).toHaveLength(2); + expect(getByRole("tab", { name: "Columns" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Filters" })).toBeInTheDocument(); + for (const tab of tabs) { + expect(tab).toHaveAttribute("data-pretable-tool-tab"); + expect(tab).toHaveAttribute("data-pretable-section"); + } + }); + + it("renders no pane while activeSection is null, and opens one on tab click wired via aria-controls/aria-labelledby", () => { + const { container, getByRole, queryByRole, getByTestId } = render(); + expect(queryByRole("tabpanel")).toBeNull(); + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + + const tab = getByRole("tab", { name: "Columns" }); + fireEvent.click(tab); + + const pane = getByRole("tabpanel"); + expect(pane).toHaveAttribute("data-pretable-tool-pane"); + expect(pane.id).toBe(tab.getAttribute("aria-controls")); + expect(pane.getAttribute("aria-labelledby")).toBe(tab.id); + expect(tab).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "Filters" })).toHaveAttribute( + "aria-selected", + "false", + ); + // The section container the CSS pins, holding the descriptor's output. + expect(pane.querySelector("[data-pretable-tool-section]")).not.toBeNull(); + expect(getByTestId("fake-section")).toBeInTheDocument(); + }); + + it("clicking the active tab closes the pane and reports null", () => { + const onChange = vi.fn(); + const { getByRole, queryByRole } = render( + , + ); + expect(getByRole("tabpanel")).toBeInTheDocument(); + + fireEvent.click(getByRole("tab", { name: "Columns" })); + expect(onChange).toHaveBeenLastCalledWith(null); + expect(queryByRole("tabpanel")).toBeNull(); + }); + + it("ArrowDown moves DOM focus to the next tab without following activation", () => { + const onChange = vi.fn(); + const { getByRole } = render( + , + ); + const columnsTab = getByRole("tab", { name: "Columns" }); + const filtersTab = getByRole("tab", { name: "Filters" }); + + columnsTab.focus(); + fireEvent.keyDown(columnsTab, { key: "ArrowDown" }); + + expect(filtersTab).toHaveFocus(); + // Focus moved; activation did not. + expect(columnsTab).toHaveAttribute("aria-selected", "true"); + expect(filtersTab).toHaveAttribute("aria-selected", "false"); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(filtersTab, { key: "ArrowUp" }); + expect(columnsTab).toHaveFocus(); + }); + + it("keeps the rail a single tab stop: exactly one tab has tabIndex 0, before and after arrowing", () => { + const { getAllByRole, getByRole } = render(); + const zeroStops = () => getAllByRole("tab").filter((t) => t.tabIndex === 0); + expect(zeroStops()).toHaveLength(1); + expect(zeroStops()[0]).toBe(getByRole("tab", { name: "Columns" })); + + const columnsTab = getByRole("tab", { name: "Columns" }); + columnsTab.focus(); + fireEvent.keyDown(columnsTab, { key: "ArrowDown" }); + expect(zeroStops()).toHaveLength(1); + }); + + it("resets the tab stop to the active tab when focus leaves the rail mid-browse", () => { + const { getByRole } = render( +
+ + +
, + ); + const columnsTab = getByRole("tab", { name: "Columns" }); + const filtersTab = getByRole("tab", { name: "Filters" }); + + columnsTab.focus(); + fireEvent.keyDown(columnsTab, { key: "ArrowDown" }); + expect(filtersTab).toHaveFocus(); + expect(filtersTab.tabIndex).toBe(0); + + // Abandon the browse: focus something outside the rail. + getByRole("button", { name: "outside" }).focus(); + fireEvent.blur(filtersTab, { + relatedTarget: getByRole("button", { name: "outside" }), + }); + + // A returning Tab lands on the ACTIVE tab, not where the browse stopped. + expect(columnsTab.tabIndex).toBe(0); + expect(filtersTab.tabIndex).toBe(-1); + }); + + it("Escape inside the pane returns focus to the active rail tab", () => { + const { getByRole, getByTestId } = render(); + const inner = getByTestId("fake-section"); + inner.focus?.(); + fireEvent.keyDown(inner, { key: "Escape" }); + expect(getByRole("tab", { name: "Columns" })).toHaveFocus(); + // Escape closes nothing — it is a focus hand-back, not a dismissal. + expect(getByRole("tabpanel")).toBeInTheDocument(); + }); +}); + +/* ---- Task 6: the panel on the surface ---------------------------------- */ + +type SurfaceRow = { id: string; name: string; amount: number }; + +const surfaceColumns: PretableColumn[] = [ + { id: "name", header: "Name" }, + { id: "amount", header: "Amount" }, +]; +const surfaceRows: SurfaceRow[] = [ + { id: "r1", name: "Alpha", amount: 1 }, + { id: "r2", name: "Beta", amount: 2 }, +]; + +function renderSurface(toolPanel?: boolean | PretableToolPanelConfig) { + return render( + r.id} + viewportHeight={300} + {...(toolPanel === undefined ? {} : { toolPanel })} + />, + ); +} + +describe("tool panel on the surface", () => { + it("is on by default: no toolPanel prop renders the rail with no open pane", () => { + const { container } = renderSurface(); + expect(container.querySelector("[data-pretable-tool-rail]")).not.toBeNull(); + expect( + container.querySelector( + '[data-pretable-tool-tab][data-pretable-section="columns"]', + ), + ).not.toBeNull(); + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + }); + + it("toolPanel={false} renders neither rail nor pane", () => { + const { container } = renderSurface(false); + expect(container.querySelector("[data-pretable-tool-rail]")).toBeNull(); + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + }); + + it("names the tablist so the rail has an accessible name", () => { + const { getByRole } = renderSurface(); + expect(getByRole("tablist", { name: "Tool panel" })).toBeInTheDocument(); + }); + + it("uncontrolled: defaultActiveSection opens the pane at mount and tab clicks toggle it", () => { + const { container, getByRole } = renderSurface({ + defaultActiveSection: "columns", + }); + expect(container.querySelector("[data-pretable-tool-pane]")).not.toBeNull(); + + fireEvent.click(getByRole("tab", { name: "Columns" })); + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + + fireEvent.click(getByRole("tab", { name: "Columns" })); + expect(container.querySelector("[data-pretable-tool-pane]")).not.toBeNull(); + }); + + it("controlled: activeSection pins the pane; a tab click reports but does not mutate", () => { + const onActiveSectionChange = vi.fn(); + const controlled = (active: ToolPanelSectionId | null) => ( + r.id} + viewportHeight={300} + toolPanel={{ activeSection: active, onActiveSectionChange }} + /> + ); + const { container, getByRole, rerender } = render(controlled(null)); + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + + // The click reports the intent… + fireEvent.click(getByRole("tab", { name: "Columns" })); + expect(onActiveSectionChange).toHaveBeenLastCalledWith("columns"); + // …but the DOM holds until the prop moves. + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + + rerender(controlled("columns")); + expect(container.querySelector("[data-pretable-tool-pane]")).not.toBeNull(); + + // And closing under control is the same one-way street. + fireEvent.click(getByRole("tab", { name: "Columns" })); + expect(onActiveSectionChange).toHaveBeenLastCalledWith(null); + expect(container.querySelector("[data-pretable-tool-pane]")).not.toBeNull(); + rerender(controlled(null)); + expect(container.querySelector("[data-pretable-tool-pane]")).toBeNull(); + }); + + it("composes with the group panel: the panel wrapper lands inside the tool layout's grid area", () => { + // Task 6 shipped both wrappers but no test rendered them together; the + // group-panel wrapper must stack INSIDE the grid area so the pane and + // rail dock beside the whole vertical stack, panel included. + const { container } = render( + r.id} + groupPanel={{ enabled: true }} + viewportHeight={300} + />, + ); + const gridArea = container.querySelector("[data-pretable-tool-grid-area]"); + expect(gridArea).not.toBeNull(); + expect( + gridArea?.querySelector("[data-pretable-group-panel-wrapper]"), + ).not.toBeNull(); + }); + + it("keeps the rail and pane inside the card wrapper so the chrome wraps them", () => { + const { container } = renderSurface({ defaultActiveSection: "columns" }); + const layout = container.querySelector("[data-pretable-tool-layout]"); + expect(layout).not.toBeNull(); + // Visual order inside the row: [grid area][pane][rail]. + const children = [...(layout as HTMLElement).children]; + expect(children[0]?.hasAttribute("data-pretable-tool-grid-area")).toBe( + true, + ); + expect(children[1]?.hasAttribute("data-pretable-tool-pane")).toBe(true); + expect(children[2]?.hasAttribute("data-pretable-tool-rail")).toBe(true); + // The scroll viewport (and its hydration signal) lives in the grid area. + expect( + children[0]?.querySelector("[data-pretable-scroll-viewport]"), + ).not.toBeNull(); + }); +}); + +/* ---- Task 7: the columns section --------------------------------------- */ + +type SectionRow = { + id: string; + a: string; + b: string; + c: string; + d: string; +}; + +const sectionRows: SectionRow[] = [ + { id: "r1", a: "1", b: "2", c: "3", d: "4" }, +]; +const sectionColumns: PretableColumn[] = [ + { id: "a", header: "Alpha", pinned: "left" }, + { id: "b", header: "Bravo" }, + { id: "c", header: "Charlie" }, + { id: "d", header: "Delta", pinned: "right" }, +]; + +type SectionGrid = PretableSurfaceGrid< + SectionRow, + string, + readonly PretableColumn[] +>; + +function mountColumnsSection(options?: { + columns?: PretableColumn[]; + withRowSelect?: boolean; + rowGroups?: string[]; + open?: boolean; +}) { + // A ref-shaped holder rather than a `let`: TS's control-flow analysis + // cannot see the callback assignment, and a property read narrows cleanly + // at the explicit null check below — no `!`, so an unfired onGridReady + // fails with its own message instead of a null dereference. + const captured = { current: null as SectionGrid | null }; + const shared = { + ariaLabel: "Columns section grid", + columns: options?.columns ?? sectionColumns, + getRowId: (r: SectionRow) => r.id, + onGridReady: (g: unknown) => { + captured.current = g as SectionGrid; + }, + rows: sectionRows, + toolPanel: { + defaultActiveSection: options?.open === false ? null : "columns", + } as const, + viewportHeight: 300, + ...(options?.withRowSelect + ? { rowSelectionColumn: { enabled: true as const } } + : {}), + }; + // Rendered as two literal JSX branches: `query` controlled-or-absent is a + // props UNION, and a conditional spread widens every member to `| undefined` + // — which the union's "absent" arm rejects. + const view = options?.rowGroups + ? render( + + {...shared} + onQueryChange={() => {}} + query={{ + filters: [], + sort: [], + rowGroups: options.rowGroups.map((columnId) => ({ columnId })), + }} + />, + ) + : render( {...shared} />); + /** Swap the columns PROP mid-session — the roster-change path, not a + * runtime layout write. Only meaningful for the non-grouped mount. */ + const rerenderColumns = (nextColumns: PretableColumn[]) => + view.rerender( + {...shared} columns={nextColumns} />, + ); + const rows = () => + Array.from( + view.container.querySelectorAll("[data-pretable-tool-column-row]"), + ) as HTMLElement[]; + if (captured.current === null) { + throw new Error("onGridReady never fired: no grid captured at mount"); + } + const grid = captured.current; + return { + view, + rerenderColumns, + grid, + rows, + rowByLabel: (label: string) => + rows().find( + (row) => + row.querySelector("[data-pretable-tool-column-label]") + ?.textContent === label, + ), + rowLabels: () => + rows().map( + (row) => + row.querySelector("[data-pretable-tool-column-label]")?.textContent, + ), + groupLabels: () => + Array.from( + view.container.querySelectorAll("[data-pretable-tool-group-label]"), + ).map((el) => el.textContent), + /** Group label + row label texts in DOM order — the subgrouping proof. */ + listSequence: () => + Array.from( + view.container.querySelectorAll( + "[data-pretable-tool-group-label], [data-pretable-tool-column-label]", + ), + ).map((el) => el.textContent), + toggleFor: (label: string) => + rows() + .find( + (row) => + row.querySelector("[data-pretable-tool-column-label]") + ?.textContent === label, + ) + ?.querySelector("button[data-pretable-tool-column-toggle]") as + HTMLButtonElement | undefined, + search: () => + view.container.querySelector( + "[data-pretable-tool-search]", + ) as HTMLInputElement, + reset: () => + view.container.querySelector( + "[data-pretable-tool-reset]", + ) as HTMLButtonElement, + drawnHeaderIds: () => + Array.from( + view.container.querySelectorAll( + "[data-pretable-header-cell][data-pretable-column-id]", + ), + ).map((el) => el.getAttribute("data-pretable-column-id")), + kebabFor: (label: string) => + rows() + .find( + (row) => + row.querySelector("[data-pretable-tool-column-label]") + ?.textContent === label, + ) + ?.querySelector("button[data-pretable-tool-row-menu-button]") as + HTMLButtonElement | undefined, + /** The pin menu lives in an OverlayPortal, so it is queried on document. */ + menu: () => + document.querySelector( + "[data-pretable-column-menu]", + ) as HTMLElement | null, + menuItems: () => + Array.from( + document.querySelectorAll("[data-pretable-menu-item]"), + ) as HTMLButtonElement[], + engineLayout: () => + grid.getState().columnLayout.map((entry) => ({ + id: entry.id, + pinned: entry.pinned ?? null, + hidden: entry.hidden === true, + })), + }; +} + +describe("columns section", () => { + it("lists every layout column in layout order, subgrouped by pin state, hidden rows present and marked", () => { + const h = mountColumnsSection(); + // Layout order is the drawn order: [left][unpinned][right]. + expect(h.rowLabels()).toEqual(["Alpha", "Bravo", "Charlie", "Delta"]); + expect(h.listSequence()).toEqual([ + "Pinned left", + "Alpha", + "Columns", + "Bravo", + "Charlie", + "Pinned right", + "Delta", + ]); + // Full row anatomy: grip, toggle, label, kebab — grip and kebab inert. + const row = h.rowByLabel("Bravo")!; + expect(row.querySelector("[data-pretable-tool-row-grip]")).not.toBeNull(); + expect( + row.querySelector("button[data-pretable-tool-column-toggle]"), + ).not.toBeNull(); + const kebab = row.querySelector( + "button[data-pretable-tool-row-menu-button]", + ); + expect(kebab).not.toBeNull(); + // Wired since Task 8: a closed menu button announces itself as such. + expect(kebab).toHaveAttribute("aria-haspopup", "menu"); + expect(kebab).toHaveAttribute("aria-expanded", "false"); + + // A hidden column stays listed at its position, unchecked and marked. + act(() => h.grid.setColumnVisible("b", false)); + expect(h.rowLabels()).toEqual(["Alpha", "Bravo", "Charlie", "Delta"]); + const hiddenRow = h.rowByLabel("Bravo")!; + expect(hiddenRow.getAttribute("data-pretable-column-hidden")).toBe("true"); + expect(h.toggleFor("Bravo")).toHaveAttribute("aria-checked", "false"); + expect(h.toggleFor("Alpha")).toHaveAttribute("aria-checked", "true"); + expect( + h.rowByLabel("Alpha")!.hasAttribute("data-pretable-column-hidden"), + ).toBe(false); + }); + + it("excludes the derived group column and the selection column while the engine still draws them", async () => { + const h = mountColumnsSection({ withRowSelect: true, rowGroups: ["c"] }); + await waitFor(() => { + // Non-vacuous: both synthetic columns really are in the engine layout. + const ids = h.engineLayout().map((entry) => entry.id); + expect(ids).toContain(GROUP_COLUMN_ID); + expect(ids).toContain(ROW_SELECT_COLUMN_ID); + }); + // Grouped-away "c" leaves the layout entirely under the default + // hideGroupedColumns, so the panel lists the remaining schema columns. + expect(h.rowLabels()).toEqual(["Alpha", "Bravo", "Delta"]); + }); + + it("unchecking hides the column in the grid; the row stays, dimmed", () => { + const h = mountColumnsSection(); + expect(h.drawnHeaderIds()).toEqual(["a", "b", "c", "d"]); + + fireEvent.click(h.toggleFor("Bravo")!); + + // The engine records the hide… + expect(h.engineLayout()).toContainEqual({ + id: "b", + pinned: null, + hidden: true, + }); + // …the drawn grid loses the column… + expect(h.drawnHeaderIds()).toEqual(["a", "c", "d"]); + // …and the panel keeps the row, dimmed and unchecked. + const row = h.rowByLabel("Bravo")!; + expect(row.getAttribute("data-pretable-column-hidden")).toBe("true"); + expect(h.toggleFor("Bravo")).toHaveAttribute("aria-checked", "false"); + + // Re-checking restores the column at its old position. + fireEvent.click(h.toggleFor("Bravo")!); + expect(h.drawnHeaderIds()).toEqual(["a", "b", "c", "d"]); + expect( + h.rowByLabel("Bravo")!.hasAttribute("data-pretable-column-hidden"), + ).toBe(false); + }); + + it("search narrows rows case-insensitively and hides emptied subgroup labels", () => { + const h = mountColumnsSection(); + fireEvent.change(h.search(), { target: { value: "RA" } }); + expect(h.rowLabels()).toEqual(["Bravo"]); + expect(h.groupLabels()).toEqual(["Columns"]); + + fireEvent.change(h.search(), { target: { value: "delta" } }); + expect(h.rowLabels()).toEqual(["Delta"]); + expect(h.groupLabels()).toEqual(["Pinned right"]); + + fireEvent.change(h.search(), { target: { value: "" } }); + expect(h.rowLabels()).toEqual(["Alpha", "Bravo", "Charlie", "Delta"]); + expect(h.groupLabels()).toEqual(["Pinned left", "Columns", "Pinned right"]); + }); + + it("reset after hide+pin+reorder restores the initial layout on the engine", () => { + const h = mountColumnsSection(); + act(() => { + h.grid.setColumnVisible("b", false); + h.grid.setColumnPinned("c", "left"); + h.grid.setColumnOrder(["d", "c", "b", "a"]); + }); + // Sanity: the layout really moved before reset. + expect(h.engineLayout()).not.toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "c", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + + fireEvent.click(h.reset()); + + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "c", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + }); + + it("reset replays the layout captured at SURFACE mount, even when the pane opened later", () => { + // The pane starts closed, the layout is mutated, THEN the pane opens: a + // section-mount capture would treat the mutated layout as the baseline. + const h = mountColumnsSection({ open: false }); + act(() => { + h.grid.setColumnVisible("b", false); + h.grid.setColumnPinned("c", "left"); + }); + + fireEvent.click(h.view.getByRole("tab", { name: "Columns" })); + fireEvent.click(h.reset()); + + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "c", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + }); + + it("reset keeps a column ADDED since mount at its current position while restoring the initial ids", () => { + // "e" joins the roster after the surface captured its baseline, so the + // reset has no initial state for it: the order replay must splice it back + // at its CURRENT index, not drop it (setColumnOrder demands every id) and + // not shove it to the end. + const h = mountColumnsSection(); + act(() => { + h.rerenderColumns([...sectionColumns, { id: "e", header: "Echo" }]); + }); + act(() => { + // Park "e" mid-list AND shuffle the initial ids around it: "keep e's + // position" must be distinguishable from "append it", and the initial + // ids being out of order is what makes a skipped order replay visible + // at all — with them already in initial order, setColumnOrder is a + // no-op and deleting it would go undetected. + h.grid.setColumnOrder(["a", "c", "e", "b", "d"]); + h.grid.setColumnVisible("c", false); + }); + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "c", pinned: null, hidden: true }, + { id: "e", pinned: null, hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + + fireEvent.click(h.reset()); + + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "e", pinned: null, hidden: false }, + { id: "c", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + }); + + it("shows an empty-state line when the search matches nothing", () => { + const h = mountColumnsSection(); + const empty = () => + h.view.container.querySelector("[data-pretable-tool-empty]"); + expect(empty()).toBeNull(); + + fireEvent.change(h.search(), { target: { value: "zzz" } }); + expect(h.rows()).toHaveLength(0); + expect(empty()).not.toBeNull(); + expect(empty()?.textContent).toBe("No columns match"); + + fireEvent.change(h.search(), { target: { value: "" } }); + expect(empty()).toBeNull(); + }); + + it("reset skips a column REMOVED since mount instead of naming a stale id", () => { + // "d" is in the captured baseline but gone from the roster: the order + // replay must filter it out, or setColumnOrder throws invalid-ui-state on + // the stale id and the reset dies mid-flight. + const h = mountColumnsSection(); + act(() => { + h.rerenderColumns(sectionColumns.filter((column) => column.id !== "d")); + }); + act(() => { + h.grid.setColumnOrder(["c", "b", "a"]); + h.grid.setColumnVisible("b", false); + }); + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "c", pinned: null, hidden: false }, + { id: "b", pinned: null, hidden: true }, + ]); + + fireEvent.click(h.reset()); + + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "c", pinned: null, hidden: false }, + ]); + }); +}); + +/* ---- Task 8: the per-row pin menu -------------------------------------- */ + +/** Open a row's kebab the way a pointer does: pointerdown, then click. */ +function openKebab(button: HTMLElement) { + fireEvent.pointerDown(button); + fireEvent.click(button); +} + +describe("columns section pin menu", () => { + it("opens a role=menu with Pin left / Pin right / Unpin, the current state disabled", () => { + const h = mountColumnsSection(); + const kebab = h.kebabFor("Bravo")!; + expect(kebab).toHaveAttribute("aria-haspopup", "menu"); + expect(kebab).toHaveAttribute("aria-expanded", "false"); + + openKebab(kebab); + + expect(kebab).toHaveAttribute("aria-expanded", "true"); + const menu = h.menu()!; + expect(menu).not.toBeNull(); + expect(menu).toHaveAttribute("role", "menu"); + // The popover styling contract: portal box surface + menu container. + expect(menu.hasAttribute("data-pretable-popover")).toBe(true); + // Portaled, not inline: OverlayPortal mounts into document.body because + // the viewport's `contain: content` traps AND clips `position: fixed` + // descendants — jsdom cannot see that clipping, so the DOM location is + // the enforceable proxy. An inline render would parent it in the pane. + expect(menu.parentElement).toBe(document.body); + expect( + h.view.container.querySelector("[data-pretable-tool-pane]"), + ).not.toContainElement(menu); + const items = h.menuItems(); + expect(items.map((item) => item.textContent)).toEqual([ + "Pin left", + "Pin right", + "Unpin", + ]); + for (const item of items) { + expect(item).toHaveAttribute("role", "menuitem"); + } + // Bravo is unpinned, so Unpin is the current state. + expect(items.map((item) => item.disabled)).toEqual([false, false, true]); + }); + + it("disables the matching pin item for an already-pinned column", () => { + const h = mountColumnsSection(); + openKebab(h.kebabFor("Alpha")!); + // Alpha is pinned left. + expect(h.menuItems().map((item) => item.disabled)).toEqual([ + true, + false, + false, + ]); + }); + + it("Pin right calls setColumnPinned, moves the row to the Pinned-right subgroup, keeps a hidden column hidden, and refocuses the kebab", () => { + const h = mountColumnsSection(); + // Hidden first: pinning must not reveal the column. + act(() => h.grid.setColumnVisible("b", false)); + + openKebab(h.kebabFor("Bravo")!); + fireEvent.click( + h.menuItems().find((item) => item.textContent === "Pin right")!, + ); + + expect(h.engineLayout()).toContainEqual({ + id: "b", + pinned: "right", + hidden: true, + }); + // The row now lists under Pinned right — after the group label, with the + // still-hidden marking intact. + expect(h.listSequence()).toEqual([ + "Pinned left", + "Alpha", + "Columns", + "Charlie", + "Pinned right", + "Bravo", + "Delta", + ]); + expect( + h.rowByLabel("Bravo")!.getAttribute("data-pretable-column-hidden"), + ).toBe("true"); + // Selecting closes the menu and hands focus back to the (remounted) kebab. + expect(h.menu()).toBeNull(); + expect(h.kebabFor("Bravo")).toHaveFocus(); + expect(h.kebabFor("Bravo")).toHaveAttribute("aria-expanded", "false"); + }); + + it("Unpin returns a pinned column to the unpinned subgroup", () => { + const h = mountColumnsSection(); + openKebab(h.kebabFor("Delta")!); + fireEvent.click( + h.menuItems().find((item) => item.textContent === "Unpin")!, + ); + expect(h.engineLayout()).toContainEqual({ + id: "d", + pinned: null, + hidden: false, + }); + expect(h.groupLabels()).toEqual(["Pinned left", "Columns"]); + }); + + it("Escape closes, refocuses the kebab, and does NOT yank focus to the rail tab", () => { + const h = mountColumnsSection(); + const kebab = h.kebabFor("Bravo")!; + openKebab(kebab); + const focused = document.activeElement as HTMLElement; + expect(focused).toHaveAttribute("data-pretable-menu-item"); + + fireEvent.keyDown(focused, { key: "Escape" }); + + expect(h.menu()).toBeNull(); + expect(kebab).toHaveFocus(); + expect(kebab).toHaveAttribute("aria-expanded", "false"); + // The pane's own Escape handler checks defaultPrevented; had the menu not + // prevented it, focus would have landed here instead of on the kebab. + expect(h.view.getByRole("tab", { name: "Columns" })).not.toHaveFocus(); + }); + + it("focuses the first enabled item on open and roves with ArrowDown/ArrowUp, skipping the disabled item", () => { + const h = mountColumnsSection(); + openKebab(h.kebabFor("Bravo")!); + const [pinLeft, pinRight, unpin] = h.menuItems(); + expect(pinLeft).toHaveFocus(); + expect(unpin!.disabled).toBe(true); + + fireEvent.keyDown(pinLeft!, { key: "ArrowDown" }); + expect(pinRight).toHaveFocus(); + // Wraps past the disabled Unpin back to the top. + fireEvent.keyDown(pinRight!, { key: "ArrowDown" }); + expect(pinLeft).toHaveFocus(); + fireEvent.keyDown(pinLeft!, { key: "ArrowUp" }); + expect(pinRight).toHaveFocus(); + }); + + it("follows its kebab through a scroll of the list box, and closes when the anchor scrolls off-screen", () => { + // jsdom has no layout, so the kebab reports its own rects: a real one at + // open, a moved one after "scrolling". Non-zero sizes on purpose — the + // popover machinery treats 0x0 as "cannot measure, do not act". + const h = mountColumnsSection(); + const kebab = h.kebabFor("Bravo")!; + kebab.getBoundingClientRect = () => new DOMRect(300, 100, 24, 24); + openKebab(kebab); + expect(h.menu()!.style.top).toBe("128px"); // rect.bottom (124) + 4 gap + + // The list box scrolls; the row (and its kebab) is now higher up. Scroll + // does not bubble, so this only reaches a capture-phase window listener. + kebab.getBoundingClientRect = () => new DOMRect(300, 60, 24, 24); + const section = h.view.container.querySelector( + "[data-pretable-tool-section]", + )!; + fireEvent.scroll(section); + + // The menu re-anchored instead of drifting (or closing mid-scroll). + expect(h.menu()).not.toBeNull(); + expect(h.menu()!.style.top).toBe("88px"); + + // Scrolled clean out of the window: nothing left to point at — close. + kebab.getBoundingClientRect = () => new DOMRect(300, 2000, 24, 24); + fireEvent.scroll(section); + expect(h.menu()).toBeNull(); + expect(kebab).toHaveAttribute("aria-expanded", "false"); + }); + + it("searching the open row out closes the menu; clearing the search does not resurrect it", () => { + const h = mountColumnsSection(); + openKebab(h.kebabFor("Bravo")!); + expect(h.menu()).not.toBeNull(); + + fireEvent.change(h.search(), { target: { value: "delta" } }); + // The row is gone from the list, so a menu for it has nothing to anchor + // to — and its STATE is cleared, not just its rendering suppressed. + expect(h.menu()).toBeNull(); + + fireEvent.change(h.search(), { target: { value: "" } }); + // No zombie remount at a stale rect, no focus steal. + expect(h.menu()).toBeNull(); + expect(h.kebabFor("Bravo")).toHaveAttribute("aria-expanded", "false"); + expect(h.kebabFor("Bravo")).not.toHaveFocus(); + }); + + it("closes on an outside pointerdown without stealing focus", () => { + const h = mountColumnsSection(); + openKebab(h.kebabFor("Bravo")!); + expect(h.menu()).not.toBeNull(); + + fireEvent.pointerDown(document.body); + + expect(h.menu()).toBeNull(); + expect(h.kebabFor("Bravo")).not.toHaveFocus(); + }); +}); + +/* ---- Task 9: drag reorder + keyboard alternative ------------------------ */ + +/** The row's drag handle, focusable since Task 9. */ +function gripFor( + h: ReturnType, + label: string, +): HTMLElement { + const grip = h + .rowByLabel(label) + ?.querySelector("[data-pretable-tool-row-grip]") as HTMLElement | null; + if (!grip) throw new Error(`No grip rendered for ${label}`); + return grip; +} + +describe("columns section reorder", () => { + it("makes each grip a focusable button with the chord announced", () => { + const h = mountColumnsSection(); + const grip = gripFor(h, "Bravo"); + expect(grip).toHaveAttribute("role", "button"); + expect(grip).toHaveAttribute("tabindex", "0"); + expect(grip).toHaveAccessibleName("Reorder Bravo"); + expect(grip).toHaveAttribute( + "aria-keyshortcuts", + "Shift+ArrowUp Shift+ArrowDown", + ); + }); + + it("Shift+ArrowDown swaps the row with its in-group neighbor on the ENGINE and refocuses its grip", () => { + const h = mountColumnsSection(); + const grip = gripFor(h, "Bravo"); + grip.focus(); + + fireEvent.keyDown(grip, { key: "ArrowDown", shiftKey: true }); + + expect(h.engineLayout().map((e) => e.id)).toEqual(["a", "c", "b", "d"]); + expect(h.rowLabels()).toEqual(["Alpha", "Charlie", "Bravo", "Delta"]); + // The drawn grid follows — the feature, not just the panel's list. + expect(h.drawnHeaderIds()).toEqual(["a", "c", "b", "d"]); + expect(gripFor(h, "Bravo")).toHaveFocus(); + }); + + it("Shift+ArrowUp at a subgroup boundary re-pins into the group above, landing last of it", () => { + const h = mountColumnsSection(); + const grip = gripFor(h, "Bravo"); + grip.focus(); + + fireEvent.keyDown(grip, { key: "ArrowUp", shiftKey: true }); + + expect(h.engineLayout()).toContainEqual({ + id: "b", + pinned: "left", + hidden: false, + }); + expect(h.listSequence()).toEqual([ + "Pinned left", + "Alpha", + "Bravo", + "Columns", + "Charlie", + "Pinned right", + "Delta", + ]); + expect(gripFor(h, "Bravo")).toHaveFocus(); + }); + + it("Shift+ArrowUp on a pinned-right row re-pins it to the unpinned group, landing last of it", () => { + const h = mountColumnsSection(); + const grip = gripFor(h, "Delta"); + grip.focus(); + + fireEvent.keyDown(grip, { key: "ArrowUp", shiftKey: true }); + + expect(h.engineLayout()).toContainEqual({ + id: "d", + pinned: null, + hidden: false, + }); + expect(h.listSequence()).toEqual([ + "Pinned left", + "Alpha", + "Columns", + "Bravo", + "Charlie", + "Delta", + ]); + }); + + it("Shift+ArrowDown from the bottom of Pinned-left lands FIRST of the unpinned group", () => { + const h = mountColumnsSection(); + const grip = gripFor(h, "Alpha"); + grip.focus(); + + fireEvent.keyDown(grip, { key: "ArrowDown", shiftKey: true }); + + // orderPinnedColumns' stable partition: the unpinned columns all FOLLOW + // the ex-pinned row in layout order, so unpinning drops it in ahead of + // them — first of the group below, mirroring ArrowUp's last-of-above. + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: null, hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "c", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + expect(h.listSequence()).toEqual([ + "Columns", + "Alpha", + "Bravo", + "Charlie", + "Pinned right", + "Delta", + ]); + expect(gripFor(h, "Alpha")).toHaveFocus(); + }); + + it("crosses a boundary while a search filter is active — filtered-out ids keep their places", () => { + const h = mountColumnsSection(); + // "l" matches Alpha, Charlie, Delta; Bravo is filtered OUT, so the + // rendered neighbor above Charlie is Alpha, across the pin boundary. + fireEvent.change(h.search(), { target: { value: "l" } }); + expect(h.rowLabels()).toEqual(["Alpha", "Charlie", "Delta"]); + + const grip = gripFor(h, "Charlie"); + grip.focus(); + fireEvent.keyDown(grip, { key: "ArrowUp", shiftKey: true }); + + // Charlie re-pins left and lands LAST of that group; the unrendered + // Bravo keeps its slot in the unpinned group untouched. + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "c", pinned: "left", hidden: false }, + { id: "b", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + }); + + it("does not wrap or commit at the list's ends", () => { + const h = mountColumnsSection(); + const before = h.engineLayout(); + + const first = gripFor(h, "Alpha"); + first.focus(); + fireEvent.keyDown(first, { key: "ArrowUp", shiftKey: true }); + const last = gripFor(h, "Delta"); + last.focus(); + fireEvent.keyDown(last, { key: "ArrowDown", shiftKey: true }); + + expect(h.engineLayout()).toEqual(before); + }); + + it("moves a HIDDEN row like any other — hidden ids ride the order write", () => { + const h = mountColumnsSection(); + act(() => h.grid.setColumnVisible("c", false)); + + const grip = gripFor(h, "Charlie"); + grip.focus(); + fireEvent.keyDown(grip, { key: "ArrowUp", shiftKey: true }); + + expect(h.engineLayout()).toEqual([ + { id: "a", pinned: "left", hidden: false }, + { id: "c", pinned: null, hidden: true }, + { id: "b", pinned: null, hidden: false }, + { id: "d", pinned: "right", hidden: false }, + ]); + }); + + it("ignores arrows without the Shift modifier", () => { + const h = mountColumnsSection(); + const before = h.engineLayout(); + const grip = gripFor(h, "Bravo"); + grip.focus(); + + fireEvent.keyDown(grip, { key: "ArrowDown" }); + + expect(h.engineLayout()).toEqual(before); + }); + + it("marks the row while a pointer drag is in flight, draws the indicator, and mutates NOTHING until drop", () => { + const h = mountColumnsSection(); + const before = h.engineLayout(); + const grip = gripFor(h, "Bravo"); + const row = h.rowByLabel("Bravo")!; + + fireEvent.pointerDown(grip, { + button: 0, + pointerId: 1, + clientX: 10, + clientY: 10, + }); + // Under the 5px threshold: still a press, not a drag. + fireEvent.pointerMove(grip, { pointerId: 1, clientX: 10, clientY: 12 }); + expect(row.hasAttribute("data-pretable-tool-row-dragging")).toBe(false); + + // Past the threshold. jsdom rects are all 0×0, so the pure function + // (the tested geometry authority) resolves "after the last row" — the + // point here is the drag STATE, not the target. + fireEvent.pointerMove(grip, { pointerId: 1, clientX: 10, clientY: 60 }); + expect(row.hasAttribute("data-pretable-tool-row-dragging")).toBe(true); + expect( + h.view.container.querySelector("[data-pretable-tool-drop-indicator]"), + ).not.toBeNull(); + // Commit on drop, never mid-drag. + expect(h.engineLayout()).toEqual(before); + + // A cancel abandons the gesture: state cleared, engine untouched. + fireEvent.pointerCancel(grip, { pointerId: 1 }); + expect(row.hasAttribute("data-pretable-tool-row-dragging")).toBe(false); + expect( + h.view.container.querySelector("[data-pretable-tool-drop-indicator]"), + ).toBeNull(); + expect(h.engineLayout()).toEqual(before); + }); + + it("captures the pointer AT pointerdown, not after the threshold", () => { + // Load-bearing on a ~16px handle: engines rAF-coalesce pointermoves, so + // the first delivered move can already be outside the grip — a capture + // taken in the move handler never happens and the drag silently dies. + // jsdom has no setPointerCapture, so the timing is pinned with a spy. + const h = mountColumnsSection(); + const grip = gripFor(h, "Bravo"); + const capture = vi.fn(); + ( + grip as HTMLElement & { setPointerCapture: typeof capture } + ).setPointerCapture = capture; + + fireEvent.pointerDown(grip, { + button: 0, + pointerId: 7, + clientX: 10, + clientY: 10, + }); + + expect(capture).toHaveBeenCalledWith(7); + }); + + it("Escape mid-drag cancels: state cleared, indicator gone, engine untouched — and the drop never commits", () => { + const h = mountColumnsSection(); + const before = h.engineLayout(); + const grip = gripFor(h, "Bravo"); + const row = h.rowByLabel("Bravo")!; + + fireEvent.pointerDown(grip, { + button: 0, + pointerId: 1, + clientX: 10, + clientY: 10, + }); + fireEvent.pointerMove(grip, { pointerId: 1, clientX: 10, clientY: 60 }); + expect(row.hasAttribute("data-pretable-tool-row-dragging")).toBe(true); + + fireEvent.keyDown(document, { key: "Escape" }); + + expect(row.hasAttribute("data-pretable-tool-row-dragging")).toBe(false); + expect( + h.view.container.querySelector("[data-pretable-tool-drop-indicator]"), + ).toBeNull(); + expect(h.engineLayout()).toEqual(before); + + // The release that ends the abandoned gesture must not resurrect it. + fireEvent.pointerUp(grip, { pointerId: 1, clientX: 10, clientY: 60 }); + expect(h.engineLayout()).toEqual(before); + }); + + it("starting a drag on a grip closes an open pin menu via the outside-pointerdown path", () => { + const h = mountColumnsSection(); + fireEvent.click(h.kebabFor("Charlie")!); + expect(h.menu()).not.toBeNull(); + + fireEvent.pointerDown(gripFor(h, "Bravo"), { + button: 0, + pointerId: 1, + clientX: 10, + clientY: 10, + }); + + expect(h.menu()).toBeNull(); + }); +}); diff --git a/packages/react/src/icons.tsx b/packages/react/src/icons.tsx index 127b279f9..1e95a9842 100644 --- a/packages/react/src/icons.tsx +++ b/packages/react/src/icons.tsx @@ -1,5 +1,5 @@ /** - * The grid's icon set. Eleven glyphs on one 16px grid, 1.5px stroke, rounded + * The grid's icon set. Twelve glyphs on one 16px grid, 1.5px stroke, rounded * caps and joins, drawn in `currentColor` and sized from `--pretable-icon-size`. * * Deliberately not a dependency: the whole set is a few hundred bytes, and an @@ -147,6 +147,18 @@ export function OverflowIcon(props: IconProps) { ); } +/* The tool rail's columns tab. Three vertical bars — a table read column-wise. + Full-height strokes rather than a boxed table glyph: at 16px a 1.5px frame + around three 1.5px dividers muddies, and the rail tab only has to say + "columns", not "grid". */ +export function ColumnsIcon(props: IconProps) { + return ( + + + + ); +} + export function GripIcon(props: IconProps) { return ( diff --git a/packages/react/src/pretable-model.ts b/packages/react/src/pretable-model.ts index 4fa5e75cd..4c731a9e9 100644 --- a/packages/react/src/pretable-model.ts +++ b/packages/react/src/pretable-model.ts @@ -141,6 +141,13 @@ export type PretableReactGrid< columnId: TColumnId, pinned: "left" | "right" | null, ) => void; + /** + * Show or hide a layout column. Declared here for the same reason + * {@link getCellSelectionSummary} is: the runtime object reaches it through + * the grid core's prototype, so leaving it off this type would strand a + * runtime-reachable method behind a cast and keep it out of `react.api.md`. + */ + readonly setColumnVisible: (columnId: TColumnId, visible: boolean) => void; readonly setColumnOrder: (columnIds: readonly TColumnId[]) => void; readonly autosizeColumns: () => void; /** Reports a measured visible-row height to the indexed layout. */ @@ -294,6 +301,7 @@ function mergeRenderColumns( readonly id: string; readonly widthPx: number; readonly pinned?: "left" | "right"; + readonly hidden?: boolean; }[], autoWidthIds: ReadonlySet, ): readonly DomLayoutColumn[] { @@ -301,16 +309,24 @@ function mergeRenderColumns( const hasSameIds = layout.length === columns.length && layout.every((entry) => byId.has(entry.id)); - const effectiveLayout = hasSameIds - ? layout - : columns.map( - (column) => - layout.find((entry) => entry.id === column.id) ?? { - id: column.id, - widthPx: column.widthPx ?? 160, - ...(column.pinned === undefined ? {} : { pinned: column.pinned }), - }, - ); + const effectiveLayout = ( + hasSameIds + ? layout + : columns.map( + (column) => + layout.find((entry) => entry.id === column.id) ?? { + id: column.id, + widthPx: column.widthPx ?? 160, + ...(column.pinned === undefined ? {} : { pinned: column.pinned }), + }, + ) + ) + // DRAWN columns only: this feeds the row-layout controller and, through + // it, every rendered header and body cell. A hidden column stays in the + // engine layout (width and pin persist) but must not paint or contribute + // to row-height estimation. Present only when `true`, so truthiness, not + // a comparison against `false`. + .filter((entry) => (entry as { hidden?: boolean }).hidden !== true); return effectiveLayout.map((entry) => { const presentation = byId.get(entry.id); if (presentation === undefined) { @@ -618,6 +634,8 @@ export function usePretableModelInternal< const grid = useMemo(() => { const setQuery = (query: PretableQueryFor) => { + // ALL columns, hidden included: `setColumnOrder` demands the full + // layout roster, and this is a no-op replay of it, not a reorder. const currentLayout = stores.gridCore.getState().columnLayout; stores.gridCore.setColumnOrder(currentLayout.map((column) => column.id)); const callback = queryChangeChannel.get(); @@ -803,6 +821,9 @@ export function usePretableModelInternal< if (!sameIds) { stores.gridCore.setColumns(columns); } else if (previousOrder.some((id, index) => id !== nextOrder[index])) { + // ALL columns: visibility is engine state, not a prop, so the prop + // roster names every layout column — hidden ones included — which is + // exactly what `setColumnOrder` demands. stores.gridCore.setColumnOrder(nextOrder); } for (const column of columns) { diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index c4f6cac6d..4bd2b4528 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -112,8 +112,15 @@ import { getPositionedCellStyle, getRowStyle, getScrollContentStyle, + getToolPanelGridAreaStyle, + getToolPanelLayoutStyle, getViewportStyle, } from "./styles"; +import { ColumnsSection, ToolPanel } from "./tool-panel"; +import type { + ToolPanelSectionDescriptor, + ToolPanelSectionId, +} from "./tool-panel"; import { findParentGroupRow } from "./group-model"; import { GroupRow } from "./group-row"; import { GroupPanel } from "./group-panel/GroupPanel"; @@ -362,7 +369,13 @@ import { } from "./paste"; import { parseDraftForType } from "./editors/type-parsing"; import { deriveRowChange } from "./row-change"; -import { CheckIcon, MinusIcon, SortAscIcon, SortDescIcon } from "./icons"; +import { + CheckIcon, + ColumnsIcon, + MinusIcon, + SortAscIcon, + SortDescIcon, +} from "./icons"; import { compileNumberFormatters, formatDataCellValue, @@ -591,6 +604,30 @@ export interface PretableSurfaceMessages { emptyStateMessage?: () => string; loadingStateMessage?: () => string; dataErrorAnnouncement?: (args: { message?: string }) => string; + /** Accessible name for the tool panel's rail (`role="tablist"`). */ + toolPanelLabel?: () => string; + /** The columns section's tab label — its `aria-label` and tooltip text. */ + toolPanelColumnsLabel?: () => string; +} + +/** + * Configuration for the tool panel — the rail of section tabs docked at the + * grid's right edge and the pane a selected tab opens. The panel itself is on + * by default; pass `toolPanel={false}` to remove it, or this object to + * control which section is open. + * + * `activeSection` present (including `null`, which means "open nothing") + * makes the open section fully controlled — tab clicks then only report + * through `onActiveSectionChange`, mirroring how {@link + * PretableSurfaceSharedProps.state} asserts and `onSelectionChange` reports. + * Absent, the surface owns the state, seeded by `defaultActiveSection`. + * + * @public + */ +export interface PretableToolPanelConfig { + readonly defaultActiveSection?: ToolPanelSectionId | null; + readonly activeSection?: ToolPanelSectionId | null; + readonly onActiveSectionChange?: (section: ToolPanelSectionId | null) => void; } const defaultMessages: Required = { @@ -642,6 +679,8 @@ const defaultMessages: Required = { loadingStateMessage: () => "Loading…", dataErrorAnnouncement: ({ message }) => message ? `Could not load results. ${message}` : "Could not load results", + toolPanelLabel: () => "Tool panel", + toolPanelColumnsLabel: () => "Columns", }; const ANNOUNCE_DEBOUNCE_MS = 500; @@ -996,6 +1035,16 @@ export interface PretableSurfaceSharedProps< * enabling it never reflows the surrounding layout. */ groupPanel?: { enabled: boolean; emptyMessage?: string }; + /** + * The tool panel — a rail of section tabs at the grid's right edge whose + * selected tab opens a full-height pane. **On by default** (`true`): the + * rail shows with no section open. `false` removes rail and pane both. + * + * The rail consumes width from the surface's own box rather than adding to + * it, the horizontal twin of {@link PretableSurfaceSharedProps.groupPanel}'s + * height rule — enabling it never reflows the surrounding layout. + */ + toolPanel?: boolean | PretableToolPanelConfig; onGridReady?: (grid: PretableSurfaceGrid) => void; renderBodyCell?: ( input: PretableSurfaceBodyCellInput, @@ -1521,6 +1570,7 @@ export function PretableSurface< rowSelectionColumn, selectFocusedRowOnArrowKey = false, tabBehavior = "exit", + toolPanel = true, viewportStyle, viewportHeight, copyWithHeaders, @@ -1680,9 +1730,38 @@ export function PretableSurface< dataErrorAnnouncement: messages?.dataErrorAnnouncement ?? defaultMessages.dataErrorAnnouncement, + toolPanelLabel: + messages?.toolPanelLabel ?? defaultMessages.toolPanelLabel, + toolPanelColumnsLabel: + messages?.toolPanelColumnsLabel ?? + defaultMessages.toolPanelColumnsLabel, }), [messages], ); + // ---- Tool panel chrome state ------------------------------------------- + const toolPanelEnabled = toolPanel !== false; + const toolPanelConfig = typeof toolPanel === "object" ? toolPanel : null; + // Plain value, deliberately: chrome state, not a disposable model, so the + // StrictMode double-invoke that kills `useState`-held engines (see + // `useDisposeOnUnmount`) has nothing here to kill. + const [uncontrolledToolSection, setUncontrolledToolSection] = + useState( + () => toolPanelConfig?.defaultActiveSection ?? null, + ); + // `activeSection` PRESENT — `null` included, which means "hold it closed" — + // is what makes the open section controlled; `defaultActiveSection` only + // seeds the internal state above. The `state`/`onSelectionChange` pairing + // is the precedent: assertion through the prop, reporting through the + // callback, and in controlled form a tab click mutates nothing here. + const controlledToolSection = toolPanelConfig?.activeSection; + const activeToolSection = + controlledToolSection !== undefined + ? controlledToolSection + : uncontrolledToolSection; + // `toolPanelSections` — the descriptor array — is declared further down, + // beside `labelForColumn`: the columns section needs the grid handle and the + // label resolver, neither of which exists yet at this point in the + // component. const measuredRowKeysRef = useRef>({}); const rowNodesRef = useRef>(new Map()); const cellNodesRef = useRef>(new Map()); @@ -1957,6 +2036,23 @@ export function PretableSurface< [presentationQuery, resolveEffectiveColumns], ); const indexedGrid = indexed.grid; + // The tool panel's reset baseline: the engine layout as of the SURFACE's + // first render — already normalized (pins regrouped, synthetic columns + // placed) and already the prop-declared order/pin/visibility, because the + // grid core is seeded from `effectiveColumns` at store creation. Captured + // here rather than in the columns section because the section unmounts with + // the pane; a section-mount capture would adopt whatever mutations preceded + // a reopen as "initial". + const initialColumnLayoutRef = useRef< + readonly Readonly<{ + id: string; + pinned?: "left" | "right"; + hidden?: boolean; + }>[] + >(null); + if (initialColumnLayoutRef.current === null) { + initialColumnLayoutRef.current = indexedGrid.getState().columnLayout; + } // The cell-edit controller owns a token for its UI lifecycle, but explicit // model writes happen inside its awaited commit callback — before the // controller gets a chance to check that token. Keep a surface-side token at @@ -2033,12 +2129,26 @@ export function PretableSurface< } const layout = indexedGrid.getState().columnLayout; if (state.columnOrder !== undefined) { + // The controlled vocabulary is DRAWN: `onColumnOrderChange` reports + // visible ids only, so a round-tripping consumer never names a hidden + // column. The engine's `setColumnOrder` demands the full roster, so the + // hidden ids are spliced back at their prior relative positions. A + // full-roster order (hidden ids included) is also accepted verbatim. const currentIds = new Set(layout.map((column) => column.id as string)); + const visible = layout.filter((column) => column.hidden !== true); + const visibleIds = new Set(visible.map((column) => column.id as string)); if ( state.columnOrder.length === layout.length && state.columnOrder.every((columnId) => currentIds.has(columnId)) ) { indexedGrid.setColumnOrder(state.columnOrder); + } else if ( + state.columnOrder.length === visible.length && + state.columnOrder.every((columnId) => visibleIds.has(columnId)) + ) { + indexedGrid.setColumnOrder( + withHiddenIdsSpliced(layout, state.columnOrder), + ); } } for (const [columnId, width] of Object.entries(state.columnWidths ?? {})) { @@ -2384,7 +2494,12 @@ export function PretableSurface< const byId = new Map( current.columns.map((column) => [column.id, column]), ); + // DRAWN columns: this is the order copy, CSV export and the reorder + // write-backs resolve spans against, so hidden columns are excluded + // exactly as they are from `drawnColumns`. The full layout — hidden + // included — stays reachable through `getState().columnLayout`. return indexedGrid.getState().columnLayout.flatMap((layout) => { + if (layout.hidden === true) return []; const column = byId.get(layout.id as string); if (column === undefined) return []; return [ @@ -2607,36 +2722,68 @@ export function PretableSurface< setColumnWidth: indexedGrid.setColumnWidth, setColumnPinned: indexedGrid.setColumnPinned, moveColumn(columnId: string, toIndex: number) { + // `toIndex` is a DRAWN-space index: a position among the visible + // columns after the dragged one is removed, which is the only space a + // drop gesture can measure. The engine's `setColumnOrder` demands the + // FULL roster — hidden ids included — so the destination is resolved + // by visible neighbor and the hidden ids ride along at their prior + // relative positions. Unlike the controlled write-back's + // `withHiddenIdsSpliced` (which anchors each hidden id to its visible + // predecessor), a drag moves exactly one column, so every non-moved + // column — hidden included — keeps its absolute relative order. const currentLayout = indexedGrid.getState().columnLayout; - const ids = currentLayout.map((entry) => entry.id); - const from = ids.indexOf(columnId); + const from = currentLayout.findIndex((entry) => entry.id === columnId); if (from < 0) return; - const next = ids.slice(); - const [moved] = next.splice(from, 1); - if (moved === undefined) return; - const destination = Math.max(0, Math.min(toIndex, next.length)); - const remaining = currentLayout.filter((entry) => entry.id !== moved); - const leftCount = remaining.filter( + const current = currentLayout[from]!; + // A hidden column is not drawn, so it has no drawn index to move to. + if (current.hidden === true) return; + const remaining = currentLayout.filter( + (entry) => entry.id !== columnId, + ); + const visibleRemaining = remaining.filter( + (entry) => entry.hidden !== true, + ); + const destination = Math.max( + 0, + Math.min(toIndex, visibleRemaining.length), + ); + // Pin inference reads the DRAWN pin zones: a hidden pinned column + // occupies no strip on screen, so it must not widen the zone a drop + // is judged against. + const leftCount = visibleRemaining.filter( (entry) => entry.pinned === "left", ).length; - const rightCount = remaining.filter( + const rightCount = visibleRemaining.filter( (entry) => entry.pinned === "right", ).length; - const current = currentLayout[from]!; const nextPinned = destination < leftCount ? "left" - : rightCount > 0 && destination > remaining.length - rightCount + : rightCount > 0 && + destination > visibleRemaining.length - rightCount ? "right" : current.pinned === "right" && rightCount === 0 && - destination === remaining.length + destination === visibleRemaining.length ? "right" : null; if ((current.pinned ?? null) !== nextPinned) { - indexedGrid.setColumnPinned(moved, nextPinned); + indexedGrid.setColumnPinned(columnId, nextPinned); } - next.splice(destination, 0, moved); + // Splice into the FULL order next to the visible neighbor the drop + // named: after the drawn predecessor when there is one, else before + // the drawn successor — "before the first drawn column" must not mean + // "before the hidden ones ahead of it". + const next = remaining.map((entry) => entry.id); + const predecessor = visibleRemaining[destination - 1]; + const successor = visibleRemaining[destination]; + const insertAt = + predecessor !== undefined + ? next.indexOf(predecessor.id) + 1 + : successor !== undefined + ? next.indexOf(successor.id) + : next.length; + next.splice(insertAt, 0, columnId); indexedGrid.setColumnOrder(next); }, beginEdit( @@ -3089,6 +3236,33 @@ export function PretableSurface< columnId, [authoritativeColumns], ); + // The tool panel's section descriptors. The deps are honest and HANDLES + // only, never engine state: `indexedGrid` and `initialColumnLayoutRef` are + // stable for the model's lifetime, `labelForColumn` changes identity exactly + // when the `columns` prop does (which is when labels can change), and + // `effectiveMessages` when the messages prop does. Nothing here closes over + // a layout snapshot — the columns section subscribes to the engine itself + // (via `useSyncExternalStore` on the layout slice), so a memoized descriptor + // can never hand it stale state. That is the Task 6 review's stale-closure + // trap, kept fixed: if a future section needs engine state, it must read it + // through its own subscription, not through a value baked in here. + const toolPanelSections = useMemo( + () => [ + { + id: "columns", + icon: ColumnsIcon, + label: effectiveMessages.toolPanelColumnsLabel(), + render: () => ( + + ), + }, + ], + [effectiveMessages, indexedGrid, labelForColumn], + ); // Shared by the data-row and group-row cell refs: the focus-follow effect // looks a cell up by `rowId::columnId`, and a group cell that never // registered would leave DOM focus stranded on an unmounted data cell. @@ -3146,6 +3320,11 @@ export function PretableSurface< const byId = new Map(effectiveColumns.map((column) => [column.id, column])); return indexedSnapshot.columnLayout.flatMap>( (layout) => { + // Hidden columns stay in the engine layout (width and pin persist) + // but are NOT drawn — filtering here is what makes every span + // consumer below inherit that at once. `hidden` is present only when + // `true`, hence truthiness rather than a `=== false` comparison. + if (layout.hidden === true) return []; const column = byId.get(layout.id as string); return column === undefined ? [] @@ -3806,18 +3985,19 @@ export function PretableSurface< return map; }, [drawnColumns, effectiveColumns]); - // One plan over the whole engine column set, shared by the two features that + // One plan over the whole DRAWN column set, shared by the two features that // need to reason about columns `renderSnapshot.columns` does not carry: // reorder hit-testing (a scrolled-out column is still a legitimate drop // target) and scroll-into-view (an off-window column is the only reason it // runs). Both want identical geometry, so they read the same object rather // than each deriving one — see `planColumnLayout` for why that matters. - // Content order, and each entry's `index` is its engine index — what + // Drawn order, and each entry's `index` is its drawn-space index — what // grid.moveColumn takes. // Planned from the DRAWN columns, because both consumers compare it against // rendered pixels: a plan built from `options.columns` while grouped would // miss the group column entirely and put every other column's `left` a - // group-column width away from where it is painted. + // group-column width away from where it is painted — and a hidden column + // paints nothing, so it must not occupy a strip of the plan either. const columnLayout = useMemo( () => planColumnLayout([...drawnColumns]), [drawnColumns], @@ -6510,18 +6690,18 @@ export function PretableSurface<
); - // Without the panel the surface IS the scroll viewport — no wrapper, so a - // consumer's DOM, CSS selectors and layout are untouched by SP3 existing. - if (!groupPanelEnabled) { - return viewportWithDataState; - } - + // Without the group panel the vertical stack IS the scroll viewport — no + // wrapper, so a consumer's DOM, CSS selectors and layout are untouched by + // SP3 existing. + // // With it, the viewport keeps every attribute it had and gains a parent. The // panel cannot live inside the viewport: that element carries // `role="grid"`/`"treegrid"` (whose children must be rows and rowgroups, so a // listbox of chips there is invalid ARIA) and `minWidth: totalWidth` on its // content, which would scroll the panel sideways with the data. - return ( + const verticalStack = !groupPanelEnabled ? ( + viewportWithDataState + ) : (
); + + if (!toolPanelEnabled) { + return verticalStack; + } + + // The tool panel row: [vertical stack][pane?][rail], all inside one wrapper + // that carries the card chrome (grid.css moves the border/radius/shadow up + // from the viewport under `[data-pretable-tool-layout]`) so the docked rail + // sits inside the card rather than hanging off it. The pane and rail arrive + // as fragment siblings from the shell — the surface owns this row's layout. + // Opening the pane narrows the grid area; the viewport's ResizeObserver + // (which re-derives `viewportWidth` from `clientWidth`) reflows the columns + // and the right-pinned insets, exactly as it does for a container resize. + return ( +
+
+ {verticalStack} +
+ { + if (controlledToolSection === undefined) { + setUncontrolledToolSection(next); + } + toolPanelConfig?.onActiveSectionChange?.(next); + }} + /> +
+ ); } /** @@ -7616,6 +7827,10 @@ function handleSurfaceKeyDown( * in the engine array — which does have a slot for everything — says where it * goes. The returned index is in post-removal space, which is what * `moveColumn`'s splice-out-then-splice-in takes. + * + * Hidden columns need no case here: `getColumns()` excludes them, so both + * inputs are drawn-vocabulary lists and `moveColumn` itself owns splicing the + * hidden ids back into the engine's full roster. */ function toEngineDropIndex( drawn: readonly PretableColumn[], @@ -7647,6 +7862,44 @@ function toEngineDropIndex( return 0; } +/** + * Rebuild a FULL-roster column order from a DRAWN-vocabulary one by splicing + * the layout's hidden ids back in at their prior relative positions. + * + * Each hidden id follows the nearest visible column that preceded it in the + * old layout, wherever that column moved to; a hidden id with no visible + * predecessor stays at the front. Runs of hidden columns sharing an anchor + * keep their own relative order. + */ +function withHiddenIdsSpliced( + layout: readonly { + readonly id: string; + readonly hidden?: boolean; + }[], + visibleOrder: readonly string[], +): readonly string[] { + const anchors: { readonly id: string; readonly after: string | null }[] = []; + let lastVisible: string | null = null; + for (const entry of layout) { + if (entry.hidden === true) { + anchors.push({ id: entry.id, after: lastVisible }); + } else { + lastVisible = entry.id; + } + } + const result = [...visibleOrder]; + const inserted = new Set(); + for (const { id, after } of anchors) { + let index = after === null ? 0 : result.indexOf(after) + 1; + // Step over hidden ids already re-seated at this anchor so a run of + // hidden columns lands in its original order, not reversed. + while (index < result.length && inserted.has(result[index]!)) index += 1; + result.splice(index, 0, id); + inserted.add(id); + } + return result; +} + function buildWidthsMap( grid: SurfaceFacade, ): Record { diff --git a/packages/react/src/pretable.tsx b/packages/react/src/pretable.tsx index c837169e6..103f1bf62 100644 --- a/packages/react/src/pretable.tsx +++ b/packages/react/src/pretable.tsx @@ -117,6 +117,10 @@ export interface PretableBaseProps< TColumns >["copyToClipboard"]; messages?: PretableSurfaceSharedProps["messages"]; + /** The tool panel rail at the grid's right edge. On by default — the + * default lives in the surface, so the preset only ever forwards; pass + * `false` to remove it or a config object to control the open section. */ + toolPanel?: PretableSurfaceSharedProps["toolPanel"]; onColumnWidthsChange?: PretableSurfaceSharedProps< TRow, TRowId, diff --git a/packages/react/src/public_api.ts b/packages/react/src/public_api.ts index 91aae096a..4e87b58cf 100644 --- a/packages/react/src/public_api.ts +++ b/packages/react/src/public_api.ts @@ -54,8 +54,10 @@ export type { PretableSurfaceRowInput, PretableSurfaceSharedProps, PretableSurfaceSyntheticColumnId, + PretableToolPanelConfig, RowSelectionColumnConfig, } from "./pretable-surface"; +export type { ToolPanelSectionId } from "./tool-panel"; export type { PretableBodyStateKind, PretableDataState } from "./data-state"; export type { LabeledGridSurfaceBaseProps, diff --git a/packages/react/src/styles.ts b/packages/react/src/styles.ts index 7a869579b..b4ef3a16e 100644 --- a/packages/react/src/styles.ts +++ b/packages/react/src/styles.ts @@ -25,6 +25,34 @@ export function getViewportStyle(height: number): CSSProperties { }; } +/** + * The horizontal row the surface renders when the tool panel is enabled: + * `[vertical grid stack][pane?][rail]`. Stretch (the default cross-axis + * alignment) is what gives the rail and pane the stack's full height without + * either knowing what that height is. The card chrome this row carries — + * border, radius, shadow — is skin, and lives in grid.css under + * `[data-pretable-tool-layout]`. + */ +export function getToolPanelLayoutStyle(): CSSProperties { + return { + display: "flex", + }; +} + +/** + * The vertical grid stack's slot inside the tool-panel row. `minWidth: 0` is + * the load-bearing half: a flex item's automatic minimum is its content's + * min-content size, and the viewport's content carries `minWidth: totalWidth` + * — without the override, opening the pane could not narrow the grid area, + * it would push the rail out of the card instead. + */ +export function getToolPanelGridAreaStyle(): CSSProperties { + return { + flex: "1 1 auto", + minWidth: 0, + }; +} + /** * The box that holds the group panel and the scroll viewport, used only when * the panel is enabled. diff --git a/packages/react/src/tool-panel/ColumnPinMenu.tsx b/packages/react/src/tool-panel/ColumnPinMenu.tsx new file mode 100644 index 000000000..bd174ad0b --- /dev/null +++ b/packages/react/src/tool-panel/ColumnPinMenu.tsx @@ -0,0 +1,118 @@ +import type { CSSProperties } from "react"; +import { useEffect, useRef } from "react"; + +import { OverlayPortal } from "../overlay/OverlayPortal"; + +const PIN_MENU_ITEMS = [ + { pinned: "left", label: "Pin left", action: "pin-left" }, + { pinned: "right", label: "Pin right", action: "pin-right" }, + { pinned: null, label: "Unpin", action: "unpin" }, +] as const; + +/** + * The kebab's popover: three pin placements, the row's current one disabled. + * + * Portaled for the same reason every popover here is — the grid viewport's + * `contain: content` makes it the containing block for `position: fixed` + * descendants AND clips them, so an inline menu would be trapped inside the + * pane's scroll box. It reuses the header ColumnMenu's attribute contract + * (`data-pretable-popover` + `data-pretable-column-menu` on the container, + * `data-pretable-menu-item` on items) so grid.css styles it with zero new + * rules. + * + * Focus return is the CALLER's job, via `onClose`/`onSelect`: a pin change + * moves the row across subgroup fragments, remounting the kebab, so only the + * section (with its per-id node map) can find the button again. + */ +export function ColumnPinMenu({ + columnId, + label, + pinned, + style, + onSelect, + onClose, +}: { + columnId: string; + label: string; + pinned: "left" | "right" | null; + style?: CSSProperties; + onSelect: (pinned: "left" | "right" | null) => void; + /** `restoreFocus` false only for outside clicks, which chose a new target. */ + onClose: (restoreFocus: boolean) => void; +}) { + const rootRef = useRef(null); + + const enabledItems = () => + Array.from( + rootRef.current?.querySelectorAll( + "[data-pretable-menu-item]:not(:disabled)", + ) ?? [], + ); + + // A menu opened from a button owns the focus while it is up. The first + // ENABLED item: the current pin state is disabled and may well be first. + useEffect(() => { + enabledItems()[0]?.focus(); + }, []); + + // Outside-click → close. No focus return: the click is already moving focus + // somewhere the user chose, and yanking it back would fight that. + useEffect(() => { + const onPointerDown = (e: PointerEvent) => { + const root = rootRef.current; + if (root && e.target instanceof Node && !root.contains(e.target)) { + onClose(false); + } + }; + document.addEventListener("pointerdown", onPointerDown); + return () => document.removeEventListener("pointerdown", onPointerDown); + }, [onClose]); + + return ( + +
{ + if (event.key === "Escape" || event.key === "Esc") { + // `preventDefault`, NOT `stopPropagation`: the pane's own Escape + // handler (which yanks focus to the rail tab) skips events that + // are defaultPrevented — that check is the designed interlock, + // and the portal still bubbles through the React tree to it. + event.preventDefault(); + onClose(true); + return; + } + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const items = enabledItems(); + if (items.length === 0) return; + const index = items.indexOf( + document.activeElement as HTMLButtonElement, + ); + const delta = event.key === "ArrowDown" ? 1 : -1; + items[(index + delta + items.length) % items.length]?.focus(); + }} + > + {PIN_MENU_ITEMS.map((item) => ( + + ))} +
+
+ ); +} diff --git a/packages/react/src/tool-panel/ColumnsSection.tsx b/packages/react/src/tool-panel/ColumnsSection.tsx new file mode 100644 index 000000000..11b3b5a04 --- /dev/null +++ b/packages/react/src/tool-panel/ColumnsSection.tsx @@ -0,0 +1,651 @@ +import type { RefObject } from "react"; +import { + Fragment, + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from "react"; + +import { GROUP_COLUMN_ID } from "@pretable/core"; + +import { ROW_SELECT_COLUMN_ID } from "../constants"; +import { CheckIcon, GripIcon, OverflowIcon } from "../icons"; +import { popoverStyle } from "../overlay/popover-position"; +import { useHeaderPopover } from "../overlay/useHeaderPopover"; +import { ColumnPinMenu } from "./ColumnPinMenu"; +import type { ToolDropTarget, ToolRowRect } from "./tool-panel-drop-target"; +import { dropTargetForPointer } from "./tool-panel-drop-target"; + +/** + * One `columnLayout` entry, restated structurally rather than imported: the + * section needs only these three fields, and naming them here keeps the + * component free of the engine's generic parameters (the surface's drawn + * column-id vocabulary is plain `string`). + */ +interface ColumnsSectionLayoutEntry { + readonly id: string; + readonly pinned?: "left" | "right"; + /** Present only when `true` — the engine's own encoding. */ + readonly hidden?: boolean; +} + +/** + * The slice of the react grid handle the columns section drives. Structural + * on purpose: the surface hands in its own `indexedGrid` (stable for the + * model's lifetime), and this type is what documents that the section reads + * LIVE engine state through it rather than closing over a snapshot — the + * Task 6 review's stale-closure trap. + */ +export interface ColumnsSectionGrid { + readonly subscribe: (listener: () => void) => () => void; + readonly getState: () => { + readonly columnLayout: readonly ColumnsSectionLayoutEntry[]; + }; + readonly setColumnVisible: (columnId: string, visible: boolean) => void; + readonly setColumnPinned: ( + columnId: string, + pinned: "left" | "right" | null, + ) => void; + readonly setColumnOrder: (columnIds: readonly string[]) => void; +} + +export interface ColumnsSectionProps { + readonly grid: ColumnsSectionGrid; + /** + * Label projection for schema columns — the surface's `labelForColumn` + * (header ?? id, same resolver the group panel's chips use). Props-derived, + * so it changes identity exactly when the `columns` prop does; the derived + * group/selection columns never reach it because the section excludes them. + */ + readonly labelForColumn: (columnId: string) => string; + /** + * The layout captured once at SURFACE mount — not at section mount, because + * the section unmounts whenever the pane closes and would otherwise adopt + * whatever mutations preceded its reopen as the baseline. A ref rather than + * a value so the capture-once semantics live with the surface; by the time + * any section can render, the surface's first render has filled it. + */ + readonly initialLayoutRef: RefObject< + readonly ColumnsSectionLayoutEntry[] | null + >; +} + +const PIN_GROUPS = [ + { pinned: "left", label: "Pinned left" }, + { pinned: undefined, label: "Columns" }, + { pinned: "right", label: "Pinned right" }, +] as const; + +/** Same slop the header drag uses before a press becomes a reorder. */ +const DRAG_THRESHOLD_PX = 5; + +/** A press on a grip that has not (yet) crossed the drag threshold. */ +interface PendingRowDrag { + columnId: string; + pointerId: number; + startX: number; + startY: number; + dragging: boolean; +} + +/** The in-flight drag the render reflects: dim the row, draw the line. */ +interface ActiveRowDrag { + readonly columnId: string; + readonly target: ToolDropTarget; +} + +/** + * The tool panel's columns section: the full `columnLayout` roster — hidden + * entries included, because this pane is the one place a hidden column stays + * visible — subgrouped by pin state, with visibility toggles, label search + * and a reset to the mount-time layout. The grip and kebab complete the row + * anatomy but are inert here: drag-reorder is Task 9's, the menu Task 8's. + */ +export function ColumnsSection({ + grid, + labelForColumn, + initialLayoutRef, +}: ColumnsSectionProps) { + // Live engine state, read through the section's OWN subscription — never a + // snapshot baked into the descriptor closure. The read returns the state's + // `columnLayout` array, whose identity only changes on a layout publish, so + // unrelated publishes (focus, selection, every scroll tick) bail in + // useSyncExternalStore's equality check instead of re-rendering the pane. + const readLayout = useCallback(() => grid.getState().columnLayout, [grid]); + const layout = useSyncExternalStore(grid.subscribe, readLayout, readLayout); + + // Local, deliberately: the section unmounts when the pane closes, and a + // reopened pane starting from an empty search is the expected behavior. + const [query, setQuery] = useState(""); + + // The open pin menu. The header popovers' own hook, reused rather than + // re-plumbed: the section's rows live in a scrollable pane, so a frozen + // open-time rect would leave the `position: fixed` menu drifting the moment + // the list scrolls under it — and wheel scrolling fires neither pointerdown + // nor a focus change, so nothing else would close it. The hook re-measures + // on capture-phase scroll and resize, FOLLOWS the anchor while it is still + // on screen, and closes only when it is genuinely gone (see its comment on + // why close-on-scroll-event was the wrong rule). Only the "menu" kind is + // used here; the hook's state is as local to this section as the query is. + const { + openState: menu, + toggle: toggleMenu, + close: closeMenuState, + } = useHeaderPopover(); + // Per-id kebab nodes, so focus can be handed back AFTER a pin moves the row + // across subgroup fragments and remounts its button — an element reference + // captured at open time is disconnected by then. + const kebabNodesRef = useRef(new Map()); + // A ref, not state: the pending focus is consumed by the commit that the + // pin's own layout publish already scheduled, so no extra render is needed + // (or wanted — setState in an effect is a cascading-render smell). The + // effect runs after every commit and is a no-op unless a select just armed + // it. + const pendingKebabFocusRef = useRef(null); + useEffect(() => { + const id = pendingKebabFocusRef.current; + if (id === null) return; + pendingKebabFocusRef.current = null; + kebabNodesRef.current.get(id)?.focus(); + }); + + const openColumnId = menu?.columnId ?? null; + const closeMenu = useCallback( + (restoreFocus: boolean) => { + closeMenuState(); + if (restoreFocus && openColumnId !== null) { + // Synchronous, not via the pending-focus effect: closing does not + // remount the kebab, and an Escape's focus return must land before + // any later handler in the same dispatch can observe it. + kebabNodesRef.current.get(openColumnId)?.focus(); + } + }, + [closeMenuState, openColumnId], + ); + + const entries = layout + .filter( + (entry) => + entry.id !== GROUP_COLUMN_ID && entry.id !== ROW_SELECT_COLUMN_ID, + ) + .map((entry) => ({ entry, label: labelForColumn(entry.id) })); + const needle = query.trim().toLowerCase(); + const matched = + needle === "" + ? entries + : entries.filter(({ label }) => label.toLowerCase().includes(needle)); + + // The rendered subgroups (empty ones render nothing, so they are no drop + // targets either — pinning into an empty group stays the pin menu's job) + // and the flat row list across them. Both are what the drop geometry and + // the keyboard moves reason over: RENDERED order, which under a search is + // a subsequence of the layout — the commit inserts relative to a rendered + // neighbor id, so filtered-out and derived ids keep their places. + const renderedGroups = PIN_GROUPS.map((group) => ({ + label: group.label, + pinned: (group.pinned ?? null) as "left" | "right" | null, + rows: matched.filter( + ({ entry }) => (entry.pinned ?? undefined) === group.pinned, + ), + })).filter((group) => group.rows.length > 0); + const flatRows = renderedGroups.flatMap((group, groupIndex) => + group.rows.map(({ entry }) => ({ id: entry.id, groupIndex })), + ); + + // Per-id row nodes, measured on every drag move — never cached, for the + // header drag's reason: the pane scrolls without a React render, and a + // stale rect would put the drop a scroll-distance from the pointer. + const rowNodesRef = useRef(new Map()); + // Per-id grips, for handing focus back after a keyboard move remounts the + // row in another subgroup fragment — the kebab map's exact pattern. + const gripNodesRef = useRef(new Map()); + const pendingGripFocusRef = useRef(null); + useEffect(() => { + const id = pendingGripFocusRef.current; + if (id === null) return; + pendingGripFocusRef.current = null; + gripNodesRef.current.get(id)?.focus(); + }); + + // The press-in-progress lives in a ref (every pointermove reads it, most + // discard it under the threshold); only a drag past the threshold becomes + // state, because only then does the render change. + const pendingDragRef = useRef(null); + const [drag, setDrag] = useState(null); + + const measureRows = (): readonly ToolRowRect[] => { + const rects: ToolRowRect[] = []; + for (const row of flatRows) { + const node = rowNodesRef.current.get(row.id); + if (node === undefined) continue; + const rect = node.getBoundingClientRect(); + rects.push({ + id: row.id, + top: rect.top, + height: rect.height, + groupIndex: row.groupIndex, + }); + } + return rects; + }; + + /** + * Apply a finished move: the dragged column joins `groupIndex`'s subgroup + * (pin first, so the engine's stable pin regrouping is what places the + * order) and slots in before the rendered row at `beforeRow`. + * + * `setColumnOrder` demands EVERY layout id exactly once, so the list is + * the live full roster with only the moved id relocated: hidden columns + * are rendered rows here and take part like any other, while search- + * filtered rows and the derived group/selection columns keep their + * relative positions — the settled `moveColumn` semantic, one axis over. + */ + const commitMove = (columnId: string, target: ToolDropTarget) => { + const group = renderedGroups[target.groupIndex]; + if (group === undefined) return; + const live = grid.getState().columnLayout; + const moved = live.find((entry) => entry.id === columnId); + if (moved === undefined) return; + if ((moved.pinned ?? null) !== group.pinned) { + grid.setColumnPinned(columnId, group.pinned); + } + const beforeId = flatRows[target.beforeRow]?.id ?? null; + if (beforeId === columnId) return; // its own slot: the pin was the move + const remaining = grid + .getState() + .columnLayout.map((entry) => entry.id) + .filter((id) => id !== columnId); + let insertAt: number; + if (beforeId === null) { + const lastId = flatRows[flatRows.length - 1]?.id; + insertAt = + lastId === undefined || lastId === columnId + ? remaining.length + : remaining.indexOf(lastId) + 1; + } else { + insertAt = remaining.indexOf(beforeId); + if (insertAt === -1) return; + } + remaining.splice(insertAt, 0, columnId); + grid.setColumnOrder(remaining); + }; + + const endDrag = useCallback(() => { + pendingDragRef.current = null; + setDrag(null); + }, []); + + // Escape mid-drag cancels without engine mutation — the rule BOTH other + // drag surfaces already follow (the header reorder in its keydown, the + // chip drag on a document listener like this one). Nothing has been + // committed at any point during the drag, so abandoning the gesture IS the + // restore. `preventDefault` also tells the pane's own Escape handler + // (which skips defaultPrevented events) not to yank focus to the rail tab + // over a mere gesture cancel — though only when focus sits OUTSIDE the + // pane, since a document bubble listener runs after the React-root + // handler; the chip drag's cancel has the same characteristic. + const dragActive = drag !== null; + useEffect(() => { + if (!dragActive) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" && event.key !== "Esc") return; + event.preventDefault(); + endDrag(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [dragActive, endDrag]); + + /** + * The keyboard half of reorder: Shift+ArrowUp/Down on a focused grip swaps + * the row with its rendered neighbor — the group-chip strip's chord turned + * vertical, and the a11y hard gate's reason drag is not the only path. At + * the list's ends nothing moves and nothing is committed (the chips' rule: + * no wrap). Crossing a rendered subgroup boundary re-pins instead of + * reordering: the row is already order-adjacent to the target group, so + * the engine's stable pin regrouping alone lands it at the near edge — + * last of the group above on ArrowUp, first of the group below on + * ArrowDown — and an order write would be a no-op on top. + */ + const moveByKeyboard = (columnId: string, delta: 1 | -1) => { + const index = flatRows.findIndex((row) => row.id === columnId); + if (index === -1) return; + const neighbor = flatRows[index + delta]; + if (neighbor === undefined) return; + const neighborGroup = renderedGroups[neighbor.groupIndex]; + if (neighborGroup === undefined) return; + const live = grid.getState().columnLayout; + const moved = live.find((entry) => entry.id === columnId); + if (moved === undefined) return; + if ((moved.pinned ?? null) !== neighborGroup.pinned) { + grid.setColumnPinned(columnId, neighborGroup.pinned); + } else { + const remaining = live + .map((entry) => entry.id) + .filter((id) => id !== columnId); + const at = remaining.indexOf(neighbor.id); + if (at === -1) return; + remaining.splice(delta === -1 ? at : at + 1, 0, columnId); + grid.setColumnOrder(remaining); + } + // The move can carry the row into another subgroup fragment, remounting + // its grip — focus is handed back through the per-id map after commit, + // the kebab's exact pattern. + pendingGripFocusRef.current = columnId; + }; + + const reset = () => { + const initial = initialLayoutRef.current; + if (initial === null) return; + const current = grid.getState().columnLayout; + const initialById = new Map(initial.map((entry) => [entry.id, entry])); + const currentIds = new Set(current.map((entry) => entry.id)); + // Pin state first — safe before visibility since unpinning a hidden + // column no longer reveals it — then visibility, then the full-roster + // order LAST, so the engine normalizes the initial order request against + // the already-restored pin groups and reproduces the initial layout + // exactly. Only ids present in both rosters are replayed: a column the + // props added or removed since mount has no initial state to restore. + for (const entry of current) { + const initialEntry = initialById.get(entry.id); + if (initialEntry === undefined) continue; + if ((entry.pinned ?? null) !== (initialEntry.pinned ?? null)) { + grid.setColumnPinned(entry.id, initialEntry.pinned ?? null); + } + if ((entry.hidden === true) !== (initialEntry.hidden === true)) { + grid.setColumnVisible(entry.id, initialEntry.hidden !== true); + } + } + // `setColumnOrder` demands EVERY current layout id. Ids the capture never + // saw (a group column derived since mount) are spliced back at their + // current positions; the engine's pin regrouping places them correctly. + const order = initial + .filter((entry) => currentIds.has(entry.id)) + .map((entry) => entry.id); + current.forEach((entry, index) => { + if (!initialById.has(entry.id)) { + order.splice(Math.min(index, order.length), 0, entry.id); + } + }); + grid.setColumnOrder(order); + }; + + return ( + <> + { + const value = event.target.value; + setQuery(value); + // Searching the open menu's row out of the list unmounts its kebab; + // the menu closes WITH its state, here at the source, so clearing + // the search later cannot remount a zombie menu at a stale rect + // (whose mount effect would steal focus). Handler, not effect: the + // lint rule that polices setState-in-effect does not apply to the + // event that caused the condition. + if (openColumnId !== null) { + const needleNext = value.trim().toLowerCase(); + const stillListed = + needleNext === "" || + labelForColumn(openColumnId).toLowerCase().includes(needleNext); + if (!stillListed) closeMenuState(); + } + }} + placeholder="Search" + type="text" + value={query} + /> + {renderedGroups.map((group, groupIndex) => { + const start = flatRows.findIndex( + (row) => row.groupIndex === groupIndex, + ); + // Where the drop line sits inside THIS group's fragment, as a local + // index (`rows.length` = after the last row). A boundary slot exists + // twice — end of one group, start of the next — and `groupIndex` is + // what routes the line to the side whose pin the drop would adopt. + const indicatorAt = + drag !== null && drag.target.groupIndex === groupIndex + ? drag.target.beforeRow - start + : null; + return ( + +
{group.label}
+ {group.rows.map(({ entry, label }, localIndex) => { + const visible = entry.hidden !== true; + return ( + + {indicatorAt === localIndex ? ( +
+ ) : null} +
{ + if (node) rowNodesRef.current.set(entry.id, node); + else rowNodesRef.current.delete(entry.id); + }} + {...(visible + ? {} + : { "data-pretable-column-hidden": "true" })} + {...(drag?.columnId === entry.id + ? { "data-pretable-tool-row-dragging": "" } + : {})} + > + {/* The drag handle AND the keyboard-reorder control. A + focusable span with role=button rather than a + {label} + +
+ + ); + })} + {indicatorAt === group.rows.length ? ( +
+ ) : null} + + ); + })} + {matched.length === 0 ? ( + // Hardcoded English like the section's other strings — the whole + // section is a known messages-system gap, tracked elsewhere. +
No columns match
+ ) : null} + {(() => { + if (menu === null) return null; + // `matched`, not `entries`: the lookup mirrors what is RENDERED, which + // is the roster filter AND the search filter. The search-out case is + // already cleared at its source (the input's onChange), so this guard + // is the roster arm — the column left the layout while its menu was + // up, and there is nothing to pin. The hook's anchor tracking closes + // the state itself on the next scroll or resize. + const open = matched.find(({ entry }) => entry.id === menu.columnId); + if (open === undefined) return null; + return ( + { + grid.setColumnPinned(open.entry.id, pinned); + closeMenuState(); + // Deferred: the pin just moved the row across subgroup + // fragments, so the kebab remounts and can only be focused + // after the commit, through the per-id node map. + pendingKebabFocusRef.current = open.entry.id; + }} + /> + ); + })()} + + + ); +} diff --git a/packages/react/src/tool-panel/Rail.tsx b/packages/react/src/tool-panel/Rail.tsx new file mode 100644 index 000000000..03137c94a --- /dev/null +++ b/packages/react/src/tool-panel/Rail.tsx @@ -0,0 +1,118 @@ +import { type RefObject, useState } from "react"; + +import { focusTab } from "./focus"; +import type { + ToolPanelSectionDescriptor, + ToolPanelSectionId, +} from "./sections"; + +export interface ToolPanelRailProps { + /** Accessible name for the tablist — supplied by the surface's messages + * layer, never hardcoded here (see {@link ToolPanelProps.railLabel}). */ + label: string; + sections: readonly ToolPanelSectionDescriptor[]; + activeSection: ToolPanelSectionId | null; + /** The pane element's id — every tab points its `aria-controls` here, + * because there is one pane and the tabs swap what fills it. */ + paneId: string; + /** `${baseId}-tab-${sectionId}`, shared with the pane's `aria-labelledby`. */ + tabId: (id: ToolPanelSectionId) => string; + onActiveSectionChange: (next: ToolPanelSectionId | null) => void; + railRef: RefObject; +} + +/** + * The vertical icon strip at the grid's right edge. A tablist whose tabs + * TOGGLE rather than select: activating the open section's tab closes the + * pane, which real tablists never do — but a panel with no "none" affordance + * would permanently cost the grid 264px. + * + * Roving tabindex, so the whole rail is one Tab stop. ArrowUp/ArrowDown move + * focus without activating (activation-follows-focus would open and close the + * pane on every keystroke of a browse); Enter/Space activate via the buttons' + * native click synthesis. The rover is local state, reset when focus leaves + * the rail so a returning Tab always lands on the active (or first) tab + * rather than wherever a browse was abandoned. + */ +export function Rail({ + label, + sections, + activeSection, + paneId, + tabId, + onActiveSectionChange, + railRef, +}: ToolPanelRailProps) { + const [roverId, setRoverId] = useState(null); + const tabStopId = + (roverId != null && sections.some((s) => s.id === roverId) + ? roverId + : null) ?? + (activeSection != null && sections.some((s) => s.id === activeSection) + ? activeSection + : null) ?? + sections[0]?.id ?? + null; + + const moveFocus = (from: ToolPanelSectionId, delta: 1 | -1) => { + const index = sections.findIndex((s) => s.id === from); + if (index === -1) return; + const next = sections[(index + delta + sections.length) % sections.length]; + if (next === undefined) return; + // No setRoverId here: the .focus() fires the tab's onFocus, which sets + // the rover — a second write would only diverge in the failure case, + // moving the tab stop to a tab that never actually received focus. + focusTab(railRef.current, tabId(next.id)); + }; + + return ( +
{ + if (!railRef.current?.contains(event.relatedTarget as Node | null)) { + setRoverId(null); + } + }} + > + {sections.map((section) => { + const selected = section.id === activeSection; + const Icon = section.icon; + return ( + + ); + })} +
+ ); +} diff --git a/packages/react/src/tool-panel/ToolPanel.tsx b/packages/react/src/tool-panel/ToolPanel.tsx new file mode 100644 index 000000000..154a0985e --- /dev/null +++ b/packages/react/src/tool-panel/ToolPanel.tsx @@ -0,0 +1,88 @@ +import { useId, useRef } from "react"; + +import { focusTab } from "./focus"; +import { Rail } from "./Rail"; +import type { + ToolPanelSectionDescriptor, + ToolPanelSectionId, +} from "./sections"; + +export interface ToolPanelProps { + sections: readonly ToolPanelSectionDescriptor[]; + /** + * Which section is open, or `null` for rail-only. Fully controlled: the + * shell holds no open/close state, so the surface (Task 6) can offer both + * controlled and uncontrolled forms without this component knowing which. + */ + activeSection: ToolPanelSectionId | null; + onActiveSectionChange: (next: ToolPanelSectionId | null) => void; + /** + * Accessible name for the rail's `tablist`. Required and never defaulted + * here: every user-facing string the grid renders is owned by the surface's + * messages layer, so a localizer overrides them in exactly one place. + */ + railLabel: string; +} + +/** + * The tool panel shell: a pane and its rail, rendered as siblings in visual + * order (pane, then rail at the outermost edge) for the parent's flex row to + * dock against the grid's right side. No wrapper element — the surface owns + * the layout, and a wrapper here would force it to style through one. + * + * The pane exists in the DOM only while open. `display:none` would keep a + * closed section's state alive, but these sections are projections of engine + * state, not owners of it — remount is free, and an unmounted pane can never + * hold a stale focus trap or a hidden tabpanel that screen readers still + * enumerate. + */ +export function ToolPanel({ + sections, + activeSection, + onActiveSectionChange, + railLabel, +}: ToolPanelProps) { + const baseId = useId(); + const paneId = `${baseId}-pane`; + const tabId = (id: ToolPanelSectionId) => `${baseId}-tab-${id}`; + const railRef = useRef(null); + + const active = + activeSection == null + ? undefined + : sections.find((s) => s.id === activeSection); + + return ( + <> + {active !== undefined ? ( +
{ + if (event.key === "Escape" && !event.defaultPrevented) { + event.stopPropagation(); + focusTab(railRef.current, tabId(active.id)); + } + }} + > +
{active.render()}
+
+ ) : null} + + + ); +} diff --git a/packages/react/src/tool-panel/focus.ts b/packages/react/src/tool-panel/focus.ts new file mode 100644 index 000000000..c3ddd2468 --- /dev/null +++ b/packages/react/src/tool-panel/focus.ts @@ -0,0 +1,8 @@ +/** Focus a rail tab by its DOM id. Tab ids are built from `useId`, whose + * output contains characters (`:` in React 19) that a bare `#id` selector + * chokes on — so the lookup goes through an attribute selector with + * `CSS.escape`. That subtlety is the whole reason this helper exists: it + * lives once, here, instead of being re-derived at every focus hand-off. */ +export function focusTab(rail: HTMLElement | null, tabId: string): void { + rail?.querySelector(`[id="${CSS.escape(tabId)}"]`)?.focus(); +} diff --git a/packages/react/src/tool-panel/index.ts b/packages/react/src/tool-panel/index.ts new file mode 100644 index 000000000..0d57c8aff --- /dev/null +++ b/packages/react/src/tool-panel/index.ts @@ -0,0 +1,13 @@ +/** Internal barrel for the tool panel shell. Nothing here is public API — + * Task 6 wires the surface's config types through `public_api.ts`. */ +export { ToolPanel, type ToolPanelProps } from "./ToolPanel"; +export { + ColumnsSection, + type ColumnsSectionGrid, + type ColumnsSectionProps, +} from "./ColumnsSection"; +export { Rail, type ToolPanelRailProps } from "./Rail"; +export type { + ToolPanelSectionDescriptor, + ToolPanelSectionId, +} from "./sections"; diff --git a/packages/react/src/tool-panel/sections.ts b/packages/react/src/tool-panel/sections.ts new file mode 100644 index 000000000..a7abc8c9a --- /dev/null +++ b/packages/react/src/tool-panel/sections.ts @@ -0,0 +1,35 @@ +/** + * The tool panel's section contract — the seam between the shell (rail + + * pane, this directory) and the sections that live inside it (columns today; + * SP2 and SP3 add theirs by appending a descriptor, not by touching the + * shell). + * + * Internal on purpose: consumers configure the panel through the surface's + * config types, and the surface constructs these descriptors. The eventual + * composable story — consumer-supplied sections — will widen this contract, + * which is exactly why the shell must already treat it as data it does not + * understand. + */ +import type { ComponentType, ReactNode } from "react"; + +/** + * Section ids are a closed union today; SP2 adds "filters", SP3 "grouping". + * Nothing in the shell may assume the union is closed at runtime — the + * future composable story widens this to consumer-supplied ids. + * + * The one public export of this directory: {@link PretableToolPanelConfig} + * addresses sections by id, so the id vocabulary is API even while the + * descriptor machinery stays internal. + * + * @public + */ +export type ToolPanelSectionId = "columns"; + +export interface ToolPanelSectionDescriptor { + readonly id: ToolPanelSectionId; + readonly icon: ComponentType<{ className?: string }>; + readonly label: string; + /** Props are baked in by the surface when it constructs descriptors — + * the shell renders sections without knowing what they need. */ + readonly render: () => ReactNode; +} diff --git a/packages/react/src/tool-panel/tool-panel-drop-target.ts b/packages/react/src/tool-panel/tool-panel-drop-target.ts new file mode 100644 index 000000000..8d77b7f54 --- /dev/null +++ b/packages/react/src/tool-panel/tool-panel-drop-target.ts @@ -0,0 +1,109 @@ +/** + * Pure insertion-index math for the columns section's row-reorder drag. + * + * Extracted from the pointer handlers for the same reason the header drag + * keeps `computeColumnDropTarget` in its own module: jsdom cannot express + * real pointer geometry (every rect is 0×0 there), so the only way to unit + * test "where would this drop land" is to hand the function measured rects + * and assert on the answer — the vacuous-scroll-test precedent. The pointer + * handlers measure the DOM and consume this verbatim; Playwright proves the + * measured half. + */ + +/** One rendered row's vertical extent, in any consistent coordinate space. */ +export interface ToolRowRect { + /** The column id the row represents — carried through for the caller. */ + readonly id: string; + readonly top: number; + readonly height: number; + /** Index into the caller's rendered-groups list. */ + readonly groupIndex: number; +} + +/** A rendered subgroup's pin value — the drop's re-pin target. */ +export interface ToolDropGroup { + readonly pinned: "left" | "right" | null; +} + +export interface ToolDropTarget { + /** + * Flat row index the drop inserts BEFORE; `rowRects.length` appends after + * the last row. + */ + readonly beforeRow: number; + /** + * The subgroup the dropped row would join. At a group boundary the same + * `beforeRow` exists twice — once as "end of the group above", once as + * "start of the group below" — and this field is what tells them apart: + * the insertion slot is identical, the pin value is not. + */ + readonly groupIndex: number; + /** Where the indicator line sits, in the input's coordinate space. */ + readonly indicatorY: number; +} + +/** + * Resolve a drag's pointer height to an insertion slot and target subgroup. + * + * Within a group the rule is the header drag's, turned vertical: the drop + * lands before the first row whose midpoint the pointer has not passed (a + * pointer exactly ON the midpoint has passed it); past every midpoint it + * appends after the last row. Between two groups there is one extra choice + * that no horizontal analogue has: the same slot is both "last of the group + * above" and "first of the group below", with different pin values. The gap + * between the two rows (where the subgroup label sits) is split at its + * middle — the upper half keeps the row in the group above, the lower half + * re-pins it into the group below. + * + * `rowRects` must be in rendered order (top ascending), `groupIndex` + * non-decreasing — which is how the section renders them. Hidden columns are + * ordinary rows here and occupy slots like any other. + */ +export function dropTargetForPointer( + y: number, + rowRects: readonly ToolRowRect[], + groups: readonly ToolDropGroup[], +): ToolDropTarget | null { + if (rowRects.length === 0 || groups.length === 0) return null; + + // First row whose midpoint the pointer has NOT passed. + let before = rowRects.length; + for (let i = 0; i < rowRects.length; i += 1) { + const rect = rowRects[i]!; + if (y < rect.top + rect.height / 2) { + before = i; + break; + } + } + + if (before === rowRects.length) { + const last = rowRects[rowRects.length - 1]!; + return { + beforeRow: rowRects.length, + groupIndex: last.groupIndex, + indicatorY: last.top + last.height, + }; + } + + const target = rowRects[before]!; + const previous = before > 0 ? rowRects[before - 1]! : null; + + // A slot between two groups: split the gap the label occupies. + if (previous !== null && previous.groupIndex !== target.groupIndex) { + const previousBottom = previous.top + previous.height; + const split = (previousBottom + target.top) / 2; + if (y < split) { + return { + beforeRow: before, + groupIndex: previous.groupIndex, + indicatorY: previousBottom, + }; + } + } + + return { + beforeRow: before, + groupIndex: target.groupIndex, + indicatorY: target.top, + }; +} diff --git a/packages/ui/src/__tests__/css-cascade.test.ts b/packages/ui/src/__tests__/css-cascade.test.ts index 95c60fdca..ae3fe9c89 100644 --- a/packages/ui/src/__tests__/css-cascade.test.ts +++ b/packages/ui/src/__tests__/css-cascade.test.ts @@ -707,6 +707,209 @@ describe("grid.css cascade contract", () => { } }); + describe("tool panel (SP1)", () => { + const stripped = () => + fs.readFileSync(GRID_CSS, "utf8").replace(/\/\*[\s\S]*?\*\//g, ""); + const toolRules = (css: string) => + [...css.matchAll(/([^{}]+)\{([^{}]*)\}/g)].filter((m) => + m[1].includes("data-pretable-tool-"), + ); + + test("the layout wrapper takes the card chrome and the viewport inside surrenders its own", () => { + // With the panel on by default, the surface's outer box is the + // `[data-pretable-tool-layout]` row. The card border/radius/shadow have + // to move UP onto it — otherwise the rail docks visibly OUTSIDE the + // card's frame — and the viewport must stop drawing its own copy or + // every edge inside the card doubles. + const css = stripped(); + const layout = css.match( + /:where\(\[data-pretable-tool-layout\]\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect(layout, "no [data-pretable-tool-layout] rule").toBeDefined(); + expect(layout).toMatch( + /border:\s*1px solid var\(--pretable-rule-strong\)/, + ); + expect(layout).toMatch(/border-radius:\s*var\(--pretable-radius\)/); + expect(layout).toMatch(/box-shadow:\s*var\(--pretable-shadow-card\)/); + // The wrapper clips its square-cornered children to its own radius; + // without it every child's corner pokes through the rounded frame. + expect(layout).toMatch(/overflow:\s*hidden/); + + const viewportInside = css.match( + /:where\(\[data-pretable-tool-layout\]\)\s*:where\(\[data-pretable-scroll-viewport\]\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect( + viewportInside, + "no rule stripping the viewport's chrome inside the layout wrapper", + ).toBeDefined(); + expect(viewportInside).toMatch(/border:\s*0/); + // Square corners on the inner viewport — the wrapper's radius does the + // rounding; a kept radius draws a hairline sliver at every card corner. + expect(viewportInside).toMatch(/border-radius:\s*0/); + expect(viewportInside).toMatch(/box-shadow:\s*none/); + }); + + test("the group panel and error strip surrender their frame inside the wrapper and redraw the seam as a bottom border", () => { + // Outside the wrapper those boxes draw their own border and rely on the + // viewport's top border for the seam beneath them. Inside it the + // viewport's border is gone, so without this rule the seam vanishes — + // and their own side/top borders would double against the wrapper's + // frame. One rule does both: zero the frame, redraw the seam as + // border-bottom. + const css = stripped(); + const surrender = css.match( + /:where\(\[data-pretable-tool-layout\]\)\s*:where\(\[data-pretable-group-panel\]\),\s*:where\(\[data-pretable-tool-layout\]\)\s*:where\(\[data-pretable-body-state="error-strip"\]\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect( + surrender, + "no rule surrendering the group panel's / error strip's frame inside the layout wrapper", + ).toBeDefined(); + expect(surrender).toMatch(/border:\s*0/); + expect(surrender).toMatch(/border-radius:\s*0/); + expect(surrender).toMatch( + /border-bottom:\s*1px solid var\(--pretable-rule-strong\)/, + ); + }); + + test("the rail borrows the header's surface and the pane the toolbar's", () => { + // The panel is CHROME, not content: the rail sits on the same plane as + // the header strip and the pane on the toolbar's. If either falls back + // to the grid surface it reads as a data region with buttons in it. + const css = stripped(); + const rail = css.match( + /:where\(\[data-pretable-tool-rail\]\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect(rail, "no [data-pretable-tool-rail] rule").toBeDefined(); + expect(rail).toMatch(/background:\s*var\(--pretable-bg-header\)/); + + const pane = css.match( + /:where\(\[data-pretable-tool-pane\]\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect(pane, "no [data-pretable-tool-pane] rule").toBeDefined(); + expect(pane).toMatch(/background:\s*var\(--pretable-bg-toolbar\)/); + }); + + test("tool-panel rules read only tokens the theme contract declares", () => { + // The panel must not smuggle a new --pretable-* name past the 50-token + // contract: a var() that no theme declares resolves to nothing, and the + // contract test only proves that names RESOLVE — an element-scoped + // invention (`--pretable-tool-width: 264px` declared and read in the + // same section) would resolve and still be a token no theme owns. + // The contract here is what pretable.css declares, the same source the + // token contract test loads. + const theme = fs + .readFileSync(path.join(THEMES_DIR, "pretable.css"), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, ""); + const contract = new Set( + [...theme.matchAll(/(--pretable-[a-z0-9-]+)\s*:/g)].map((m) => m[1]), + ); + expect(contract.size, "pretable.css declares no tokens?").toBeGreaterThan( + 20, + ); + + const rules = toolRules(stripped()); + expect(rules.length, "no tool-panel rules at all").toBeGreaterThan(0); + for (const [, selector, body] of rules) { + for (const [, name] of body.matchAll(/var\(\s*(--[a-zA-Z0-9-]+)/g)) { + expect( + contract.has(name), + `tool-panel rule "${selector.trim()}" reads ${name}, which is outside the token contract`, + ).toBe(true); + } + expect( + body, + `tool-panel rule "${selector.trim()}" declares a custom property; the panel adds no tokens`, + ).not.toMatch(/--[a-zA-Z0-9-]+\s*:/); + } + }); + + test("a hidden column dims by token, and nothing in the panel dims by opacity", () => { + // The entity-secondary precedent, verbatim: every opacity-dimmed + // secondary this repo has shipped failed WCAG AA, because opacity + // multiplies away a contrast that --pretable-text-dim holds by + // construction. The ban covers the whole section, not just the hidden + // row — a faded row is also a dimmed label, and dimmed-by-fade is the + // presentation a hidden column owns. + const css = stripped(); + const hidden = css.match( + /:where\(\s*\[data-pretable-tool-column-row\]\[data-pretable-column-hidden="true"\]\s*\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect(hidden, "no hidden-column row rule").toBeDefined(); + expect(hidden).toMatch(/color:\s*var\(--pretable-text-dim\)/); + + for (const [, selector, body] of toolRules(css)) { + expect( + body, + `tool-panel rule "${selector.trim()}" uses opacity; dim by --pretable-text-dim instead`, + ).not.toMatch(/opacity:/); + } + }); + + test("the selected tab swaps its surface without the background shorthand", () => { + // The selected rule FOLLOWS the hover rule at equal (0,0,0) + // specificity, and the `background` shorthand resets background-image + // to `none` — so hovering the open tab would show no tint, on exactly + // the tab a pointer rests on most. Same hazard the selection-fill + // rules document at the top of grid.css. + const css = stripped(); + const rule = css.match( + /:where\(\[data-pretable-tool-tab\]\[aria-selected="true"\]\)\s*\{([\s\S]*?)\}/, + )?.[1]; + expect(rule, "no selected-tab rule").toBeDefined(); + expect(rule).toMatch(/background-color:\s*var\(--pretable-bg-toolbar\)/); + expect( + rule, + "the background shorthand resets background-image and erases the hover tint", + ).not.toMatch(/background:\s/); + }); + + test("grid.css styles every element of the shell and columns section", () => { + // Same shape as the drag-to-group panel's guard: the DOM is a fixed + // contract the React task will emit, and an unstyled member ships as a + // naked or