From 85964533c18b2751590f56d5938f7751220e7a15 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 07:50:18 -0700 Subject: [PATCH 01/11] docs: tool-panel pane-resize + auto-width design (SP5) Co-Authored-By: Claude Fable 5 --- .../2026-08-30-tool-panel-resize-autosize.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md diff --git a/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md b/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md new file mode 100644 index 00000000..45e0745a --- /dev/null +++ b/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md @@ -0,0 +1,163 @@ +# Pane Resizing + Auto Width (Tool Panel SP5) — Design + +**Status:** approved direction, specced in full. +**Parents:** `2026-08-24-tool-panel-design.md` — both items sit on SP1's +out-of-scope-but-designed-for list: "panel width resizing" and "column +autosize action in the kebab (menu is built to take it later)". + +## What this is + +Two finishing touches on the tool panel, one PR: + +- **A. Pane width resizing** — a drag handle on the pane's grid-side edge, + with keyboard parity, bounds, and the surface's controlled/uncontrolled + convention. +- **B. Auto width in the columns section** — the kebab gains an "Auto + width" toggle per column, and the section footer gains "Auto-size all + columns", both over the auto-width machinery that already exists. + +## Facts the design rests on (verified in code) + +1. Pane width is a plain `inline-size: 264px` at (0,0,0) specificity in + `grid.css` (~1462), documented as consumer-overridable; the pane is a + "fixed budget the grid area yields", and the virtualizer already + observes resize (SP1). +2. Auto width is a **live set**, not a one-shot fit: + `createAutoWidthStore` (pretable-model.ts) holds the set of column ids + in auto mode; columns with no declared `widthPx` START auto; the + renderer sizes auto columns from content continuously. + `autosizeColumns()` on the handle = set ALL columns auto; + `setColumnWidth(id, px)` flips that column to manual. There is no + public per-column auto setter yet. +3. The pane's keyboard walks (columns, grouping, custom-section e2e) pin + tab-stop rosters, and the tab-exit guard is a hard gate — a new + focusable in the pane moves rosters deliberately, never accidentally. + +## Decisions — A. Pane resizing + +A1. **The handle is the pane's inline-start edge** (the pane/grid seam): a +slim grab strip (`data-pretable-pane-resize`), full pane height, +≥24px hit area on coarse pointers via the established coarse-pointer +hit-area pattern. Zero new tokens; the visible affordance reuses +existing rule/hover tokens. + +A2. **Live resize, pointer-captured, Escape-cancels.** Capture at +pointerdown (the recorded rAF-coalescing lesson); width applies +continuously during the drag (chrome-cheap; the grid reflows through +the virtualizer's resize observer); Escape mid-drag restores the +drag-start width; release commits. Double-click resets to the default +width. + +A3. **State is React chrome state with the surface's assert-and-report +trio**: `defaultPaneWidthPx?` / `paneWidthPx?` / `onPaneWidthChange?` +on `PretableToolPanelConfig` — the same controlled/uncontrolled +contract as `activeSection` (SP1 decision 5: chrome state is +React-owned; engine state would be dead surface for headless +consumers). Rejected: uncontrolled-only — cheaper, but it breaks the +surface's otherwise-universal convention for interactive chrome, and +a persisted-layout consumer needs the controlled form. + +A4. **Bounds, clamped everywhere**: a `MIN` floor (exact px chosen at +implementation by measuring the filters section's narrowest usable +row — the spec's rule, not a magic number) and a dynamic max +(`surfaceWidth − rail − a grid minimum`), applied to drags, keyboard +steps, AND incoming controlled/default values. A controlled value +outside bounds renders clamped and reports the clamped value. + +A5. **No inline style until someone acts.** Untouched and uncontrolled, +the pane keeps its stylesheet width — so the documented consumer css +override keeps working exactly as today. The first drag/keyboard +change (or a controlled/default prop) switches to an inline +`inline-size`, which outranks any stylesheet. Documented on the docs +page beside the existing override note. + +A6. **Keyboard parity (WCAG history makes this non-optional):** the handle +is focusable, `role="separator"` with `aria-orientation="vertical"`, +`aria-valuenow/min/max`, ArrowLeft/ArrowRight adjusting by 16px +(direction-aware: "grow the pane" is the same arrow that drags the +seam that way in the current writing direction — follow the header +column-resize's RTL treatment), Home/End to min/max, Enter resets to +default. It is a NEW tab stop inside the pane: every keyboard-walk +roster that enumerates pane stops is updated deliberately, and the +tab-exit guard must stay green. + +A7. **No persistence** (no saved-views concept — unchanged from SP1); +consumers persist via the controlled trio. + +## Decisions — B. Auto width + +B1. **The kebab item is a TOGGLE named for the real semantic.** Auto width +is continuous, so the menu gains a `role="menuitemcheckbox"` "Auto +width" item, checked when the column is in the auto set. On → +per-column auto; the column starts tracking content. Off → the column +becomes manual at the engine's current stored width (no jump). +Rejected: an AG-style one-shot "Autosize" action — it would +misdescribe the machinery (the store has no one-shot mode), and a +one-shot that silently left the column tracking content would be the +worse lie. + +B2. **Public handle gains `setColumnAutoWidth(columnId, auto)`**, +mirroring `setColumnWidth`'s shape and TSDoc conventions; +`autosizeColumns()` keeps its all-columns meaning and its name. The +kebab writes through the new method. (Handle addition → api report +moves; docs guard will demand registration.) + +B3. **"Auto-size all columns" joins the columns section footer** beside +Reset columns, calling `autosizeColumns()`. Same button anatomy as +Reset. + +B4. **Reset columns restores the INITIAL auto set** — audit item: verify +whether today's Reset already does (the initial set is "columns +without declared widthPx"); if it doesn't, fixing it is in scope, +because a Reset that restores order/pin/visibility but leaves +auto-mode drift is a half-reset. Test both directions. + +B5. **The menu component sheds its stale name.** `ColumnPinMenu` grows a +non-pin item; rename to what it is (e.g. `ColumnRowMenu` — the +columns-section row's menu; exact name at implementation, following +directory conventions). No-backcompat repo; internal component. + +B6. **Interaction with manual resize, documented not fought**: dragging a +column's header resize strip (or the pane's setColumnWidth path) +already flips auto off — the toggle reflects that on next open. The +docs state the pair plainly: auto tracks content until you size the +column yourself; the toggle turns tracking back on. + +## Verification + +- **A (resize):** jsdom — drag commits width within bounds; Escape-cancel + restores; double-click resets; controlled trio asserts-and-reports + (including out-of-bounds clamp report); no inline style before first + interaction (assert the attribute is absent, then present after a + drag); keyboard: arrows step (both directions × both writing modes if + the harness allows), Home/End, Enter-reset, aria-value* correctness. + Playwright — pointer drag on the real seam resizes pane AND grid + reflows (assert a grid column count/position change or scrollbar + change, not just the pane's width); the keyboard walks' updated rosters + pass; tab-exit guard green; coarse-pointer hit area ≥24px (the touch + spec's existing pattern). +- **B (auto width):** jsdom — the toggle reflects the live set (a column + with declared width starts unchecked; one without starts checked); + toggling on makes a content change ACTUALLY change the drawn width + (disprove-capable: two contents with different widths — assert the + header cell's width moves, not that a method was called); toggling off + freezes it (content changes, width doesn't); manual header resize + unchecks it on next open; footer Auto-size-all sets every column; + Reset restores the initial set (B4, both directions). Mutation checks + on each: a toggle wired to the wrong column id or inverted must fail. +- **Docs:** tool-panel page — resizing (with the css-override interplay + note, A5) and the auto-width pair (B6); configuration table gains the + width trio; handle docs gain `setColumnAutoWidth`; api reports + regenerate (build before api); docs guards satisfied honestly. +- **Changesets:** `@pretable/react` minor (config + handle), `@pretable/ui` + patch (css). +- Assert the old behavior survives: header column-resize, the kebab's pin + actions, Reset's existing restores, and the three keyboard walks. + +## Out of scope + +Width persistence beyond the controlled trio; per-section widths; a +resize handle on the rail side; min/max-width props for the pane +(the bounds are internal); autosize sampling options (padding, header +inclusion — the store has no such knobs and none are invented here); +touching the header column-resize implementation. From 1512e813e841c51db623e008cd88f786dc86d3fd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 07:51:35 -0700 Subject: [PATCH 02/11] docs: SP5 pane-resize + auto-width implementation plan Co-Authored-By: Claude Fable 5 --- .../2026-08-30-tool-panel-resize-autosize.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md diff --git a/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md b/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md new file mode 100644 index 00000000..f60f0706 --- /dev/null +++ b/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md @@ -0,0 +1,110 @@ +# Tool Panel SP5 — Pane Resizing + Auto Width 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:** Pane width resizing (drag + keyboard, controlled/uncontrolled) and the columns section's auto-width affordances (kebab toggle + auto-size-all footer action), per `docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md`. + +**Architecture:** Resizing is React chrome state (the `activeSection` pattern) applied as an inline `inline-size` only after first interaction; the handle is a `role="separator"` strip on the pane's inline-start edge. Auto width rides the existing `createAutoWidthStore` — one new public handle method (`setColumnAutoWidth`), no engine changes. + +**Tech Stack:** React 19, TypeScript, vitest via the package `test` script (never bare `vitest run`; positional filters, never `--`), Playwright, api-extractor, changesets. + +**Read first, every task:** the spec (decisions A1–A7, B1–B6); `packages/react/src/tool-panel/ToolPanel.tsx` + the `` render site and `PretableToolPanelConfig` in `pretable-surface.tsx`; `packages/react/src/tool-panel/useToolRowDrag.ts` (#527 — the drag conventions: capture at pointerdown, Escape-cancel, the comment register); `createAutoWidthStore` + `autosizeColumns` + `setColumnWidth` in `packages/react/src/pretable-model.ts` (~lines 420-440, 655-745); `packages/react/src/tool-panel/ColumnPinMenu.tsx` + `overlay/menu-keyboard.ts`; the keyboard-walk rosters in `apps/website/e2e/tool-panel.spec.ts` and `grid-tab-wrap-rows.spec.ts`; the coarse-pointer hit-area pattern and the pane block in `packages/ui/src/grid.css` (~1455-1480). + +**House rules:** worktree only; no `git stash`; `pnpm format` before every commit (required gate); loaded box — isolate failures before believing; `pnpm build` before `pnpm api`; commit per task with trailer `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: `setColumnAutoWidth` on the handle + Reset audit (B2, B4) + +**Files:** +- Modify: `packages/react/src/pretable-model.ts` (facade + type) +- Modify: `packages/react/src/tool-panel/ColumnsSection.tsx` ONLY IF the Reset audit finds drift (see Step 3) +- Test: `packages/react/src/__tests__/column-auto-width.test.tsx` (new) + +- [ ] **Step 1: Write the failing tests.** + +```tsx +// Harness: a 2-column surface — "fixed" declares widthPx: 120, "fluid" +// declares NO width (starts auto). Capture the grid via onGridReady. +// Content strings chosen so auto width visibly differs between them +// (choose-data-that-can-disprove): render "x" vs a long string, assert the +// drawn header cell's offsetWidth/style width CHANGES when content changes +// while auto, and does NOT change after setColumnAutoWidth(id, false). + +it("a column with no declared width starts auto; a declared one does not", ...); +it("setColumnAutoWidth(id, true) makes content drive the drawn width", ...); +it("setColumnAutoWidth(id, false) freezes the current width (no jump)", ...); +it("setColumnWidth still flips auto OFF (old behavior survives)", ...); +``` + +(Adapt: jsdom has no real text measurement — read how existing auto-width behavior is tested; if drawn-width assertions need the renderer's measure seam mocked, follow that precedent. If jsdom cannot observe content-driven width at all, assert the STORE membership through the surface (a data attribute or the kebab state added in Task 3) AND add the drawn-width proof to the Playwright task instead — note the substitution honestly in the test header.) + +- [ ] **Step 2: Implement.** In `pretable-model.ts`: add `setColumnAutoWidth(columnId: TColumnId, auto: boolean)` to the `PretableReactGrid`/facade type beside `setColumnWidth`, TSDoc mirroring its conventions (declare-here-for-the-prototype-reach reason if applicable); implementation: `stores.autoWidths.setAuto(columnId as string, auto)`. Verify `autosizeColumns` and `setColumnWidth` interplay unchanged. +- [ ] **Step 3: The Reset audit (B4).** Read ColumnsSection's Reset action: does it restore the initial auto set? Write the test either way: Reset after `setColumnAutoWidth("fixed", true)` must return "fixed" to manual and "fluid" to auto (the initial state). If Reset today doesn't touch the store, wire it (the initial set = ids without declared widthPx — the store's own constructor rule; expose what's needed via existing seams, not new public API). +- [ ] **Step 4: Run the new file + the columns-section suites** (`pnpm --filter @pretable/react test column-auto-width tool-panel`). Mutation: make `setColumnAutoWidth` ignore its `auto` arg (always true) → the freeze test fails; revert. +- [ ] **Step 5: Format, lint, typecheck, commit** — `feat(react): setColumnAutoWidth on the grid handle (SP5)`. + +--- + +### Task 2: pane resize — state, handle, pointer + keyboard (A1–A7) + +**Files:** +- Modify: `packages/react/src/pretable-surface.tsx` (config trio + state + clamp), `packages/react/src/tool-panel/ToolPanel.tsx` (the handle element + inline width application) +- Create: `packages/react/src/tool-panel/pane-resize.ts` (pure: clamp logic, step math, direction resolution — the testable-without-React split) +- Modify: `packages/ui/src/grid.css` (handle styling + coarse hit area; zero new tokens) +- Tests: `packages/react/src/__tests__/tool-panel-pane-resize.test.tsx` (new) + pure `tool-panel-pane-resize-math.test.ts`; extend `attribute-contract.test.tsx` (`data-pretable-pane-resize`, asserted non-vacuously) + +- [ ] **Step 1: Pure module first, TDD.** `pane-resize.ts`: `clampPaneWidth(px, {min, max})`, `paneWidthAfterKey(key, current, {min, max, dir})` (arrows ±16, Home/End → min/max, direction-aware per A6), `paneWidthAfterDrag(startWidth, startX, currentX, dir, bounds)`. Write the table-driven tests red (both `ltr`/`rtl` rows — the direction flip is the mutation-prone branch), implement, green. MIN: measure the filters section's narrowest usable row in the browser once, record the number AND the measurement in a comment (A4's rule). +- [ ] **Step 2: Config + state.** `PretableToolPanelConfig` gains `defaultPaneWidthPx?/paneWidthPx?/onPaneWidthChange?` (TSDoc per the `activeSection` fields' register, incl. the A5 inline-style note and the clamp-and-report rule). Surface: uncontrolled `useState(defaultPaneWidthPx ?? null)` (`null` = untouched → no inline style); controlled presence wins; every write path clamps then reports. Pass width + change callback + bounds into ``. +- [ ] **Step 3: The handle in ToolPanel.tsx.** Rendered at the pane's inline-start edge when a pane is open: `role="separator"`, `aria-orientation="vertical"`, `aria-valuenow/min/max`, `tabIndex={0}`, `data-pretable-pane-resize`, aria-label from a NEW message key `toolPanelResizeLabel` (add to messages layer per the established three-places pattern). Pointer: capture at pointerdown, live width updates via the pure drag math, Escape restores drag-start width (document-level listener while dragging, `preventDefault` interlock comment per the house pattern), release commits, double-click and Enter reset to default (clear to `null` when uncontrolled — restoring the stylesheet width per A5; controlled → report the default). Keyboard per the pure module. +- [ ] **Step 4: jsdom tests red then green** (spec Verification A list): drag/clamp/Escape/dblclick-reset/controlled-trio/clamp-report/no-inline-before-interaction/aria values/arrow steps both directions. Follow `useToolRowDrag`'s test techniques for pointer sequences. +- [ ] **Step 5: css** — the strip (reuse rule/hover tokens; a wider transparent hit area on coarse pointers via the established pattern); attribute-contract addition. +- [ ] **Step 6: Mutations:** invert the direction resolution → the rtl rows fail; drop the clamp on the controlled path → the clamp-report test fails. Revert, green. +- [ ] **Step 7: Full react suite + ui suite; format, lint, typecheck; commit** — `feat(react): the tool-panel pane resizes by pointer and keyboard (SP5)`. + +--- + +### Task 3: the kebab toggle + footer action + rename (B1, B3, B5) + +**Files:** +- Rename: `packages/react/src/tool-panel/ColumnPinMenu.tsx` → the honest name (B5; update imports/tests) +- Modify: the renamed menu (menuitemcheckbox "Auto width"), `ColumnsSection.tsx` (footer "Auto-size all columns" beside Reset; plumb `setColumnAutoWidth` + the auto set), messages (+`toolPanelAutoWidthLabel`, `toolPanelAutosizeAllLabel` — three places), `grid.css` if the checkbox item needs styling +- Tests: extend the menu's + columns-section's suites; attribute contract if new attributes + +- [ ] **Step 1: Failing tests:** the menu shows "Auto width" checked/unchecked per the live set (fixed → unchecked, fluid → checked — reuse Task 1's harness shape); toggling writes through `setColumnAutoWidth` with the RIGHT column id (mutation target); `role="menuitemcheckbox"` + `aria-checked` correct; keyboard operation via the shared `useMenuKeyboard` (no fourth hand-rolled copy); footer button sets ALL columns auto (assert the store effect through the UI, e.g. every kebab now checked); Reset restores (extends Task 1's B4 test through the UI). +- [ ] **Step 2: Implement.** The section already subscribes to engine/layout state — the auto set needs its own subscription (`autoWidths.subscribe/getState` — check what the section can reach; plumb through the descriptor via a stable handle, respecting the DEPS RULE: the set is engine-ish state, so reach it by subscription, never bake it into the memo). +- [ ] **Step 3: Mutations:** wire the toggle to a constant column id → the per-column test fails; invert checked-state derivation → the reflect test fails. Revert. +- [ ] **Step 4: Run the tool-panel suites + full react suite; format, lint, typecheck; commit** — `feat(react): auto-width toggle and auto-size-all in the columns section (SP5)`. + +--- + +### Task 4: e2e — the seam, the walks, the pixel proofs + +**Files:** +- Modify: `apps/website/e2e/tool-panel.spec.ts` (+ possibly the fixtures page if the walk needs the handle) +- Verify: `grid-tab-wrap-rows.spec.ts` untouched-green + +- [ ] **Step 1: New cases** (hydration-gated, helpers reused): (a) pointer-drag the seam → pane width changes AND the grid reflows (assert something grid-side: a header cell's x-position or the horizontal scrollbar state — prove the pixel, not the style); (b) keyboard: Tab reaches the handle inside the pane's roster (UPDATE every walk roster the handle joins — with the "update when…" comment style), arrows resize, Enter resets; tab-exit guard still green; (c) auto-width: toggle a column's "Auto width" in the kebab, stream/update content, the header width visibly changes; toggle off → frozen (this is the drawn-width proof if Task 1's jsdom couldn't observe it). +- [ ] **Step 2: Run per the recipe** (root build, website build, prod server on a free port, `--workers=1`, both browsers, twice; isolate any failure). Include `grid-tab-wrap-rows.spec.ts` in the run. +- [ ] **Step 3: Format/lint/typecheck website; commit** — `test(e2e): pane resize and auto width through the real shell (SP5)`. + +--- + +### Task 5: docs, api, changesets, final verification + +**Files:** +- Modify: `apps/website/content/docs/grid/tool-panel.mdx` (resizing subsection with the A5 css-override interplay note; the width trio joins the Configuration table; the columns-section prose gains the auto-width pair with B6's plain statement; new message keys join the strings listings — the #512 guard will demand them), handle docs for `setColumnAutoWidth` wherever the guard requires +- Create: changesets — `@pretable/react` minor, `@pretable/ui` patch + +- [ ] **Step 1:** Docs + live-example touch-ups if the existing tool-panel example benefits (don't force one); run the website guard suite; satisfy every fail-closed demand honestly (SEO description ≤155 if touched). +- [ ] **Step 2:** `pnpm build` then `pnpm api`; commit reports; `api:check` green. Expected moves: the config trio, `setColumnAutoWidth`, new message keys. +- [ ] **Step 3:** Full verification: root build; react/ui/website suites; root lint + typecheck; format. Commit. Push branch, open PR `feat: tool-panel pane resizing and column auto width (SP5)` (body: spec decisions + test plan + generated-with footer). Merge on green only; read the merge back from origin/main. + +--- + +## Self-review notes + +- Spec coverage: A1→T2 (handle/css), A2→T2 (pointer/Escape/dblclick), A3→T2 (trio), A4→T2 (pure clamp + measured MIN), A5→T2 (null-state inline rule) + T5 docs, A6→T2+T4 (keyboard + rosters), A7→spec-only (no work); B1→T3, B2→T1, B3→T3, B4→T1+T3, B5→T3, B6→T5 docs. Verification section maps to T1–T4 tests + T5 gates. +- Names used consistently: `setColumnAutoWidth`, `defaultPaneWidthPx`/`paneWidthPx`/`onPaneWidthChange`, `pane-resize.ts` functions (T2 defines, T4 exercises), message keys `toolPanelResizeLabel`/`toolPanelAutoWidthLabel`/`toolPanelAutosizeAllLabel` (T2/T3 define, T5 documents). +- No engine (grid-core/row-model) changes anywhere — if a task finds itself editing them, stop and escalate. +- jsdom's text-measurement limitation is handled explicitly (T1 Step 1's substitution rule + T4's pixel proof), not discovered mid-task. From 18d8a5aae3b13a0957da344417d8acaf4c281d84 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 13:06:07 -0700 Subject: [PATCH 03/11] feat(react): setColumnAutoWidth on the grid handle (SP5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B2: the public handle gains setColumnAutoWidth(columnId, auto) beside setColumnWidth — true puts the column in the live auto-width set, false makes it manual at the engine's current stored width. setColumnWidth still flips auto off and autosizeColumns keeps its all-columns meaning; both pinned by test. B4 audit verdict: Reset restored pin/visibility/order but left auto-mode drift — a half-reset. The surface now captures the mount-time auto set (read from the store's own state, so the no-declared-widthPx rule keeps one home) beside the layout baseline, and Reset replays it through the new setter. The facade also carries an internal ɵautoWidths read seam for chrome that must reflect membership (the SP5 kebab toggle's future subscription). jsdom observes the set at its real seam: mergeRenderColumns strips widthPx for auto columns, so the drawn header width follows the renderer's fallback (140) for auto and the engine store for manual — content-driven pixels stay with Task 4's e2e. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/column-auto-width.test.tsx | 183 ++++++++++++++++++ packages/react/src/pretable-model.ts | 38 ++++ packages/react/src/pretable-surface.tsx | 12 ++ .../react/src/tool-panel/ColumnsSection.tsx | 18 ++ 4 files changed, 251 insertions(+) create mode 100644 packages/react/src/__tests__/column-auto-width.test.tsx diff --git a/packages/react/src/__tests__/column-auto-width.test.tsx b/packages/react/src/__tests__/column-auto-width.test.tsx new file mode 100644 index 00000000..e369c567 --- /dev/null +++ b/packages/react/src/__tests__/column-auto-width.test.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { act, cleanup, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import type { PretableColumn } from "../public_api"; +import { PretableSurface } from "../public_api"; +import type { PretableSurfaceGrid } from "../pretable-surface"; + +/** + * `setColumnAutoWidth` and the auto-width set, observed at the drawn seam. + * + * WHAT JSDOM CAN AND CANNOT SEE — read before trusting these numbers. jsdom + * performs no text measurement, so "content drives the width" is not + * observable here; that pixel proof belongs to the Playwright pass (SP5 + * Task 4). What jsdom CAN see is the seam the auto set actually acts + * through: `mergeRenderColumns` (pretable-model.ts) strips `widthPx` from + * every column in the auto set, so the renderer — not the engine — owns an + * auto column's drawn width (`resolveColumnWidth`'s fallback, 140px for an + * unwrapped column, or a flex share), while a manual column draws at the + * engine's stored width (grid-core's `DEFAULT_COLUMN_WIDTH_PX` 160 when the + * props declared none). 140 ≠ 160 ≠ any declared width, so auto-set + * membership is directly observable as WHICH owner the drawn header width + * follows: renderer fallback ⇒ auto, engine store ⇒ manual. Every assertion + * below reads the header cell's inline `style.width` — the real DOM output — + * never the store. + */ + +type DemoRow = { + id: string; + fixed: string; + fluid: string; +}; + +const rows: DemoRow[] = [{ id: "r1", fixed: "x", fluid: "y" }]; + +/** The renderer's fallback for an unwrapped column with no `widthPx` — + * `FIXED_COLUMN_WIDTH` in renderer-dom's create-renderer.ts. */ +const RENDERER_AUTO_WIDTH = 140; +/** grid-core's `DEFAULT_COLUMN_WIDTH_PX`: what the engine stores for a + * column whose props declared no width. */ +const ENGINE_DEFAULT_WIDTH = 160; +/** The "fixed" column's declared `widthPx`. */ +const DECLARED_WIDTH = 120; + +const columns: PretableColumn[] = [ + { + id: "fixed", + header: "Fixed", + widthPx: DECLARED_WIDTH, + value: (r) => r.fixed, + }, + { id: "fluid", header: "Fluid", value: (r) => r.fluid }, +]; + +type DemoGrid = PretableSurfaceGrid< + DemoRow, + string, + readonly PretableColumn[] +>; + +function mount(options?: { toolPanel?: boolean }) { + const captured = { current: null as DemoGrid | null }; + const view = render( + + ariaLabel="Auto width demo" + columns={columns} + getRowId={(row) => row.id} + onGridReady={(g) => { + captured.current = g as DemoGrid; + }} + rows={rows} + toolPanel={{ + defaultActiveSection: options?.toolPanel === true ? "columns" : null, + }} + viewportHeight={200} + />, + ); + if (captured.current === null) { + throw new Error("onGridReady never fired: no grid captured at mount"); + } + const grid = captured.current; + return { + view, + grid, + /** The drawn header width — the inline style the planner wrote. */ + drawnWidth: (columnId: string) => { + const cell = view.container.querySelector( + `[data-pretable-header-cell][data-pretable-column-id="${columnId}"]`, + ) as HTMLElement | null; + if (cell === null) throw new Error(`No header cell for ${columnId}`); + return Number.parseFloat(cell.style.width.replace("px", "")); + }, + /** What the ENGINE stores — distinct from what is drawn. */ + engineWidth: (columnId: string) => { + const entry = grid + .getState() + .columnLayout.find((column) => column.id === columnId); + if (entry === undefined) throw new Error(`No layout entry ${columnId}`); + return entry.widthPx; + }, + resetButton: () => + view.container.querySelector( + "[data-pretable-tool-reset]", + ) as HTMLButtonElement, + }; +} + +afterEach(cleanup); + +describe("column auto width", () => { + it("a column with no declared width starts auto; a declared one does not", () => { + const h = mount(); + // Manual: the engine's stored (declared) width is what draws. + expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); + expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); + // Auto: the engine stores its 160 default, but the RENDERER owns the + // drawn width — the divergence is the membership proof. + expect(h.engineWidth("fluid")).toBe(ENGINE_DEFAULT_WIDTH); + expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); + }); + + it("setColumnAutoWidth(id, true) hands the drawn width to the renderer", () => { + const h = mount(); + act(() => { + h.grid.setColumnAutoWidth("fixed", true); + }); + // The drawn width leaves the engine's stored value for the renderer's + // content-tracking mode (its fallback in jsdom, where nothing measures). + expect(h.drawnWidth("fixed")).toBe(RENDERER_AUTO_WIDTH); + // Non-destructive: the engine still remembers the declared width. + expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); + }); + + it("setColumnAutoWidth(id, false) freezes at the engine's stored width", () => { + const h = mount(); + act(() => { + h.grid.setColumnAutoWidth("fluid", false); + }); + // Off means manual at the engine's current stored width (spec B1) — + // the drawn width now follows the store, not the renderer. + expect(h.drawnWidth("fluid")).toBe(ENGINE_DEFAULT_WIDTH); + // The other column is untouched. + expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); + }); + + it("setColumnWidth still flips auto OFF, and autosizeColumns still sets ALL auto", () => { + const h = mount(); + // Old behavior 1: an explicit width write turns tracking off AND applies. + act(() => { + h.grid.setColumnWidth("fluid", 200); + }); + expect(h.drawnWidth("fluid")).toBe(200); + expect(h.engineWidth("fluid")).toBe(200); + // Old behavior 2: autosizeColumns keeps its all-columns meaning. + act(() => { + h.grid.autosizeColumns(); + }); + expect(h.drawnWidth("fixed")).toBe(RENDERER_AUTO_WIDTH); + expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); + // And the engine still remembers both stored widths underneath. + expect(h.engineWidth("fixed")).toBe(DECLARED_WIDTH); + expect(h.engineWidth("fluid")).toBe(200); + }); + + it("Reset columns restores the INITIAL auto set, both directions", () => { + const h = mount({ toolPanel: true }); + // Drift both ways from the initial state. + act(() => { + h.grid.setColumnAutoWidth("fixed", true); + h.grid.setColumnAutoWidth("fluid", false); + }); + expect(h.drawnWidth("fixed")).toBe(RENDERER_AUTO_WIDTH); + expect(h.drawnWidth("fluid")).toBe(ENGINE_DEFAULT_WIDTH); + // Reset: "fixed" declared a width, so it returns to manual; "fluid" + // declared none, so it returns to auto. + act(() => { + fireEvent.click(h.resetButton()); + }); + expect(h.drawnWidth("fixed")).toBe(DECLARED_WIDTH); + expect(h.drawnWidth("fluid")).toBe(RENDERER_AUTO_WIDTH); + }); +}); diff --git a/packages/react/src/pretable-model.ts b/packages/react/src/pretable-model.ts index 968ade4b..03747817 100644 --- a/packages/react/src/pretable-model.ts +++ b/packages/react/src/pretable-model.ts @@ -137,6 +137,18 @@ export type PretableReactGrid< ) => void; readonly cancelEdit: () => void; readonly setColumnWidth: (columnId: TColumnId, width: number) => void; + /** + * Put one column into (or take it out of) the auto-width set — the LIVE set + * of columns whose drawn width the renderer owns and tracks from content, + * rather than the engine's stored width. `true` starts tracking; `false` + * makes the column manual again at the engine's current stored width, with + * no width write of its own. Columns that declare no `widthPx` start in the + * set; {@link setColumnWidth} takes a column OUT of it (an explicit width is + * a manual gesture), and `autosizeColumns` puts EVERY column in. Declared + * here beside `setColumnWidth` rather than inherited: the auto set lives in + * this layer's store, not in grid-core, so the facade is its only home. + */ + readonly setColumnAutoWidth: (columnId: TColumnId, auto: boolean) => void; readonly setColumnPinned: ( columnId: TColumnId, pinned: "left" | "right" | null, @@ -530,6 +542,21 @@ export interface WindowState { readonly windowed: boolean; } +/** + * The `ɵautoWidths` read seam the facade carries at runtime: subscribe + + * getState over the auto-width set. Not on {@link PretableReactGrid} — the + * public voice over the set is `setColumnAutoWidth` / `setColumnWidth` / + * `autosizeColumns`; this reader exists for the surface's own chrome, which + * must also REFLECT membership (the tool panel). Reached by a cast at the + * consumer, the `setWindowState` pattern. + * + * @internal + */ +export interface AutoWidthSetReader { + readonly subscribe: (listener: () => void) => () => void; + readonly getState: () => ReadonlySet; +} + /** Internal indexed implementation shared by the public ownership overloads. */ export function usePretableModelInternal< TRow extends object, @@ -737,6 +764,17 @@ export function usePretableModelInternal< stores.gridCore.setColumnWidth(columnId, width); stores.autoWidths.setAuto(columnId, false); }; + facade.setColumnAutoWidth = (columnId: TColumnId, auto: boolean) => { + stores.autoWidths.setAuto(columnId as string, auto); + }; + // Internal read seam over the auto set for the surface's chrome (the + // tool panel's Reset and, later, its kebab toggle): membership is store + // state with no home in any snapshot, so chrome that must REFLECT it + // subscribes here rather than baking a copy into a descriptor closure. + facade.ɵautoWidths = { + subscribe: stores.autoWidths.subscribe, + getState: stores.autoWidths.getState, + }; return facade as PretableReactGrid; }, [ presentationColumnsRef, diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index 34de4282..b2a4a723 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -94,6 +94,7 @@ import type { PretableTelemetry, } from "./surface-types"; import type { + AutoWidthSetReader, PretableReactGrid, WindowSpacers, WindowState, @@ -2514,6 +2515,16 @@ export function PretableSurface< if (initialColumnLayoutRef.current === null) { initialColumnLayoutRef.current = indexedGrid.getState().columnLayout; } + // The reset baseline's AUTO-WIDTH half, captured at the same instant for + // the same reason. At first render the set is exactly the store's + // constructor rule — "columns without a declared `widthPx`" — read from + // the store itself rather than re-derived here, so the rule has one home. + const initialAutoWidthIdsRef = useRef>(null); + if (initialAutoWidthIdsRef.current === null) { + initialAutoWidthIdsRef.current = ( + indexedGrid as unknown as { readonly ɵautoWidths: AutoWidthSetReader } + ).ɵautoWidths.getState(); + } // 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 @@ -3909,6 +3920,7 @@ export function PretableSurface< render: () => ( void; readonly setColumnOrder: (columnIds: readonly string[]) => void; + readonly setColumnAutoWidth: (columnId: string, auto: boolean) => void; } export interface ColumnsSectionProps { @@ -72,6 +73,13 @@ export interface ColumnsSectionProps { readonly initialLayoutRef: RefObject< readonly ColumnsSectionLayoutEntry[] | null >; + /** + * The auto-width set as of the same surface-mount instant — the reset + * baseline's other half. Column ids whose width the renderer owned at + * mount (the ids that declared no `widthPx`); Reset returns exactly these + * to auto and every other replayed id to manual. + */ + readonly initialAutoWidthRef: RefObject | null>; /** Resolved surface messages — this section defaults no string itself. */ readonly messages: ToolPanelColumnsMessages; } @@ -98,6 +106,7 @@ export function ColumnsSection({ grid, labelForColumn, initialLayoutRef, + initialAutoWidthRef, messages, }: ColumnsSectionProps) { // Live engine state, read through the section's OWN subscription — never a @@ -316,6 +325,7 @@ export function ColumnsSection({ // 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. + const initialAuto = initialAutoWidthRef.current; for (const entry of current) { const initialEntry = initialById.get(entry.id); if (initialEntry === undefined) continue; @@ -325,6 +335,14 @@ export function ColumnsSection({ if ((entry.hidden === true) !== (initialEntry.hidden === true)) { grid.setColumnVisible(entry.id, initialEntry.hidden !== true); } + // The auto-width half of the baseline (spec B4): a reset that restores + // order/pin/visibility but leaves auto-mode drift is a half-reset. The + // write is unconditional over the replayed ids — the store no-ops when + // membership already matches — because unlike pin/visibility the + // CURRENT membership is not readable from the layout entry here. + if (initialAuto !== null) { + grid.setColumnAutoWidth(entry.id, initialAuto.has(entry.id)); + } } // `setColumnOrder` demands EVERY current layout id. Ids the capture never // saw (a group column derived since mount) are spliced back at their From 3fe5770206a61318ea05ce7144c96715ebb90dac Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 13:13:23 -0700 Subject: [PATCH 04/11] =?UTF-8?q?docs:=20SP5=20spec/plan=20amended=20?= =?UTF-8?q?=E2=80=94=20auto=20width=20is=20a=20mode=20bit,=20not=20a=20con?= =?UTF-8?q?tent=20fit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified in a real browser: nothing measures cell content in the column-width path. B3 (auto-size-all footer) dropped; the toggle and docs say grid-managed width; the 140→160 jump documented. Co-Authored-By: Claude Fable 5 --- .../2026-08-30-tool-panel-resize-autosize.md | 10 +-- .../2026-08-30-tool-panel-resize-autosize.md | 66 ++++++++++++------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md b/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md index f60f0706..d50db75d 100644 --- a/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md +++ b/docs/superpowers/plans/2026-08-30-tool-panel-resize-autosize.md @@ -64,14 +64,14 @@ it("setColumnWidth still flips auto OFF (old behavior survives)", ...); --- -### Task 3: the kebab toggle + footer action + rename (B1, B3, B5) +### Task 3: the kebab toggle + rename (B1, B5; B3 DROPPED — see the amended spec) **Files:** - Rename: `packages/react/src/tool-panel/ColumnPinMenu.tsx` → the honest name (B5; update imports/tests) -- Modify: the renamed menu (menuitemcheckbox "Auto width"), `ColumnsSection.tsx` (footer "Auto-size all columns" beside Reset; plumb `setColumnAutoWidth` + the auto set), messages (+`toolPanelAutoWidthLabel`, `toolPanelAutosizeAllLabel` — three places), `grid.css` if the checkbox item needs styling +- Modify: the renamed menu (menuitemcheckbox "Auto width"), `ColumnsSection.tsx` (plumb `setColumnAutoWidth` + the auto set via the ɵautoWidths seam Task 1 added), messages (+`toolPanelAutoWidthLabel` — three places), `grid.css` if the checkbox item needs styling; correct the Task 1 test header's "content-tracking mode" phrase to the mode-bit wording - Tests: extend the menu's + columns-section's suites; attribute contract if new attributes -- [ ] **Step 1: Failing tests:** the menu shows "Auto width" checked/unchecked per the live set (fixed → unchecked, fluid → checked — reuse Task 1's harness shape); toggling writes through `setColumnAutoWidth` with the RIGHT column id (mutation target); `role="menuitemcheckbox"` + `aria-checked` correct; keyboard operation via the shared `useMenuKeyboard` (no fourth hand-rolled copy); footer button sets ALL columns auto (assert the store effect through the UI, e.g. every kebab now checked); Reset restores (extends Task 1's B4 test through the UI). +- [ ] **Step 1: Failing tests:** the menu shows "Auto width" checked/unchecked per the live set (fixed → unchecked, fluid → checked — reuse Task 1's harness shape); toggling writes through `setColumnAutoWidth` with the RIGHT column id (mutation target); `role="menuitemcheckbox"` + `aria-checked` correct; keyboard operation via the shared `useMenuKeyboard` (no fourth hand-rolled copy); Reset restores (extends Task 1's B4 test through the UI). Assertions state the REAL semantic (auto ⇒ renderer width, manual ⇒ engine width) — never a content-fit claim. - [ ] **Step 2: Implement.** The section already subscribes to engine/layout state — the auto set needs its own subscription (`autoWidths.subscribe/getState` — check what the section can reach; plumb through the descriptor via a stable handle, respecting the DEPS RULE: the set is engine-ish state, so reach it by subscription, never bake it into the memo). - [ ] **Step 3: Mutations:** wire the toggle to a constant column id → the per-column test fails; invert checked-state derivation → the reflect test fails. Revert. - [ ] **Step 4: Run the tool-panel suites + full react suite; format, lint, typecheck; commit** — `feat(react): auto-width toggle and auto-size-all in the columns section (SP5)`. @@ -84,7 +84,7 @@ it("setColumnWidth still flips auto OFF (old behavior survives)", ...); - Modify: `apps/website/e2e/tool-panel.spec.ts` (+ possibly the fixtures page if the walk needs the handle) - Verify: `grid-tab-wrap-rows.spec.ts` untouched-green -- [ ] **Step 1: New cases** (hydration-gated, helpers reused): (a) pointer-drag the seam → pane width changes AND the grid reflows (assert something grid-side: a header cell's x-position or the horizontal scrollbar state — prove the pixel, not the style); (b) keyboard: Tab reaches the handle inside the pane's roster (UPDATE every walk roster the handle joins — with the "update when…" comment style), arrows resize, Enter resets; tab-exit guard still green; (c) auto-width: toggle a column's "Auto width" in the kebab, stream/update content, the header width visibly changes; toggle off → frozen (this is the drawn-width proof if Task 1's jsdom couldn't observe it). +- [ ] **Step 1: New cases** (hydration-gated, helpers reused): (a) pointer-drag the seam → pane width changes AND the grid reflows (assert something grid-side: a header cell's x-position or the horizontal scrollbar state — prove the pixel, not the style); (b) keyboard: Tab reaches the handle inside the pane's roster (UPDATE every walk roster the handle joins — with the "update when…" comment style), arrows resize, Enter resets; tab-exit guard still green; (c) auto-width: toggle a column's "Auto width" in the kebab → the drawn header width moves between the ENGINE's stored width and the RENDERER's width (140/flex share) — the verified mode-bit semantic; content changes must NOT move an auto column's width (assert the negative — it pins the semantic against the content-fit myth). - [ ] **Step 2: Run per the recipe** (root build, website build, prod server on a free port, `--workers=1`, both browsers, twice; isolate any failure). Include `grid-tab-wrap-rows.spec.ts` in the run. - [ ] **Step 3: Format/lint/typecheck website; commit** — `test(e2e): pane resize and auto width through the real shell (SP5)`. @@ -105,6 +105,6 @@ it("setColumnWidth still flips auto OFF (old behavior survives)", ...); ## Self-review notes - Spec coverage: A1→T2 (handle/css), A2→T2 (pointer/Escape/dblclick), A3→T2 (trio), A4→T2 (pure clamp + measured MIN), A5→T2 (null-state inline rule) + T5 docs, A6→T2+T4 (keyboard + rosters), A7→spec-only (no work); B1→T3, B2→T1, B3→T3, B4→T1+T3, B5→T3, B6→T5 docs. Verification section maps to T1–T4 tests + T5 gates. -- Names used consistently: `setColumnAutoWidth`, `defaultPaneWidthPx`/`paneWidthPx`/`onPaneWidthChange`, `pane-resize.ts` functions (T2 defines, T4 exercises), message keys `toolPanelResizeLabel`/`toolPanelAutoWidthLabel`/`toolPanelAutosizeAllLabel` (T2/T3 define, T5 documents). +- Names used consistently: `setColumnAutoWidth`, `defaultPaneWidthPx`/`paneWidthPx`/`onPaneWidthChange`, `pane-resize.ts` functions (T2 defines, T4 exercises), message keys `toolPanelResizeLabel`/`toolPanelAutoWidthLabel` (T2/T3 define, T5 documents). - No engine (grid-core/row-model) changes anywhere — if a task finds itself editing them, stop and escalate. - jsdom's text-measurement limitation is handled explicitly (T1 Step 1's substitution rule + T4's pixel proof), not discovered mid-task. diff --git a/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md b/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md index 45e0745a..06bd19ef 100644 --- a/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md +++ b/docs/superpowers/specs/2026-08-30-tool-panel-resize-autosize.md @@ -22,13 +22,22 @@ Two finishing touches on the tool panel, one PR: `grid.css` (~1462), documented as consumer-overridable; the pane is a "fixed budget the grid area yields", and the virtualizer already observes resize (SP1). -2. Auto width is a **live set**, not a one-shot fit: - `createAutoWidthStore` (pretable-model.ts) holds the set of column ids - in auto mode; columns with no declared `widthPx` START auto; the - renderer sizes auto columns from content continuously. - `autosizeColumns()` on the handle = set ALL columns auto; - `setColumnWidth(id, px)` flips that column to manual. There is no - public per-column auto setter yet. +2. Auto width is a **mode bit, not a content fit** (verified in a real + browser, 2026-08-30 — the first draft of this fact claimed content + tracking and was WRONG): `createAutoWidthStore` (pretable-model.ts) + holds the set of column ids whose width is **grid-managed** — the + engine's stored width is withheld from the renderer + (`mergeRenderColumns` strips `widthPx`), and the renderer draws its + own default (140px, 220px wrapped — `resolveColumnWidth`) or a flex + share when the column declares `flex`. **Nothing measures cell + content anywhere in the column-width path** (the canvas text + measurement feeds row heights only). Columns with no declared + `widthPx` start auto; `setColumnWidth(id, px)` flips to manual; + `autosizeColumns()` marks all columns auto (its name over-promises — + filed for rename, out of scope here). Toggling auto OFF on a + never-resized column visibly jumps 140→160 (grid-core's + `DEFAULT_COLUMN_WIDTH_PX` never matched the renderer fallback — + unification filed, out of scope). 3. The pane's keyboard walks (columns, grouping, custom-section e2e) pin tab-stop rosters, and the tab-exit guard is a hard gate — a new focusable in the pane moves rosters deliberately, never accidentally. @@ -86,15 +95,18 @@ consumers persist via the controlled trio. ## Decisions — B. Auto width -B1. **The kebab item is a TOGGLE named for the real semantic.** Auto width -is continuous, so the menu gains a `role="menuitemcheckbox"` "Auto -width" item, checked when the column is in the auto set. On → -per-column auto; the column starts tracking content. Off → the column -becomes manual at the engine's current stored width (no jump). -Rejected: an AG-style one-shot "Autosize" action — it would -misdescribe the machinery (the store has no one-shot mode), and a -one-shot that silently left the column tracking content would be the -worse lie. +B1. **The kebab item is a TOGGLE named for the real semantic.** The menu +gains a `role="menuitemcheckbox"` "Auto width" item, checked when the +column is in the auto set. Its meaning — stated identically in the +docs — is "let the grid manage this column's width" (renderer default, +or a flex share when the column declares `flex`), NOT "fit to +content". On → grid-managed; off → manual at the engine's current +stored width. The 140→160 jump for a never-resized column is +documented plainly rather than hidden. Rejected: an AG-style one-shot +"Autosize" action — no machinery computes a content fit, and shipping +the word "autosize" over a mode bit would be a lie the toggle avoids; +a true fit-to-content action is new machinery, filed as a future +candidate, out of scope. B2. **Public handle gains `setColumnAutoWidth(columnId, auto)`**, mirroring `setColumnWidth`'s shape and TSDoc conventions; @@ -102,9 +114,12 @@ mirroring `setColumnWidth`'s shape and TSDoc conventions; kebab writes through the new method. (Handle addition → api report moves; docs guard will demand registration.) -B3. **"Auto-size all columns" joins the columns section footer** beside -Reset columns, calling `autosizeColumns()`. Same button anatomy as -Reset. +B3. **DROPPED: no "Auto-size all columns" footer action.** Its value was +predicated on the fit-to-content reading; over a mode bit it is a +low-value mass toggle with a misleading pedigree (`autosizeColumns`'s +name). Per-column control via the kebab suffices; Reset columns +already restores the initial auto set (B4). Revisit only with the +fit-to-content machinery. B4. **Reset columns restores the INITIAL auto set** — audit item: verify whether today's Reset already does (the initial set is "columns @@ -141,16 +156,21 @@ column yourself; the toggle turns tracking back on. toggling on makes a content change ACTUALLY change the drawn width (disprove-capable: two contents with different widths — assert the header cell's width moves, not that a method was called); toggling off - freezes it (content changes, width doesn't); manual header resize - unchecks it on next open; footer Auto-size-all sets every column; - Reset restores the initial set (B4, both directions). Mutation checks - on each: a toggle wired to the wrong column id or inverted must fail. + freezes it at the engine's stored width; manual header resize + unchecks it on next open; Reset restores the initial set (B4, both + directions). The drawn-width proof asserts the REAL semantic: auto ⇒ + the renderer's width (140/flex), manual ⇒ the engine's stored width — + never a content-fit claim. Mutation checks on each: a toggle wired to + the wrong column id or inverted must fail. - **Docs:** tool-panel page — resizing (with the css-override interplay note, A5) and the auto-width pair (B6); configuration table gains the width trio; handle docs gain `setColumnAutoWidth`; api reports regenerate (build before api); docs guards satisfied honestly. - **Changesets:** `@pretable/react` minor (config + handle), `@pretable/ui` patch (css). +- The Task 1 jsdom test header's "content-tracking mode" phrase is + corrected to the mode-bit wording (it currently repeats the myth its + own body debunks). - Assert the old behavior survives: header column-resize, the kebab's pin actions, Reset's existing restores, and the three keyboard walks. From 4bda6d266fc5317df1da0a77ac704acb1925f8b0 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Sun, 30 Aug 2026 13:27:29 -0700 Subject: [PATCH 05/11] feat(react): the tool-panel pane resizes by pointer and keyboard (SP5) Co-Authored-By: Claude Fable 5 --- .../src/__tests__/attribute-contract.test.tsx | 5 + .../tool-panel-pane-resize-math.test.ts | 106 ++++++ .../__tests__/tool-panel-pane-resize.test.tsx | 330 ++++++++++++++++++ .../react/src/__tests__/tool-panel.test.tsx | 8 + packages/react/src/messages.ts | 1 + packages/react/src/pretable-surface.tsx | 139 +++++++- packages/react/src/tool-panel/ToolPanel.tsx | 248 ++++++++++++- packages/react/src/tool-panel/pane-resize.ts | 106 ++++++ packages/ui/grid.css | 53 +++ 9 files changed, 994 insertions(+), 2 deletions(-) create mode 100644 packages/react/src/__tests__/tool-panel-pane-resize-math.test.ts create mode 100644 packages/react/src/__tests__/tool-panel-pane-resize.test.tsx create mode 100644 packages/react/src/tool-panel/pane-resize.ts diff --git a/packages/react/src/__tests__/attribute-contract.test.tsx b/packages/react/src/__tests__/attribute-contract.test.tsx index 8aca3481..d1eced8f 100644 --- a/packages/react/src/__tests__/attribute-contract.test.tsx +++ b/packages/react/src/__tests__/attribute-contract.test.tsx @@ -99,6 +99,11 @@ describe("attribute contract", () => { expect( container.querySelector("[data-pretable-tool-grouping]"), ).not.toBeNull(); + // The pane's resize handle (SP5) renders with any open pane — asserted + // here so the sweep covers data-pretable-pane-resize non-vacuously. + expect( + container.querySelector('[role="separator"][data-pretable-pane-resize]'), + ).not.toBeNull(); expect( container.querySelector("[data-pretable-tool-group-row]"), ).not.toBeNull(); diff --git a/packages/react/src/__tests__/tool-panel-pane-resize-math.test.ts b/packages/react/src/__tests__/tool-panel-pane-resize-math.test.ts new file mode 100644 index 00000000..81f456f2 --- /dev/null +++ b/packages/react/src/__tests__/tool-panel-pane-resize-math.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; + +import { + PANE_KEY_STEP_PX, + clampPaneWidth, + paneWidthAfterDrag, + paneWidthAfterKey, +} from "../tool-panel/pane-resize"; + +const BOUNDS = { min: 200, max: 600 }; +const FLOOR_ONLY = { min: 200, max: null }; + +describe("clampPaneWidth", () => { + test.each([ + ["below min clamps up", 120, BOUNDS, 200], + ["above max clamps down", 900, BOUNDS, 600], + ["in bounds is identity", 300, BOUNDS, 300], + ["min itself survives", 200, BOUNDS, 200], + ["max itself survives", 600, BOUNDS, 600], + // max: null = the surface has no measurement yet (jsdom, pre-mount). + // Only the floor applies — never an invented ceiling. + ["floor-only: large value passes", 5000, FLOOR_ONLY, 5000], + ["floor-only: min still applies", 10, FLOOR_ONLY, 200], + ] as const)("%s", (_name, px, bounds, expected) => { + expect(clampPaneWidth(px, bounds)).toBe(expected); + }); + + test("fractional drag positions round to whole pixels", () => { + expect(clampPaneWidth(300.6, BOUNDS)).toBe(301); + }); +}); + +describe("paneWidthAfterKey", () => { + // A6: "grow the pane" is the arrow that drags the seam that way. The pane + // docks at the layout row's inline END, so its resize seam moves toward + // inline-start as the pane grows: in ltr that is ArrowLeft, in rtl + // ArrowRight. These rtl rows are the mutation target — an inverted + // direction resolution must fail them. + test.each([ + ["ltr ArrowLeft grows", "ArrowLeft", 300, "ltr", 300 + PANE_KEY_STEP_PX], + [ + "ltr ArrowRight shrinks", + "ArrowRight", + 300, + "ltr", + 300 - PANE_KEY_STEP_PX, + ], + ["rtl ArrowRight grows", "ArrowRight", 300, "rtl", 300 + PANE_KEY_STEP_PX], + ["rtl ArrowLeft shrinks", "ArrowLeft", 300, "rtl", 300 - PANE_KEY_STEP_PX], + ["grow clamps at max", "ArrowLeft", 590, "ltr", 600], + ["shrink clamps at min", "ArrowRight", 210, "ltr", 200], + ["rtl grow clamps at max", "ArrowRight", 590, "rtl", 600], + ["Home goes to min", "Home", 300, "ltr", 200], + ["End goes to max", "End", 300, "ltr", 600], + ["Home in rtl still goes to min", "Home", 300, "rtl", 200], + ] as const)("%s", (_name, key, current, dir, expected) => { + expect(paneWidthAfterKey(key, current, { min: 200, max: 600, dir })).toBe( + expected, + ); + }); + + test("End with an unmeasured max is a no-op (no invented ceiling)", () => { + expect( + paneWidthAfterKey("End", 300, { min: 200, max: null, dir: "ltr" }), + ).toBe(300); + }); + + test("a grow step with an unmeasured max still steps", () => { + expect( + paneWidthAfterKey("ArrowLeft", 300, { min: 200, max: null, dir: "ltr" }), + ).toBe(300 + PANE_KEY_STEP_PX); + }); + + test.each(["ArrowUp", "ArrowDown", "Enter", "a", "Escape"])( + "non-resize key %s returns null", + (key) => { + expect( + paneWidthAfterKey(key, 300, { min: 200, max: 600, dir: "ltr" }), + ).toBeNull(); + }, + ); +}); + +describe("paneWidthAfterDrag", () => { + // Same seam geometry as the keys: in ltr the pointer moving LEFT + // (currentX < startX) grows the pane; in rtl the pointer moving RIGHT + // grows it. The rtl rows are the mutation target. + test.each([ + ["ltr: pointer left grows", 300, 500, 440, "ltr", 360], + ["ltr: pointer right shrinks", 300, 500, 560, "ltr", 240], + ["rtl: pointer right grows", 300, 500, 560, "rtl", 360], + ["rtl: pointer left shrinks", 300, 500, 440, "rtl", 240], + ["no movement is identity", 300, 500, 500, "ltr", 300], + ["ltr grow clamps at max", 300, 500, 100, "ltr", 600], + ["ltr shrink clamps at min", 300, 500, 900, "ltr", 200], + ["rtl shrink clamps at min", 300, 500, 100, "rtl", 200], + ] as const)("%s", (_name, startWidth, startX, currentX, dir, expected) => { + expect(paneWidthAfterDrag(startWidth, startX, currentX, dir, BOUNDS)).toBe( + expected, + ); + }); + + test("floor-only bounds never cap a grow drag", () => { + expect(paneWidthAfterDrag(300, 500, 0, "ltr", FLOOR_ONLY)).toBe(800); + }); +}); diff --git a/packages/react/src/__tests__/tool-panel-pane-resize.test.tsx b/packages/react/src/__tests__/tool-panel-pane-resize.test.tsx new file mode 100644 index 00000000..145fabbc --- /dev/null +++ b/packages/react/src/__tests__/tool-panel-pane-resize.test.tsx @@ -0,0 +1,330 @@ +/** + * The pane resize handle through the real surface (SP5, spec A1–A6): the + * separator contract, the A5 no-inline-style-until-someone-acts rule, drag / + * Escape / double-click / Enter, keyboard steps in both writing directions, + * and the controlled trio's clamp-and-report. + * + * jsdom cannot lay out, so the pane measures 0 wide and the surface's max + * bound stays unmeasured (`max: null`) throughout — every ceiling behavior + * here exercises the floor and the unmeasured-max degradations; the real + * ceiling is the pure module's table (tool-panel-pane-resize-math.test.ts) + * plus the Playwright seam drag (Task 4). Widths asserted below are style + * ATTRIBUTE text (`inline-size: Npx`): the inline style is the feature. + */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { PretableSurface } from "../public_api"; +import type { PretableColumn, PretableToolPanelConfig } from "../public_api"; +import { PANE_MIN_WIDTH_PX, PANE_KEY_STEP_PX } from "../tool-panel/pane-resize"; + +type Row = { id: string; name: string; amount: number }; + +const columns: PretableColumn[] = [ + { id: "name", header: "Name" }, + { id: "amount", header: "Amount" }, +]; +const rows: Row[] = [ + { id: "r1", name: "Alpha", amount: 1 }, + { id: "r2", name: "Beta", amount: 2 }, +]; + +function renderSurface(toolPanel: PretableToolPanelConfig = {}, dir?: "rtl") { + const surface = ( + r.id} + toolPanel={{ defaultActiveSection: "columns", ...toolPanel }} + viewportHeight={300} + /> + ); + return render(dir === "rtl" ?
{surface}
: surface); +} + +const handleOf = (container: HTMLElement) => + container.querySelector("[data-pretable-pane-resize]"); +const paneOf = (container: HTMLElement) => + container.querySelector("[data-pretable-tool-pane]"); +const paneWidth = (container: HTMLElement) => { + const style = paneOf(container)?.getAttribute("style") ?? ""; + const m = /inline-size:\s*([0-9.]+)px/.exec(style); + return m === null ? null : Number(m[1]); +}; + +describe("the resize handle's contract", () => { + it("renders in the open pane as a focusable vertical separator", () => { + const { container } = renderSurface(); + const handle = handleOf(container); + expect(handle).not.toBeNull(); + expect(handle).toHaveAttribute("role", "separator"); + expect(handle).toHaveAttribute("aria-orientation", "vertical"); + expect(handle).toHaveAttribute("aria-label", "Resize tool panel"); + expect(handle?.tabIndex).toBe(0); + expect(handle).toHaveAttribute("aria-valuemin", String(PANE_MIN_WIDTH_PX)); + // Unmeasured surface (jsdom): no max, and no invented valuenow. + expect(handle).not.toHaveAttribute("aria-valuemax"); + expect(handle).not.toHaveAttribute("aria-valuenow"); + }); + + it("does not render while the pane is closed", () => { + const { container } = renderSurface({ defaultActiveSection: null }); + expect(handleOf(container)).toBeNull(); + }); + + it("reflects the committed width in aria-valuenow", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + expect(handleOf(container)).toHaveAttribute("aria-valuenow", "300"); + }); +}); + +describe("no inline style until someone acts (A5)", () => { + it("writes no inline width untouched, and one after a keystroke", () => { + const { container } = renderSurface(); + expect(paneWidth(container)).toBeNull(); + fireEvent.keyDown(handleOf(container)!, { key: "ArrowLeft" }); + // Untouched + unmeasurable pane: the gesture starts from the floor. + expect(paneWidth(container)).toBe(PANE_MIN_WIDTH_PX + PANE_KEY_STEP_PX); + }); + + it("seeds an inline width from defaultPaneWidthPx", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + expect(paneWidth(container)).toBe(300); + }); +}); + +describe("keyboard resize (A6)", () => { + it("ltr: ArrowLeft grows, ArrowRight shrinks, Home floors", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(paneWidth(container)).toBe(300 + PANE_KEY_STEP_PX); + fireEvent.keyDown(handle, { key: "ArrowRight" }); + fireEvent.keyDown(handle, { key: "ArrowRight" }); + expect(paneWidth(container)).toBe(300 - PANE_KEY_STEP_PX); + fireEvent.keyDown(handle, { key: "Home" }); + expect(paneWidth(container)).toBe(PANE_MIN_WIDTH_PX); + }); + + it("rtl: the arrows flip with the writing direction", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }, "rtl"); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowRight" }); + expect(paneWidth(container)).toBe(300 + PANE_KEY_STEP_PX); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(paneWidth(container)).toBe(300 - PANE_KEY_STEP_PX); + }); + + it("a shrink step clamps at the floor", () => { + const { container } = renderSurface({ + defaultPaneWidthPx: PANE_MIN_WIDTH_PX + 4, + }); + fireEvent.keyDown(handleOf(container)!, { key: "ArrowRight" }); + expect(paneWidth(container)).toBe(PANE_MIN_WIDTH_PX); + }); + + it("End with an unmeasured max is a no-op", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ + defaultPaneWidthPx: 300, + onPaneWidthChange, + }); + fireEvent.keyDown(handleOf(container)!, { key: "End" }); + expect(paneWidth(container)).toBe(300); + expect(onPaneWidthChange).not.toHaveBeenCalled(); + }); + + it("Enter resets to the default width", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(paneWidth(container)).toBe(300 + PANE_KEY_STEP_PX); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(paneWidth(container)).toBe(300); + }); + + it("Enter with no default clears back to the stylesheet width", () => { + const { container } = renderSurface(); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(paneWidth(container)).not.toBeNull(); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(paneWidth(container)).toBeNull(); + }); +}); + +describe("pointer drag (A2)", () => { + const drag = ( + handle: HTMLElement, + moves: readonly number[], + startX = 500, + ) => { + fireEvent.pointerDown(handle, { + button: 0, + pointerId: 1, + clientX: startX, + }); + for (const clientX of moves) { + fireEvent.pointerMove(handle, { pointerId: 1, clientX }); + } + }; + + it("applies the width live and keeps it on release", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + drag(handle, [440]); + // ltr: 60px toward inline-start grows the pane. + expect(paneWidth(container)).toBe(360); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 440 }); + expect(paneWidth(container)).toBe(360); + }); + + it("rtl: the same pointer travel shrinks instead", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }, "rtl"); + const handle = handleOf(container)!; + drag(handle, [440]); + expect(paneWidth(container)).toBe(240); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 440 }); + }); + + it("clamps a runaway shrink at the floor", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + drag(handle, [2000]); + expect(paneWidth(container)).toBe(PANE_MIN_WIDTH_PX); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 2000 }); + }); + + it("Escape mid-drag restores the drag-start width", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + drag(handle, [440]); + expect(paneWidth(container)).toBe(360); + fireEvent.keyDown(handle, { key: "Escape" }); + expect(paneWidth(container)).toBe(300); + // The cancelled gesture is over: further moves change nothing. + fireEvent.pointerMove(handle, { pointerId: 1, clientX: 400 }); + expect(paneWidth(container)).toBe(300); + }); + + it("Escape reaches a drag even when focus sits elsewhere", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + drag(handleOf(container)!, [440]); + fireEvent.keyDown(document.body, { key: "Escape" }); + expect(paneWidth(container)).toBe(300); + }); + + it("a drag from the untouched state restores to NO inline width", () => { + const { container } = renderSurface(); + const handle = handleOf(container)!; + drag(handle, [400]); + expect(paneWidth(container)).not.toBeNull(); + fireEvent.keyDown(handle, { key: "Escape" }); + expect(paneWidth(container)).toBeNull(); + }); + + it("double-click resets to the default width", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + fireEvent.dblClick(handle); + expect(paneWidth(container)).toBe(300); + }); + + it("double-click with no default restores the stylesheet width", () => { + const { container } = renderSurface(); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + fireEvent.dblClick(handle); + expect(paneWidth(container)).toBeNull(); + }); + + it("the dblclick a real drag's release fires does not reset", () => { + const { container } = renderSurface({ defaultPaneWidthPx: 300 }); + const handle = handleOf(container)!; + drag(handle, [440]); + fireEvent.pointerUp(handle, { pointerId: 1, clientX: 440 }); + fireEvent.dblClick(handle); + expect(paneWidth(container)).toBe(360); + }); +}); + +describe("the controlled trio (A3, A4)", () => { + it("asserts through paneWidthPx and only reports gestures", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ + paneWidthPx: 320, + onPaneWidthChange, + }); + expect(paneWidth(container)).toBe(320); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + // Reported, not applied — the parent did not accept the change. + expect(onPaneWidthChange).toHaveBeenCalledWith(320 + PANE_KEY_STEP_PX); + expect(paneWidth(container)).toBe(320); + }); + + it("paneWidthPx: null holds the stylesheet width under gestures", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ + paneWidthPx: null, + onPaneWidthChange, + }); + expect(paneWidth(container)).toBeNull(); + fireEvent.keyDown(handleOf(container)!, { key: "ArrowLeft" }); + expect(onPaneWidthChange).toHaveBeenCalledWith( + PANE_MIN_WIDTH_PX + PANE_KEY_STEP_PX, + ); + expect(paneWidth(container)).toBeNull(); + }); + + it("an out-of-bounds controlled width renders clamped and reports the clamp", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ + paneWidthPx: 50, + onPaneWidthChange, + }); + expect(paneWidth(container)).toBe(PANE_MIN_WIDTH_PX); + expect(handleOf(container)).toHaveAttribute( + "aria-valuenow", + String(PANE_MIN_WIDTH_PX), + ); + expect(onPaneWidthChange).toHaveBeenCalledTimes(1); + expect(onPaneWidthChange).toHaveBeenCalledWith(PANE_MIN_WIDTH_PX); + }); + + it("an out-of-bounds DEFAULT renders clamped and reports too", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ + defaultPaneWidthPx: 50, + onPaneWidthChange, + }); + expect(paneWidth(container)).toBe(PANE_MIN_WIDTH_PX); + expect(onPaneWidthChange).toHaveBeenCalledWith(PANE_MIN_WIDTH_PX); + }); + + it("uncontrolled gestures report the committed width", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ + defaultPaneWidthPx: 300, + onPaneWidthChange, + }); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + expect(onPaneWidthChange).toHaveBeenLastCalledWith(300 + PANE_KEY_STEP_PX); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(onPaneWidthChange).toHaveBeenLastCalledWith(300); + }); + + it("a reset with no default reports null", () => { + const onPaneWidthChange = vi.fn(); + const { container } = renderSurface({ onPaneWidthChange }); + const handle = handleOf(container)!; + fireEvent.keyDown(handle, { key: "ArrowLeft" }); + fireEvent.keyDown(handle, { key: "Enter" }); + expect(onPaneWidthChange).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/packages/react/src/__tests__/tool-panel.test.tsx b/packages/react/src/__tests__/tool-panel.test.tsx index d5f3f157..84811f90 100644 --- a/packages/react/src/__tests__/tool-panel.test.tsx +++ b/packages/react/src/__tests__/tool-panel.test.tsx @@ -68,15 +68,23 @@ function Host({ onChange?: (next: string | null) => void; }) { const [active, setActive] = useState(initial); + // The pane-width props are the surface's; this harness pins the untouched + // state (no inline width, floor-only bounds). The resize behavior itself is + // tested through the real surface in tool-panel-pane-resize.test.tsx. return ( { onChange?.(next); setActive(next); }} + paneWidthPx={null} + paneBounds={{ min: 186, max: null }} + onPaneWidthChange={() => {}} + onPaneWidthReset={() => {}} /> ); } diff --git a/packages/react/src/messages.ts b/packages/react/src/messages.ts index b0d78173..87ef9df4 100644 --- a/packages/react/src/messages.ts +++ b/packages/react/src/messages.ts @@ -117,6 +117,7 @@ export const defaultMessages: Required = { toolPanelFilterJoinActionLabel: ({ opLabel, nextLabel }) => `${opLabel}, join all conditions in this list with ${nextLabel}`, toolPanelGroupingLabel: () => "Grouping", + toolPanelResizeLabel: () => "Resize tool panel", toolPanelGroupByLabel: () => "Group by", toolPanelAddRowGroupLabel: () => "Add group", toolPanelRemoveGroupLabel: ({ label }) => `Remove grouping by ${label}`, diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index b2a4a723..32ddc963 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -128,6 +128,7 @@ import { ToolPanel, resolveToolPanelRoster, } from "./tool-panel"; +import { PANE_MIN_WIDTH_PX, clampPaneWidth } from "./tool-panel/pane-resize"; import { FiltersSection } from "./tool-panel/filters"; import type { FilterRowColumn } from "./tool-panel/filters"; import { GroupingSection } from "./tool-panel/grouping"; @@ -668,6 +669,12 @@ export interface PretableSurfaceMessages { toolPanelFiltersLabel?: () => string; /** The grouping section's tab label — its `aria-label` and tooltip text. */ toolPanelGroupingLabel?: () => string; + /** + * Accessible name of the pane's width-resize handle (`role="separator"` on + * the pane's inline-start edge). Shell-level like `toolPanelLabel`, not a + * section string: the handle belongs to the pane, whichever section is open. + */ + toolPanelResizeLabel?: () => string; // ---- Tool panel: columns section --------------------------------------- @@ -889,6 +896,32 @@ export interface PretableToolPanelConfig { readonly onActiveSectionChange?: ( section: PretableToolPanelSectionId | null, ) => void; + /** + * Seeds the pane's width for the uncontrolled form, in px. Absent, the pane + * keeps its STYLESHEET width until the user first resizes it — no inline + * style is written, so the documented css override + * (`[data-pretable-tool-pane] { inline-size: … }`) keeps working exactly as + * before. Present (or after any resize), the width is an inline + * `inline-size`, which outranks every stylesheet rule. + */ + readonly defaultPaneWidthPx?: number; + /** + * The pane's width, in px — present (including `null`) makes it fully + * controlled, the `activeSection` contract: the handle then only reports + * through `onPaneWidthChange`. `null` means "the stylesheet width" — no + * inline style, the untouched state. A number outside the pane's bounds + * renders CLAMPED, and the clamped width is reported back once through + * `onPaneWidthChange` so a persisted layout converges on what is actually + * drawn. + */ + readonly paneWidthPx?: number | null; + /** + * Reports every pane-width change, already clamped: live during a drag, + * per keystroke on the handle, and `null` when a reset (double-click or + * Enter on the handle, with no `defaultPaneWidthPx`) returns the pane to + * its stylesheet width. + */ + readonly onPaneWidthChange?: (widthPx: number | null) => void; } const ANNOUNCE_DEBOUNCE_MS = 500; @@ -2072,6 +2105,8 @@ export function PretableSurface< toolPanelGroupingLabel: messages?.toolPanelGroupingLabel ?? defaultMessages.toolPanelGroupingLabel, + toolPanelResizeLabel: + messages?.toolPanelResizeLabel ?? defaultMessages.toolPanelResizeLabel, toolPanelGroupByLabel: messages?.toolPanelGroupByLabel ?? defaultMessages.toolPanelGroupByLabel, @@ -2148,6 +2183,75 @@ export function PretableSurface< controlledToolSection !== undefined ? controlledToolSection : uncontrolledToolSection; + // ---- Tool panel pane width (SP5) --------------------------------------- + // Chrome state on the `activeSection` pattern. `null` is a real state, not + // an absence: "untouched — keep the stylesheet width, write no inline + // style" (spec A5), which is what keeps the documented css override on + // `[data-pretable-tool-pane]` working until someone actually resizes. + const [uncontrolledPaneWidth, setUncontrolledPaneWidth] = useState< + number | null + >(() => toolPanelConfig?.defaultPaneWidthPx ?? null); + const controlledPaneWidth = toolPanelConfig?.paneWidthPx; + const rawPaneWidth = + controlledPaneWidth !== undefined + ? controlledPaneWidth + : uncontrolledPaneWidth; + // The dynamic max needs the tool layout row's rendered width. Observed the + // way the viewport observes its own (`viewportWidth` below): a ref plus a + // ResizeObserver, held as state so aria-valuemax and the clamp re-derive on + // a container resize. `null` until the observer's first delivery — jsdom, + // SSR, and the pre-mount render all live here — and while unmeasured the + // bounds degrade to the floor alone: `clampPaneWidth` treats `max: null` + // as "no ceiling", because clamping to an unmeasured guess would report a + // width nobody chose (the rule is stated on `PaneWidthBounds`). + // + // A CALLBACK ref rather than an effect over a plain one: the layout row + // mounts and unmounts with the panel (`toolPanel={false}`, an emptied + // roster), and a mount-time effect would never see a later attach. + const [toolLayoutWidth, setToolLayoutWidth] = useState(null); + const toolLayoutObserverRef = useRef(null); + const observeToolLayout = useCallback((el: HTMLDivElement | null) => { + toolLayoutObserverRef.current?.disconnect(); + toolLayoutObserverRef.current = null; + if (el === null || typeof ResizeObserver === "undefined") { + setToolLayoutWidth(null); + return; + } + const read = () => + setToolLayoutWidth(el.clientWidth === 0 ? null : el.clientWidth); + read(); + const observer = new ResizeObserver(read); + observer.observe(el); + toolLayoutObserverRef.current = observer; + }, []); + const paneBounds = useMemo(() => { + if (toolLayoutWidth === null) { + return { min: PANE_MIN_WIDTH_PX, max: null }; + } + // The grid keeps `surface − rail − pane`: 36 mirrors the rail's + // stylesheet `inline-size` (grid.css), and 160 is the grid-area floor — + // room for one 40px-minimum column beside a pinned selection column with + // the scrollbar's gutter, a UX floor rather than a contract. `max` never + // drops below `min`, or a small container would make the clamp oscillate. + const max = Math.max(PANE_MIN_WIDTH_PX, toolLayoutWidth - 36 - 160); + return { min: PANE_MIN_WIDTH_PX, max }; + }, [toolLayoutWidth]); + const appliedPaneWidth = + rawPaneWidth === null ? null : clampPaneWidth(rawPaneWidth, paneBounds); + // Clamp-and-report (spec A4): an incoming controlled/default width outside + // bounds renders clamped, and the clamped value is reported back so a + // persisted layout converges on what is actually drawn. The callback rides + // a ref so an inline `toolPanel={{...}}` object — a new identity every + // render — cannot re-fire the report; the effect keys on the widths alone. + const paneWidthChangeRef = useRef(toolPanelConfig?.onPaneWidthChange); + useEffect(() => { + paneWidthChangeRef.current = toolPanelConfig?.onPaneWidthChange; + }); + useEffect(() => { + if (rawPaneWidth === null || appliedPaneWidth === null) return; + if (appliedPaneWidth === rawPaneWidth) return; + paneWidthChangeRef.current?.(appliedPaneWidth); + }, [rawPaneWidth, appliedPaneWidth]); // `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 @@ -7533,12 +7637,17 @@ export function PretableSurface< // (which re-derives `viewportWidth` from `clientWidth`) reflows the columns // and the right-pinned insets, exactly as it does for a container resize. return ( -
+
{verticalStack}
{ @@ -7547,6 +7656,34 @@ export function PretableSurface< } toolPanelConfig?.onActiveSectionChange?.(next); }} + paneWidthPx={appliedPaneWidth} + paneBounds={paneBounds} + // Every write path clamps then reports — the shell hands over raw + // gesture output, and this is the one place the bounds are applied + // so the controlled and uncontrolled forms cannot drift. + onPaneWidthChange={(next) => { + const clamped = + next === null ? null : clampPaneWidth(next, paneBounds); + if (controlledPaneWidth === undefined) { + setUncontrolledPaneWidth(clamped); + } + toolPanelConfig?.onPaneWidthChange?.(clamped); + }} + // Reset (double-click / Enter on the handle): back to + // `defaultPaneWidthPx` when the consumer gave one, else to `null` — + // the untouched state, which clears the inline style and restores the + // stylesheet width (spec A5). + onPaneWidthReset={() => { + const fallback = toolPanelConfig?.defaultPaneWidthPx; + const next = + fallback === undefined + ? null + : clampPaneWidth(fallback, paneBounds); + if (controlledPaneWidth === undefined) { + setUncontrolledPaneWidth(next); + } + toolPanelConfig?.onPaneWidthChange?.(next); + }} />
); diff --git a/packages/react/src/tool-panel/ToolPanel.tsx b/packages/react/src/tool-panel/ToolPanel.tsx index af8152f9..d30e00bb 100644 --- a/packages/react/src/tool-panel/ToolPanel.tsx +++ b/packages/react/src/tool-panel/ToolPanel.tsx @@ -1,9 +1,49 @@ -import { createElement, Fragment, useId, useRef } from "react"; +import type { PointerEvent as ReactPointerEvent } from "react"; +import { + createElement, + Fragment, + useCallback, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; import { focusTab } from "./focus"; +import type { PaneResizeDirection, PaneWidthBounds } from "./pane-resize"; +import { paneWidthAfterDrag, paneWidthAfterKey } from "./pane-resize"; import { Rail } from "./Rail"; import type { ToolPanelSectionDescriptor } from "./sections"; +/** + * The writing direction the resize arithmetic keys on, read from the + * RENDERED handle. The `dir` attribute is consulted before the computed + * style, not after: an explicit `dir="rtl"` is the common way a consumer + * flips the surface, and jsdom — where the keyboard tests run — does not + * cascade the attribute into `getComputedStyle().direction`. + */ +function resolveDirection(el: Element): PaneResizeDirection { + const attr = el.closest("[dir]")?.getAttribute("dir")?.toLowerCase(); + if (attr === "rtl") return "rtl"; + if (attr === "ltr") return "ltr"; + const view = el.ownerDocument.defaultView; + return view?.getComputedStyle(el).direction === "rtl" ? "rtl" : "ltr"; +} + +/** A pane-width drag in flight. `startRaw` is what Escape restores. */ +interface PaneResizeDrag { + pointerId: number; + startX: number; + /** The committed width at pointerdown — `null` = the stylesheet width. */ + startRaw: number | null; + /** The RENDERED width at pointerdown, the drag arithmetic's origin. */ + startWidth: number; + dir: PaneResizeDirection; + /** True once any move changed the width — gates the dblclick reset. */ + moved: boolean; +} + export interface ToolPanelProps { sections: readonly ToolPanelSectionDescriptor[]; /** @@ -27,6 +67,25 @@ export interface ToolPanelProps { * directory may default a user-facing string of its own. */ railLabel: string; + /** Accessible name for the pane's resize handle — same messages-layer rule. */ + resizeLabel: string; + /** + * The pane's width, already clamped by the surface, or `null` for "the + * stylesheet width — write no inline style" (spec A5). Fully controlled + * like `activeSection`, and for the same reason. + */ + paneWidthPx: number | null; + /** The surface's bounds — `max: null` while its width is unmeasured. */ + paneBounds: PaneWidthBounds; + /** + * Reports raw gesture output: live during a drag, per keystroke, and the + * drag-start width on an Escape cancel (`null` when the drag started from + * the stylesheet width). The SURFACE clamps — one clamp site for the + * controlled and uncontrolled forms both. + */ + onPaneWidthChange: (next: number | null) => void; + /** Double-click or Enter on the handle — the surface owns what "default" is. */ + onPaneWidthReset: () => void; } /** @@ -46,25 +105,155 @@ export function ToolPanel({ activeSection, onActiveSectionChange, railLabel, + resizeLabel, + paneWidthPx, + paneBounds, + onPaneWidthChange, + onPaneWidthReset, }: ToolPanelProps) { const baseId = useId(); const paneId = `${baseId}-pane`; const tabId = (id: string) => `${baseId}-tab-${id}`; const railRef = useRef(null); + const paneRef = useRef(null); const active = activeSection == null ? undefined : sections.find((s) => s.id === activeSection); + // What the pane measures while it wears no inline width — the stylesheet + // width, whatever the consumer's css says it is. This is aria-valuenow's + // fallback and the origin of a drag or keystroke that starts from the + // untouched state; `null` where layout cannot answer (jsdom measures 0), + // in which case aria-valuenow is omitted rather than invented and the + // first gesture starts from the floor. + const [measuredPaneWidth, setMeasuredPaneWidth] = useState( + null, + ); + const activeId = active?.id; + useLayoutEffect(() => { + if (activeId === undefined || paneWidthPx !== null) return; + const el = paneRef.current; + if (el === null) return; + const width = Math.round(el.getBoundingClientRect().width); + setMeasuredPaneWidth(width === 0 ? null : width); + }, [activeId, paneWidthPx]); + + const dragRef = useRef(null); + /** True across the release that ends a real drag — gates the dblclick reset. */ + const wasResizingRef = useRef(false); + /** Render state only so the Escape document listener below can arm. */ + const [dragActive, setDragActive] = useState(false); + const currentWidth = paneWidthPx ?? measuredPaneWidth ?? paneBounds.min; + + const cancelDrag = useCallback(() => { + const drag = dragRef.current; + if (drag === null) return; + dragRef.current = null; + setDragActive(false); + // Abandoning the gesture is NOT the restore here, unlike the row drags: + // this drag commits continuously (spec A2, live resize), so the restore + // is an explicit write of the drag-start width — `null` included, which + // returns a pane that started untouched to its stylesheet width. + onPaneWidthChange(drag.startRaw); + }, [onPaneWidthChange]); + + // Escape mid-drag on a document listener, the register's pattern + // (`useToolRowDrag`): pointer capture routes the MOVES here, but keyboard + // events follow DOM focus, which a captured drag can leave anywhere. The + // handle's own keydown below handles the focused-on-the-handle case first + // and stops propagation — its comment carries the interlock with the + // pane's Escape-to-rail-tab courtesy; this listener is the catch-all for + // focus parked elsewhere, where `preventDefault` is what tells that pane + // handler (which skips defaultPrevented events) to stand down when it has + // not already run. + useEffect(() => { + if (!dragActive) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" && event.key !== "Esc") return; + event.preventDefault(); + cancelDrag(); + }; + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [dragActive, cancelDrag]); + + const handlePointerDown = (event: ReactPointerEvent) => { + if (event.button !== 0) return; + const startWidth = + paneWidthPx ?? + (paneRef.current + ? Math.round(paneRef.current.getBoundingClientRect().width) + : 0); + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startRaw: paneWidthPx, + startWidth, + dir: resolveDirection(event.currentTarget), + moved: false, + }; + wasResizingRef.current = false; + setDragActive(true); + // Capture at pointerdown, the register's rule (`useToolRowDrag`): engines + // rAF-coalesce pointermoves, so under load the first delivered move can + // already be off this slim strip, and a capture taken any later never + // happens — the drag silently dies. + try { + event.currentTarget.setPointerCapture(event.pointerId); + } catch { + // jsdom, or a pointer type without capture — no-op. + } + }; + + const handlePointerMove = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (drag === null || event.pointerId !== drag.pointerId) return; + const next = paneWidthAfterDrag( + drag.startWidth, + drag.startX, + event.clientX, + drag.dir, + paneBounds, + ); + if (next !== drag.startWidth) drag.moved = true; + // Applied LIVE — the width goes through the surface's state on every + // move (chrome-cheap; the grid reflows through the virtualizer's resize + // observer), so release has nothing left to commit. + onPaneWidthChange(next); + }; + + const handlePointerUp = (event: ReactPointerEvent) => { + const drag = dragRef.current; + if (drag === null || event.pointerId !== drag.pointerId) return; + dragRef.current = null; + setDragActive(false); + // Survives into the dblclick that two fast press-drag-releases fire — + // the header resize strip's `wasResizing` guard, same reason. + wasResizingRef.current = drag.moved; + try { + event.currentTarget.releasePointerCapture(drag.pointerId); + } catch { + // jsdom — no-op. + } + }; + return ( <> {active !== undefined ? (
+
{ + // A drag that moved ends with two fast clicks often enough for + // the header's `wasResizing` guard to exist; same rule here. + if (wasResizingRef.current) { + wasResizingRef.current = false; + return; + } + onPaneWidthReset(); + }} + onKeyDown={(event) => { + // Escape mid-drag restores the drag-start width. Handled on + // the handle itself — pointerdown gives it DOM focus in real + // browsers — with `stopPropagation` as the interlock so the + // pane's own Escape handler above does not also yank focus to + // the rail tab over a mere gesture cancel. A cancel with focus + // parked elsewhere loses only the courtesy restore; release + // still ends the drag at the live width. + if (event.key === "Escape" || event.key === "Esc") { + if (dragRef.current !== null) { + event.preventDefault(); + event.stopPropagation(); + cancelDrag(); + } + return; + } + if (event.key === "Enter") { + event.preventDefault(); + onPaneWidthReset(); + return; + } + const next = paneWidthAfterKey(event.key, currentWidth, { + min: paneBounds.min, + max: paneBounds.max, + dir: resolveDirection(event.currentTarget), + }); + if (next === null) return; + event.preventDefault(); + if (next !== currentWidth || paneWidthPx === null) { + onPaneWidthChange(next); + } + }} + />
{active.render()}
) : null} diff --git a/packages/react/src/tool-panel/pane-resize.ts b/packages/react/src/tool-panel/pane-resize.ts new file mode 100644 index 00000000..40b344b4 --- /dev/null +++ b/packages/react/src/tool-panel/pane-resize.ts @@ -0,0 +1,106 @@ +/** + * The pane-resize arithmetic, React-free — the clamp, the keyboard step and + * the drag delta live here so the direction flip (the mutation-prone branch) + * is table-testable without a DOM. The handle in `ToolPanel.tsx` owns the + * gesture (capture, Escape, commit) and calls in for every number it applies. + * + * ## Direction (spec A6) + * + * The pane docks at the layout row's inline END, so the resize seam — the + * pane's inline-start edge — moves toward inline-start as the pane grows. + * "Grow the pane" is therefore the arrow that drags the seam that way in the + * current writing direction: ArrowLeft in `ltr`, ArrowRight in `rtl`; a drag + * grows the pane as the pointer travels toward inline-start. Both key and + * drag share `growthSign` so the two inputs cannot be inverted independently. + */ + +/** Writing direction, resolved by the caller from the rendered handle. */ +export type PaneResizeDirection = "ltr" | "rtl"; + +export interface PaneWidthBounds { + readonly min: number; + /** + * `null` = the surface has no width measurement yet (first render, jsdom, a + * detached observer). Only the floor applies then — a clamp must never + * invent a ceiling, or a controlled width asserted before the observer's + * first delivery would be "clamped" to a number nobody chose. + */ + readonly max: number | null; +} + +/** + * The pane's width floor, in px. + * + * Measured (2026-08-30, Chromium via the `fixtures/tool-panel-sections` + * route) against the filters section's narrowest usable row, per spec A4: + * the filter row wraps, so its floor is set by the widest control that must + * fit a wrapped line alone — the operator `