From 27e81492c3eac036788e371bcab1f0b972624f57 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 16:19:32 -0700 Subject: [PATCH 01/13] =?UTF-8?q?docs(spec):=20cell=20editing=20(v1)=20des?= =?UTF-8?q?ign=20=E2=80=94=20full=20async=20edit=20lifecycle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controlled onCellEdit data flow; the edit lifecycle (checking → editing → validating → saving, with invalid/error branches) lives as sync transitions in @pretable/core (snapshot.editing) while the React surface orchestrates the async hooks (editable/validate/commit, all Promise-capable). Single-cell scope for v1; fill/paste/multi-cell/optimistic/undo deferred. --- .../specs/2026-06-08-cell-editing-design.md | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-08-cell-editing-design.md diff --git a/docs/superpowers/specs/2026-06-08-cell-editing-design.md b/docs/superpowers/specs/2026-06-08-cell-editing-design.md new file mode 100644 index 00000000..cdd3a262 --- /dev/null +++ b/docs/superpowers/specs/2026-06-08-cell-editing-design.md @@ -0,0 +1,194 @@ +# Cell editing (v1) — design + +**Date:** 2026-06-08 +**Status:** Approved (brainstorm) +**Branch:** `claude/cell-editing` + +## Goal + +Add inline cell editing to pretable — the largest table-stakes gap for a "drop-in +React data grid" (see the v1 gap assessment). v1 ships a **full async edit +lifecycle**: per-cell editability, validation, and commit may each be async, with +explicit pending/error states. + +## Data flow — controlled + +A commit calls **`onCellEdit({ rowId, columnId, value, row })`**; the app updates +its own `rows` (or, headless, calls `grid.applyTransaction`). The grid never +mutates a private row copy — identical to `onSortChange` / `onSelectionChange` / +`onColumnWidthsChange` today. The edited value flows back down through the `rows` +prop, so there is one source of truth. **No uncontrolled mode in v1.** + +Commit is **pessimistic**: while `onCellEdit` is in flight the cell shows a +`saving` state and the draft is retained; the row only changes once the app +persists and feeds new `rows` back down. No optimistic apply in v1. + +## Edit lifecycle + +One edit moves through phases, each gated by an async-capable hook: + +``` +idle → [editable()?] → editing → [validate()?] → [onCellEdit()?] → committed + ↘ denied ↘ invalid(msg) ↘ failed(error) +``` + +`snapshot.editing` carries the phase so React and headless consumers render +identically: + +```ts +editing: { + rowId: string; + columnId: string; + draft: unknown; + status: "checking" | "editing" | "validating" | "saving" | "error"; + error?: string; // present for "invalid" message (status "editing") and "error" +} | null; +``` + +Note: an `invalid` validation result returns to `status: "editing"` with `error` +set (the user fixes and re-commits); `error` is the distinct terminal-but-retryable +commit-failure state. + +## State in core, orchestration in the surface + +`@pretable/core` owns the lifecycle as **synchronous** transitions and exposes +`snapshot.editing`. It never holds a promise — it stays pure and unit-testable. + +New `PretableGrid` methods (all sync): + +- `beginEdit(addr: PretableCellAddress, opts?: { draft?: unknown; status?: "checking" | "editing" }): void` + — create the editing record. Default `status: "editing"`; the orchestrator + passes `"checking"` when an async `editable` gate must resolve first. +- `setEditDraft(value: unknown): void` +- `markEditing(): void` — transition `checking` → `editing` (after `editable` + resolves `true`). +- `markEditValidating(): void` / `markEditSaving(): void` — phase transitions for + the validate / commit async gates. +- `markEditInvalid(message: string): void` — back to `editing` with `error` set. +- `markEditError(message: string): void` — enter `error`. +- `commitEditSucceeded(): void` — clear editing (success). +- `cancelEdit(): void` — clear editing (revert / deny). + +`snapshot.editing.status` reflects the current phase. A synchronous +`editable: true` skips `checking` entirely (`beginEdit` with default +`status: "editing"`); the orchestrator only uses `checking` when `editable` +returns a promise. + +The **React surface (and any headless consumer) drives the async**: + +``` +trigger → + editable sync true → beginEdit(addr, { draft }) [status "editing"] + editable async → beginEdit(addr, { draft, status: "checking" }) + → await editable(input) + false → cancelEdit() + true → markEditing() [status "editing"] +commit-key → markEditValidating() → await validate(draft, input) + string → markEditInvalid(string) // back to "editing" + true → markEditSaving() → await onCellEdit(payload) + resolve → commitEditSucceeded() → moveFocus(commitDirection) + reject → markEditError(message) +``` + +The engine holds no promises; the orchestrator (a small hook in +`@pretable/react`, e.g. `useCellEditController`) owns the awaits. Headless +consumers replicate this loop against the same sync methods — documented in the +headless docs. + +## Column API (`PretableColumn`) + +```ts +editable?: boolean | ((input: PretableEditInput) => boolean | Promise); +validate?: (value: unknown, input: PretableEditInput) => + (true | string) | Promise; // string = reject message +renderEditor?: (input: PretableEditorInput) => ReactNode; // default: text +parseEditValue?: (raw: string, input) => unknown; // editor string → typed value +formatEditValue?: (value: unknown, input) => string; // typed value → editor string +``` + +- `editable` defaults to `false` (opt-in). +- Columns with a custom derived `value` getter remain editable — in controlled + mode the app decides which field(s) to write in its `onCellEdit` handler, so the + grid needs no field-mapping knowledge. +- `PretableEditInput` = `{ row, column, value, rowId, columnId }`. + `PretableEditorInput` extends it with `{ draft, setDraft, commit, cancel }`. + +Commit callback on the surface props: + +```ts +onCellEdit?: (payload: { rowId: string; columnId: string; value: unknown; row: TRow }) + => void | Promise; // rejection/throw → "error" status +``` + +## Async UX, races, errors + +- **`checking`**: brief pending affordance; `false` → no-op (no edit begins). +- **`validating` / `saving`**: cell shows pending; all keys except `Escape` + suspended. +- **invalid**: stay in `editing`, show `error` message; user fixes, re-commits. +- **`error`** (commit failed): retain draft; `Enter` retries (re-runs + validate→commit), `Escape` reverts. +- **Staleness guard across every async phase**: the orchestrator stamps each + async step with a token tied to `{rowId, columnId}` + a monotonic edit id; if + focus moves or a new edit begins before a promise resolves, the stale result is + dropped (never written to a since-changed cell, never transitions a newer edit). + +## Triggers (integrated with focus + single-tab-stop) + +- **Begin:** `Enter`, `F2`, double-click, or typing a printable character + (type-to-replace: the typed char seeds the draft). +- **Commit:** `Enter` (commit → move focus down), `Tab` (commit → move focus + right, honoring `tabBehavior`), blur. +- **Cancel:** `Escape` (revert, keep focus on the cell). +- Editing suspends range-selection keystrokes while active; the ARIA grid pattern + and single tab stop are preserved. Editor input gets appropriate ARIA wiring. + +## Public surface / packages touched + +- `@pretable/core`: new edit-lifecycle methods on `PretableGrid`, + `snapshot.editing`, new types (`PretableEditState`, `PretableEditStatus`). New + `@public` symbols → regenerate `core.api.md`. +- `@pretable/react`: `editable`/`validate`/`renderEditor`/`parseEditValue`/ + `formatEditValue` on the column type; `onCellEdit` on `PretableSurfaceProps` (and + surfaced through `PretableProps`); `useCellEditController`; editor rendering + + keyboard wiring; default text editor. New `@public` symbols → regenerate + `react.api.md`. **Run `pnpm api` and commit the regenerated reports** (the + `API Extractor — report freshness` gate is required — see + `project_dependabot_api_extractor_gap` memory; regenerate in a clean env). +- Docs: a new `docs/grid/editing.mdx` page + nav entry; a note in the headless + docs on driving the lifecycle manually. + +## v1 scope + +**In:** the full async lifecycle (editable / validate / commit), default text +editor + `renderEditor`, `parseEditValue`/`formatEditValue`, the triggers above, +pessimistic commit with pending/error states, staleness guards, controlled +`onCellEdit`, ARIA wiring, docs page. + +**Out (later, explicit):** drag-fill, paste-into-range (pairs with the separate +clipboard-paste gap), multi-cell / range editing, optimistic commit, an undo +stack, async commit retry policies beyond manual `Enter`-to-retry. + +## Testing + +- **Engine (core):** unit-test the sync transition machine — every phase path + (`checking`→deny, `editing`→`validating`→invalid, →`saving`→success, + →`saving`→`error`→retry, `cancelEdit` from each phase) and `snapshot.editing` + shape/identity (cached until next mutation, like the rest of the snapshot). +- **React (RTL):** each trigger (Enter / F2 / double-click / type-to-replace); + commit-and-move-focus (Enter→down, Tab→right); Escape-revert; async paths via + deferred promises (deny / invalid / save-success / save-error→retry); + **staleness** (move focus mid-await → no write, no stale transition); + `onCellEdit` payload correctness; `renderEditor` escape hatch; + `parseEditValue`/`formatEditValue` round-trip. + +## Open questions / risks + +- Exact keyboard hook point in the existing surface keydown handler (where edit + triggers intercept before range-selection handling) — confirm against + `packages/react/src` during planning. +- Whether `snapshot.editing` needs the resolved `editable` input cached, or the + orchestrator re-derives it — settle in planning (lean: orchestrator owns it, + engine stores only `{rowId, columnId, draft, status, error}`). +- Focus restoration after commit/cancel must not fight the existing + `selectFocusedRowOnArrowKey` / focus model — verify in planning. From 7c42d37f04c27776b89a3e8c7d921c4004b5f8bf Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 16:29:26 -0700 Subject: [PATCH 02/13] docs(plan): cell editing (v1) implementation plan 9-task TDD plan: edit-lifecycle types + sync state machine in grid-core (snapshot.editing); public surface on @pretable/core; async orchestrator (useCellEditController) with staleness token; default CellEditor + renderEditor; wire triggers/editor/onCellEdit into PretableSurface; surface through ; docs page; full verification incl. api:check regen. --- .../plans/2026-06-08-cell-editing.md | 1218 +++++++++++++++++ 1 file changed, 1218 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-08-cell-editing.md diff --git a/docs/superpowers/plans/2026-06-08-cell-editing.md b/docs/superpowers/plans/2026-06-08-cell-editing.md new file mode 100644 index 00000000..32d2f232 --- /dev/null +++ b/docs/superpowers/plans/2026-06-08-cell-editing.md @@ -0,0 +1,1218 @@ +# Cell Editing (v1) 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:** Add inline cell editing to pretable with a full async edit lifecycle (per-cell editable/validate/commit, each Promise-capable) exposed through a controlled `onCellEdit` API. + +**Architecture:** The edit lifecycle lives as **synchronous state transitions** in the `@pretable-internal/grid-core` engine (`snapshot.editing` with a `status` phase). The `@pretable/react` surface drives the async hooks (`editable` → `validate` → `onCellEdit`) via a new `useCellEditController`, calling the engine's sync transition methods and guarding against stale resolutions with an edit token. Data flow is controlled: commit fires `onCellEdit` and the app feeds new `rows` back down. + +**Tech Stack:** TypeScript, React 19, vitest + @testing-library/react (jsdom), api-extractor. + +**Spec:** `docs/superpowers/specs/2026-06-08-cell-editing-design.md` + +**Branch:** `claude/cell-editing` (off latest `main`). + +--- + +## File structure + +Modify: + +- `packages/grid-core/src/types.ts` — new edit types; `editing` on snapshot; edit hooks on `PretableColumn`; edit methods on `PretableEngine`. +- `packages/grid-core/src/create-grid-core.ts` — `editing` state + transition methods + include in `getSnapshot`. +- `packages/core/src/types.ts` — re-export the new types. +- `packages/core/src/pretable-grid.ts` — edit methods on the public `PretableGrid` interface. +- `packages/core/src/create-grid.ts` — forward the new engine methods. +- `packages/core/src/public_api.ts` — export new public types. +- `packages/react/src/types.ts` — React `PretableColumn` gains `renderEditor`. +- `packages/react/src/pretable-surface.tsx` — keyboard/dblclick triggers, editor render, `onCellEdit` prop. +- `packages/react/src/pretable.tsx` — surface `onCellEdit` through `PretableProps`. +- `packages/react/src/public_api.ts` — export new public React types. + +Create: + +- `packages/react/src/use-cell-edit-controller.ts` — async orchestrator hook. +- `packages/react/src/cell-editor.tsx` — default text editor + editor input resolution. +- `apps/website/content/docs/grid/editing.mdx` + nav entry in `apps/website/app/docs/_nav.ts`. + +Regenerate (required gate): `packages/core/core.api.md`, `packages/react/react.api.md`. + +--- + +## Task 1: Edit types + column hooks + snapshot field (grid-core) + +**Files:** + +- Modify: `packages/grid-core/src/types.ts` + +- [ ] **Step 1: Add the edit types** after `PretableSortDirection` (around line 18): + +```ts +/** + * Phase of an in-progress cell edit. + * + * @public + */ +export type PretableEditStatus = + | "checking" + | "editing" + | "validating" + | "saving" + | "error"; + +/** + * Input passed to a column's edit hooks (`editable`, `validate`, `parseEditValue`, + * `formatEditValue`). + * + * @public + */ +export interface PretableEditInput { + rowId: string; + columnId: string; + row: TRow; + column: PretableColumn; + value: unknown; +} + +/** + * In-progress cell edit observed via `PretableGrid.getSnapshot().editing`. + * `error` carries the validation message (status `"editing"`) or the commit + * failure message (status `"error"`). + * + * @public + */ +export interface PretableEditState { + rowId: string; + columnId: string; + draft: unknown; + status: PretableEditStatus; + error?: string; +} +``` + +- [ ] **Step 2: Add the edit hooks to `PretableColumn`** — inside the interface (after `reorderable?: boolean;`, before the closing brace): + +```ts + // cell editing (v1): + editable?: + | boolean + | ((input: PretableEditInput) => boolean | Promise); + validate?: ( + value: unknown, + input: PretableEditInput, + ) => (true | string) | Promise; + parseEditValue?: (raw: string, input: PretableEditInput) => unknown; + formatEditValue?: (value: unknown, input: PretableEditInput) => string; +``` + +- [ ] **Step 3: Add `editing` to `PretableGridSnapshot`** — inside the interface, after `visibleRange: PretableRowRange;`: + +```ts +editing: PretableEditState | null; +``` + +- [ ] **Step 4: Add the edit methods to `PretableEngine`** — inside the interface, after `mergeColumnsFromProps(...)`: + +```ts + // cell editing (v1): + beginEdit( + addr: PretableCellAddress, + opts?: { draft?: unknown; status?: "checking" | "editing" }, + ): void; + setEditDraft(value: unknown): void; + markEditing(): void; + markEditValidating(): void; + markEditSaving(): void; + markEditInvalid(message: string): void; + markEditError(message: string): void; + commitEditSucceeded(): void; + cancelEdit(): void; +``` + +- [ ] **Step 5: Typecheck** — Run: `pnpm --filter @pretable-internal/grid-core typecheck` + Expected: FAIL — `create-grid-core.ts` doesn't yet implement the new `PretableEngine` members / `getSnapshot` lacks `editing`. (This failure is expected; Task 2 implements them.) + +- [ ] **Step 6: Commit** + +```bash +git add packages/grid-core/src/types.ts +git commit -m "feat(grid-core): edit lifecycle types (PretableEditState/Status/Input) + column hooks + snapshot.editing" +``` + +--- + +## Task 2: Edit lifecycle state machine (grid-core engine) + +**Files:** + +- Modify: `packages/grid-core/src/create-grid-core.ts` +- Test: `packages/grid-core/src/__tests__/edit-lifecycle.test.ts` (create) + +- [ ] **Step 1: Write the failing test** — create `packages/grid-core/src/__tests__/edit-lifecycle.test.ts`: + +```ts +import { describe, expect, it } from "vitest"; + +import { createGridCore } from "../create-grid-core"; + +const COLUMNS = [{ id: "name" }, { id: "age" }]; +const ROWS = [ + { id: "r1", name: "Ada", age: 36 }, + { id: "r2", name: "Linus", age: 54 }, +]; + +function makeGrid() { + return createGridCore({ + columns: COLUMNS, + rows: ROWS, + getRowId: (r) => r.id, + }); +} + +describe("edit lifecycle", () => { + it("starts with no edit", () => { + expect(makeGrid().getSnapshot().editing).toBeNull(); + }); + + it("beginEdit defaults to status 'editing' with the given draft", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "name" }, { draft: "Ad" }); + expect(g.getSnapshot().editing).toEqual({ + rowId: "r1", + columnId: "name", + draft: "Ad", + status: "editing", + }); + }); + + it("supports the async-editable 'checking' → 'editing' path", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "name" }, { status: "checking" }); + expect(g.getSnapshot().editing?.status).toBe("checking"); + g.markEditing(); + expect(g.getSnapshot().editing?.status).toBe("editing"); + }); + + it("runs validating → saving → success, clearing the edit", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "name" }, { draft: "Ada Lovelace" }); + g.setEditDraft("Ada L."); + g.markEditValidating(); + expect(g.getSnapshot().editing?.status).toBe("validating"); + g.markEditSaving(); + expect(g.getSnapshot().editing?.status).toBe("saving"); + g.commitEditSucceeded(); + expect(g.getSnapshot().editing).toBeNull(); + }); + + it("markEditInvalid returns to 'editing' with a message", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "age" }); + g.markEditValidating(); + g.markEditInvalid("must be a number"); + expect(g.getSnapshot().editing).toMatchObject({ + status: "editing", + error: "must be a number", + }); + }); + + it("markEditError enters 'error' with a message; cancelEdit clears it", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "age" }); + g.markEditSaving(); + g.markEditError("network down"); + expect(g.getSnapshot().editing).toMatchObject({ + status: "error", + error: "network down", + }); + g.cancelEdit(); + expect(g.getSnapshot().editing).toBeNull(); + }); + + it("transition methods no-op when there is no active edit (stale-callback safety)", () => { + const g = makeGrid(); + g.markEditSaving(); + g.commitEditSucceeded(); + expect(g.getSnapshot().editing).toBeNull(); + }); + + it("notifies subscribers on edit transitions", () => { + const g = makeGrid(); + let calls = 0; + g.subscribe(() => { + calls += 1; + }); + g.beginEdit({ rowId: "r1", columnId: "name" }); + g.setEditDraft("x"); + g.commitEditSucceeded(); + expect(calls).toBe(3); + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `pnpm --filter @pretable-internal/grid-core test -- edit-lifecycle` +Expected: FAIL — `g.beginEdit is not a function`. + +- [ ] **Step 3: Add the `editing` state field** — in `create-grid-core.ts`, next to the other state declarations (near `let focus`, around line 91): + +```ts +let editing: PretableEditState | null = null; +``` + +Add `PretableEditState` (and `PretableCellAddress` if not already) to the `import type { ... } from "./types"` block at the top of the file. + +- [ ] **Step 4: Implement the transition methods** — add to the returned engine object (alongside the other actions, before the closing of the returned object). Every method that changes state calls `emit()` (the existing notify+cache-invalidate helper): + +```ts + beginEdit(addr, opts) { + editing = { + rowId: addr.rowId, + columnId: addr.columnId, + draft: opts?.draft, + status: opts?.status ?? "editing", + }; + emit(); + }, + setEditDraft(value) { + if (!editing) return; + editing = { ...editing, draft: value }; + emit(); + }, + markEditing() { + if (!editing || editing.status !== "checking") return; + editing = { ...editing, status: "editing", error: undefined }; + emit(); + }, + markEditValidating() { + if (!editing) return; + editing = { ...editing, status: "validating", error: undefined }; + emit(); + }, + markEditSaving() { + if (!editing) return; + editing = { ...editing, status: "saving", error: undefined }; + emit(); + }, + markEditInvalid(message) { + if (!editing) return; + editing = { ...editing, status: "editing", error: message }; + emit(); + }, + markEditError(message) { + if (!editing) return; + editing = { ...editing, status: "error", error: message }; + emit(); + }, + commitEditSucceeded() { + if (!editing) return; + editing = null; + emit(); + }, + cancelEdit() { + if (!editing) return; + editing = null; + emit(); + }, +``` + +- [ ] **Step 5: Include `editing` in the snapshot** — in `getSnapshot()`, add to the `cachedSnapshot = { ... }` object literal (after `visibleRange: { ... }`): + +```ts + editing: editing ? { ...editing } : null, +``` + +- [ ] **Step 6: Run the test, verify it passes** + +Run: `pnpm --filter @pretable-internal/grid-core test -- edit-lifecycle` +Expected: PASS (8 tests). + +- [ ] **Step 7: Run the full grid-core suite + typecheck** (no regressions) + +Run: `pnpm --filter @pretable-internal/grid-core test && pnpm --filter @pretable-internal/grid-core typecheck` +Expected: PASS. + +- [ ] **Step 8: Commit** + +```bash +git add packages/grid-core/src/create-grid-core.ts packages/grid-core/src/__tests__/edit-lifecycle.test.ts +git commit -m "feat(grid-core): implement sync edit-lifecycle transitions + snapshot.editing" +``` + +--- + +## Task 3: Public surface (@pretable/core) + regenerate report + +**Files:** + +- Modify: `packages/core/src/types.ts`, `packages/core/src/pretable-grid.ts`, `packages/core/src/create-grid.ts`, `packages/core/src/public_api.ts` +- Regenerate: `packages/core/core.api.md` + +- [ ] **Step 1: Re-export new types** — in `packages/core/src/types.ts`, add to the `export type { ... } from "@pretable-internal/grid-core"` list: + +```ts + PretableEditInput, + PretableEditState, + PretableEditStatus, +``` + +- [ ] **Step 2: Add edit methods to the public `PretableGrid` interface** — in `packages/core/src/pretable-grid.ts`, mirror the `PretableEngine` additions from Task 1 Step 4 (same nine signatures). Import `PretableCellAddress` if not already imported there. + +```ts + beginEdit( + addr: PretableCellAddress, + opts?: { draft?: unknown; status?: "checking" | "editing" }, + ): void; + setEditDraft(value: unknown): void; + markEditing(): void; + markEditValidating(): void; + markEditSaving(): void; + markEditInvalid(message: string): void; + markEditError(message: string): void; + commitEditSucceeded(): void; + cancelEdit(): void; +``` + +- [ ] **Step 3: Forward the methods in `createGrid`** — in `packages/core/src/create-grid.ts`, add to the returned object (alongside `applyTransaction: engine.applyTransaction,`): + +```ts + beginEdit: engine.beginEdit, + setEditDraft: engine.setEditDraft, + markEditing: engine.markEditing, + markEditValidating: engine.markEditValidating, + markEditSaving: engine.markEditSaving, + markEditInvalid: engine.markEditInvalid, + markEditError: engine.markEditError, + commitEditSucceeded: engine.commitEditSucceeded, + cancelEdit: engine.cancelEdit, +``` + +- [ ] **Step 4: Export public types** — in `packages/core/src/public_api.ts`, add `PretableEditInput`, `PretableEditState`, `PretableEditStatus` to the exported type list (matching the existing export style in that file). + +- [ ] **Step 5: Typecheck core** + +Run: `pnpm --filter @pretable/core typecheck` +Expected: PASS. + +- [ ] **Step 6: Regenerate the API report** (required CI gate; clean env to match CI) + +Run: + +```bash +pnpm --filter @pretable/core build && pnpm --filter @pretable/core api +``` + +Expected: `core.api.md` updated with the new methods/types; `pnpm --filter @pretable/core api:check` then exits 0. + +- [ ] **Step 7: Commit** + +```bash +git add packages/core/src packages/core/core.api.md +git commit -m "feat(core): expose edit lifecycle on PretableGrid + public edit types" +``` + +--- + +## Task 4: Async edit orchestrator (`useCellEditController`) + +The controller owns the async hooks and stale-resolution guarding. It calls the engine's sync transitions. It is framework-thin (no DOM) so it unit-tests against a real `createGrid`. + +**Files:** + +- Create: `packages/react/src/use-cell-edit-controller.ts` +- Test: `packages/react/src/__tests__/use-cell-edit-controller.test.ts` (create) + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from "vitest"; + +import { createGrid, type PretableColumn } from "@pretable/core"; + +import { createCellEditController } from "../use-cell-edit-controller"; + +interface Row extends Record { + id: string; + name: string; +} +const ROWS: Row[] = [{ id: "r1", name: "Ada" }]; + +function setup( + columnOverrides: Partial> = {}, + onCellEdit = vi.fn(), +) { + const columns: PretableColumn[] = [ + { id: "name", editable: true, ...columnOverrides }, + ]; + const grid = createGrid({ columns, rows: ROWS, getRowId: (r) => r.id }); + const controller = createCellEditController({ + grid, + getColumns: () => columns, + getRowById: (id) => ROWS.find((r) => r.id === id) ?? null, + onCellEdit, + }); + return { grid, controller, onCellEdit }; +} + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe("cell edit controller", () => { + it("begins an edit immediately when editable === true", async () => { + const { grid, controller } = setup(); + await controller.begin({ rowId: "r1", columnId: "name" }); + expect(grid.getSnapshot().editing).toMatchObject({ + rowId: "r1", + status: "editing", + }); + }); + + it("gates begin through 'checking' for async editable", async () => { + let resolve!: (v: boolean) => void; + const { grid, controller } = setup({ + editable: () => new Promise((r) => (resolve = r)), + }); + const p = controller.begin({ rowId: "r1", columnId: "name" }); + expect(grid.getSnapshot().editing?.status).toBe("checking"); + resolve(true); + await p; + expect(grid.getSnapshot().editing?.status).toBe("editing"); + }); + + it("does not begin when async editable resolves false", async () => { + const { grid, controller } = setup({ + editable: () => Promise.resolve(false), + }); + await controller.begin({ rowId: "r1", columnId: "name" }); + expect(grid.getSnapshot().editing).toBeNull(); + }); + + it("validate failure returns to editing with the message", async () => { + const { grid, controller } = setup({ validate: () => "too short" }); + await controller.begin({ rowId: "r1", columnId: "name" }); + grid.setEditDraft("x"); + await controller.commit("down"); + expect(grid.getSnapshot().editing).toMatchObject({ + status: "editing", + error: "too short", + }); + }); + + it("successful async commit calls onCellEdit then clears the edit", async () => { + const onCellEdit = vi.fn().mockResolvedValue(undefined); + const { grid, controller } = setup({}, onCellEdit); + await controller.begin({ rowId: "r1", columnId: "name" }); + grid.setEditDraft("Ada L."); + await controller.commit("down"); + expect(onCellEdit).toHaveBeenCalledWith( + expect.objectContaining({ + rowId: "r1", + columnId: "name", + value: "Ada L.", + }), + ); + expect(grid.getSnapshot().editing).toBeNull(); + }); + + it("commit rejection enters 'error'", async () => { + const onCellEdit = vi.fn().mockRejectedValue(new Error("boom")); + const { grid, controller } = setup({}, onCellEdit); + await controller.begin({ rowId: "r1", columnId: "name" }); + await controller.commit("down"); + expect(grid.getSnapshot().editing).toMatchObject({ + status: "error", + error: "boom", + }); + }); + + it("drops a stale async-editable resolution after cancel (staleness guard)", async () => { + let resolve!: (v: boolean) => void; + const { grid, controller } = setup({ + editable: () => new Promise((r) => (resolve = r)), + }); + const p = controller.begin({ rowId: "r1", columnId: "name" }); + controller.cancel(); + expect(grid.getSnapshot().editing).toBeNull(); + resolve(true); + await p; + expect(grid.getSnapshot().editing).toBeNull(); // stale true did not re-open + }); +}); +``` + +- [ ] **Step 2: Run the test, verify it fails** + +Run: `pnpm --filter @pretable/react test -- use-cell-edit-controller` +Expected: FAIL — `Cannot find module '../use-cell-edit-controller'`. + +- [ ] **Step 3: Implement the controller** — create `packages/react/src/use-cell-edit-controller.ts`: + +```ts +import { useMemo } from "react"; + +import type { + PretableCellAddress, + PretableColumn, + PretableEditInput, + PretableFocusDirection, + PretableGrid, + PretableRow, +} from "@pretable/core"; + +export interface CellEditController { + begin(addr: PretableCellAddress, initialDraft?: unknown): Promise; + commit(moveDirection?: PretableFocusDirection): Promise; + cancel(): void; +} + +export interface CellEditControllerOptions { + grid: PretableGrid; + getColumns: () => PretableColumn[]; + getRowById: (rowId: string) => TRow | null; + onCellEdit?: (payload: { + rowId: string; + columnId: string; + value: unknown; + row: TRow; + }) => void | Promise; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +// Stand-alone factory (tested directly). `useCellEditController` wraps it in useMemo. +export function createCellEditController( + opts: CellEditControllerOptions, +): CellEditController { + const { grid, getColumns, getRowById, onCellEdit } = opts; + // Monotonic token: every begin()/cancel() bumps it, so a stale async + // resolution (editable/commit) can detect it is no longer the active edit. + let token = 0; + + const inputFor = ( + addr: PretableCellAddress, + ): PretableEditInput | null => { + const column = getColumns().find((c) => c.id === addr.columnId); + const row = getRowById(addr.rowId); + if (!column || !row) return null; + const value = column.value ? column.value(row) : row[addr.columnId]; + return { rowId: addr.rowId, columnId: addr.columnId, row, column, value }; + }; + + return { + async begin(addr, initialDraft) { + const input = inputFor(addr); + if (!input) return; + const editable = input.column.editable ?? false; + const seed = + initialDraft !== undefined + ? initialDraft + : input.column.formatEditValue + ? input.column.formatEditValue(input.value, input) + : input.value; + + if (editable === false) return; + if (editable === true) { + grid.beginEdit(addr, { draft: seed, status: "editing" }); + token += 1; + return; + } + // async / function editable + const myToken = (token += 1); + grid.beginEdit(addr, { draft: seed, status: "checking" }); + const allowed = await editable(input); + if (myToken !== token) return; // stale + if (allowed) grid.markEditing(); + else grid.cancelEdit(); + }, + + async commit(moveDirection) { + const editing = grid.getSnapshot().editing; + if (!editing) return; + const addr = { rowId: editing.rowId, columnId: editing.columnId }; + const input = inputFor(addr); + if (!input) return; + const myToken = (token += 1); + const draft = editing.draft; + const value = input.column.parseEditValue + ? input.column.parseEditValue(String(draft ?? ""), input) + : draft; + + if (input.column.validate) { + grid.markEditValidating(); + const result = await input.column.validate(value, input); + if (myToken !== token) return; // stale + if (result !== true) { + grid.markEditInvalid(result); + return; + } + } + + grid.markEditSaving(); + try { + await onCellEdit?.({ + rowId: addr.rowId, + columnId: addr.columnId, + value, + row: input.row, + }); + if (myToken !== token) return; // stale + grid.commitEditSucceeded(); + if (moveDirection) grid.moveFocus(moveDirection); + } catch (err) { + if (myToken !== token) return; // stale + grid.markEditError(errorMessage(err)); + } + }, + + cancel() { + token += 1; + grid.cancelEdit(); + }, + }; +} + +export function useCellEditController( + opts: CellEditControllerOptions, +): CellEditController { + // grid identity is stable for the life of the surface; other opts read via + // closures that always see latest. Recreate only if grid changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => createCellEditController(opts), [opts.grid]); +} +``` + +- [ ] **Step 4: Run the test, verify it passes** + +Run: `pnpm --filter @pretable/react test -- use-cell-edit-controller` +Expected: PASS (7 tests). + +- [ ] **Step 5: Commit** + +```bash +git add packages/react/src/use-cell-edit-controller.ts packages/react/src/__tests__/use-cell-edit-controller.test.ts +git commit -m "feat(react): async cell-edit orchestrator with staleness guard" +``` + +--- + +## Task 5: Default cell editor + React column `renderEditor` + +**Files:** + +- Create: `packages/react/src/cell-editor.tsx` +- Modify: `packages/react/src/types.ts` (React `PretableColumn` gains `renderEditor`) +- Test: `packages/react/src/__tests__/cell-editor.test.tsx` (create) + +- [ ] **Step 1: Add `renderEditor` to the React column type** — in `packages/react/src/types.ts`, locate the React `PretableColumn` interface (it extends the core column and adds `render`/`renderHeader`) and add: + +```ts + renderEditor?: (input: PretableEditorInput) => ReactNode; +``` + +Define `PretableEditorInput` in the same file: + +```ts +import type { + PretableEditInput, + PretableFocusDirection, + PretableRow, +} from "@pretable/core"; +import type { ReactNode } from "react"; + +/** + * Input passed to a column's `renderEditor`. Extends the engine edit input with + * draft controls bound to the active edit. `commit` accepts the focus direction + * to move after a successful commit (Enter → "down", Tab → "right"). + * + * @public + */ +export interface PretableEditorInput< + TRow extends PretableRow = PretableRow, +> extends PretableEditInput { + draft: unknown; + setDraft: (value: unknown) => void; + commit: (direction?: PretableFocusDirection) => void; + cancel: () => void; +} +``` + +- [ ] **Step 2: Write the failing test** — create `packages/react/src/__tests__/cell-editor.test.tsx`: + +```tsx +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { CellEditor } from "../cell-editor"; +import type { PretableEditorInput } from "../types"; + +function makeInput( + over: Partial = {}, +): PretableEditorInput { + return { + rowId: "r1", + columnId: "name", + row: { id: "r1", name: "Ada" }, + column: { id: "name" }, + value: "Ada", + draft: "Ada", + setDraft: vi.fn(), + commit: vi.fn(), + cancel: vi.fn(), + ...over, + }; +} + +describe("CellEditor (default)", () => { + it("renders a text input seeded with the draft", () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue("Ada"); + }); + + it("pushes keystrokes to setDraft", () => { + const setDraft = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Ada L." }, + }); + expect(setDraft).toHaveBeenCalledWith("Ada L."); + }); + + it("commits down on Enter, right on Tab, and cancels on Escape", () => { + const commit = vi.fn(); + const cancel = vi.fn(); + render(); + const box = screen.getByRole("textbox"); + fireEvent.keyDown(box, { key: "Enter" }); + expect(commit).toHaveBeenCalledWith("down"); + fireEvent.keyDown(box, { key: "Tab" }); + expect(commit).toHaveBeenCalledWith("right"); + fireEvent.keyDown(box, { key: "Escape" }); + expect(cancel).toHaveBeenCalled(); + }); + + it("delegates to column.renderEditor when provided", () => { + const input = makeInput({ + column: { id: "name", renderEditor: () => custom }, + }); + render(); + expect(screen.getByText("custom")).toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 3: Run the test, verify it fails** + +Run: `pnpm --filter @pretable/react test -- cell-editor` +Expected: FAIL — `Cannot find module '../cell-editor'`. + +- [ ] **Step 4: Implement the editor** — create `packages/react/src/cell-editor.tsx`: + +```tsx +import { useEffect, useRef } from "react"; + +import type { PretableEditorInput } from "./types"; + +export interface CellEditorProps { + input: PretableEditorInput; +} + +/** + * Renders a column's `renderEditor` if present, otherwise a default text input + * that drives the active edit's draft and commit/cancel. + */ +export function CellEditor({ input }: CellEditorProps) { + const ref = useRef(null); + + // Autofocus + select on mount so type-to-replace and immediate typing work. + useEffect(() => { + ref.current?.focus(); + ref.current?.select(); + }, []); + + if (input.column.renderEditor) { + return <>{input.column.renderEditor(input)}; + } + + return ( + input.setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + input.commit("down"); + } else if (e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + input.commit("right"); + } else if (e.key === "Escape" || e.key === "Esc") { + e.preventDefault(); + e.stopPropagation(); + input.cancel(); + } + }} + /> + ); +} +``` + +- [ ] **Step 5: Run the test, verify it passes** + +Run: `pnpm --filter @pretable/react test -- cell-editor` +Expected: PASS (4 tests). + +- [ ] **Step 6: Commit** + +```bash +git add packages/react/src/cell-editor.tsx packages/react/src/types.ts packages/react/src/__tests__/cell-editor.test.tsx +git commit -m "feat(react): default CellEditor + renderEditor column hook + PretableEditorInput" +``` + +--- + +## Task 6: Wire editing into `PretableSurface` (triggers + render + props) + +`packages/react/src/pretable-surface.tsx` is large (~2347 lines). Make **additive** changes at the named anchors; do not restructure the file. + +**Files:** + +- Modify: `packages/react/src/pretable-surface.tsx` +- Test: `packages/react/src/__tests__/pretable-surface-editing.test.tsx` (create) + +- [ ] **Step 1: Write the failing integration test** — create `packages/react/src/__tests__/pretable-surface-editing.test.tsx`: + +```tsx +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { PretableSurface } from "../pretable-surface"; +import type { PretableColumn } from "../types"; + +interface Row extends Record { + id: string; + name: string; +} +const ROWS: Row[] = [ + { id: "r1", name: "Ada" }, + { id: "r2", name: "Linus" }, +]; +const COLUMNS: PretableColumn[] = [ + { id: "name", header: "Name", editable: true }, +]; + +function renderGrid(onCellEdit = vi.fn()) { + render( + + ariaLabel="people" + columns={COLUMNS} + rows={ROWS} + getRowId={(r) => r.id} + viewportHeight={300} + onCellEdit={onCellEdit} + />, + ); + return { onCellEdit }; +} + +function firstNameCell(): HTMLElement { + // first body row, first cell + return within(screen.getAllByRole("row")[1]).getAllByRole("gridcell")[0]; +} + +describe("PretableSurface editing", () => { + it("enters edit mode on Enter and shows an input", () => { + renderGrid(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("commits on Enter and fires onCellEdit with the new value", async () => { + const { onCellEdit } = renderGrid(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + const box = screen.getByRole("textbox"); + fireEvent.change(box, { target: { value: "Ada Lovelace" } }); + fireEvent.keyDown(box, { key: "Enter" }); + await Promise.resolve(); + expect(onCellEdit).toHaveBeenCalledWith( + expect.objectContaining({ + rowId: "r1", + columnId: "name", + value: "Ada Lovelace", + }), + ); + }); + + it("reverts on Escape without firing onCellEdit", () => { + const { onCellEdit } = renderGrid(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "x" } }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(onCellEdit).not.toHaveBeenCalled(); + }); + + it("does not enter edit mode for a non-editable column", () => { + render( + + ariaLabel="people" + columns={[{ id: "name", header: "Name" }]} + rows={ROWS} + getRowId={(r) => r.id} + viewportHeight={300} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); +}); +``` + +- [ ] **Step 2: Run it, verify it fails** + +Run: `pnpm --filter @pretable/react test -- pretable-surface-editing` +Expected: FAIL — `onCellEdit` isn't a prop / no textbox appears. + +- [ ] **Step 3: Add the `onCellEdit` prop** — in `pretable-surface.tsx`, add to `PretableSurfaceProps`: + +```ts + onCellEdit?: (payload: { + rowId: string; + columnId: string; + value: unknown; + row: TRow; + }) => void | Promise; +``` + +- [ ] **Step 4: Instantiate the controller** — inside the `PretableSurface` component body, after the grid/model is available (the `grid` from `usePretable`/model and the resolved `columns` are in scope), add: + +```ts +const editController = useCellEditController({ + grid, + getColumns: () => columns, + getRowById: (id) => + snapshot.visibleRows.find((r) => r.id === id)?.row ?? null, + onCellEdit, +}); +``` + +Add imports at the top: `import { useCellEditController } from "./use-cell-edit-controller";` and `import { CellEditor } from "./cell-editor";`. (Use the component's existing names for the grid handle, resolved `columns`, and `snapshot`; if the snapshot variable has a different local name, match it.) + +- [ ] **Step 5: Add begin-edit triggers to the grid keydown handler** — in the keydown handler function (the one containing the `if (key === "Enter" ...)` branch around line 2266), at the **top** of the handler, before the existing navigation/selection branches, add: + +```ts +const editing = snapshot.editing; +if (!editing) { + const focusAddr = + snapshot.focus.rowId && snapshot.focus.columnId + ? { rowId: snapshot.focus.rowId, columnId: snapshot.focus.columnId } + : null; + if (focusAddr) { + if (key === "Enter" || key === "F2") { + event.preventDefault(); + void editController.begin(focusAddr); + return; + } + // type-to-replace: a single printable character seeds the draft + if (key.length === 1 && !cmd && !event.ctrlKey && !event.altKey) { + event.preventDefault(); + void editController.begin(focusAddr, key); + return; + } + } +} +``` + +(`cmd` is the existing meta/ctrl flag in this handler; reuse it. If editing is active, the editor input owns keystrokes — Enter/Escape are handled inside `CellEditor` and stop-propagated, so the grid handler is not reached.) + +- [ ] **Step 6: Add a double-click trigger** — at the existing cell `onDoubleClick` (around line 1448), add a call to begin editing for that cell's address (the row id + column id are in scope where the cell is rendered): + +```ts + onDoubleClick={(event) => { + // ...existing behavior... + if (column.editable) { + void editController.begin({ rowId, columnId: column.id }); + } + }} +``` + +- [ ] **Step 7: Render the editor in the active cell** — in the body-cell render path, when `snapshot.editing` targets this `{rowId, columnId}`, render `` instead of the cell content: + +```tsx +{snapshot.editing && +snapshot.editing.rowId === rowId && +snapshot.editing.columnId === column.id ? ( + grid.setEditDraft(v), + commit: (dir) => void editController.commit(dir ?? "down"), + cancel: () => editController.cancel(), + }} + /> +) : ( + /* existing cell content render */ +)} +``` + +(Use the component's existing `row`/`rowId`/`column` locals in the cell render scope. The `"saving"`/`"error"`/`"checking"` statuses may be surfaced via a `data-pretable-edit-status={snapshot.editing.status}` attribute on the cell for styling — optional, additive.) + +- [ ] **Step 8: Run the editing test + full react suite** + +Run: `pnpm --filter @pretable/react test -- pretable-surface-editing && pnpm --filter @pretable/react test` +Expected: PASS (4 new tests; no regressions in the existing suite). + +- [ ] **Step 9: Typecheck** + +Run: `pnpm --filter @pretable/react typecheck` +Expected: PASS. + +- [ ] **Step 10: Commit** + +```bash +git add packages/react/src/pretable-surface.tsx packages/react/src/__tests__/pretable-surface-editing.test.tsx +git commit -m "feat(react): wire cell editing into PretableSurface (triggers, editor render, onCellEdit)" +``` + +--- + +## Task 7: Surface `onCellEdit` through `` + export public types + +**Files:** + +- Modify: `packages/react/src/pretable.tsx`, `packages/react/src/public_api.ts` +- Regenerate: `packages/react/react.api.md` + +- [ ] **Step 1: Forward `onCellEdit` in ``** — in `packages/react/src/pretable.tsx`, add `onCellEdit` to `PretableProps` (delegating to the surface prop, matching how `onCopy`/`onColumnOrderChange` are forwarded): + +```ts + onCellEdit?: PretableSurfaceProps["onCellEdit"]; +``` + +and pass it through to the rendered ``. + +- [ ] **Step 2: Export public types** — in `packages/react/src/public_api.ts`, export `PretableEditorInput` (and re-export `PretableEditInput`/`PretableEditState`/`PretableEditStatus` from core if the react entry point is expected to surface them, matching existing re-export style). + +- [ ] **Step 3: Typecheck** + +Run: `pnpm --filter @pretable/react typecheck` +Expected: PASS. + +- [ ] **Step 4: Regenerate the API report** (required gate; clean env) + +Run: + +```bash +pnpm --filter @pretable/react build && pnpm --filter @pretable/react api +``` + +Expected: `react.api.md` updated; `pnpm --filter @pretable/react api:check` exits 0. + +- [ ] **Step 5: Commit** + +```bash +git add packages/react/src/pretable.tsx packages/react/src/public_api.ts packages/react/react.api.md +git commit -m "feat(react): expose onCellEdit on + export edit types" +``` + +--- + +## Task 8: Docs — `docs/grid/editing.mdx` + nav + +**Files:** + +- Create: `apps/website/content/docs/grid/editing.mdx` +- Modify: `apps/website/app/docs/_nav.ts` + +- [ ] **Step 1: Write the docs page** — `apps/website/content/docs/grid/editing.mdx`. Frontmatter: + +```mdx +--- +title: Editing +description: "Controlled inline cell editing with an async editable / validate / commit lifecycle." +nav: Grid +order: 7 +--- +``` + +Required content (match the house voice in `docs/grid/clipboard.mdx`; every code sample type-correct against the shipped API): + +- **Controlled model:** editing commits via `onCellEdit({ rowId, columnId, value, row })`; you update your own `rows`. No internal mutation. +- **Make a column editable:** `editable: true | (input) => boolean | Promise` (note the async permission-gate use). Default off. +- **Validate:** `validate: (value, input) => true | string | Promise<...>` — return a string to reject and stay in edit. +- **Custom editors:** `renderEditor`, with `parseEditValue`/`formatEditValue`. +- **Lifecycle & async states:** the `checking → editing → validating → saving` phases, `invalid`/`error`, and that commit is pessimistic; `snapshot.editing.status` for headless/custom rendering. +- **Keyboard:** Enter/F2/double-click/type-to-replace to begin; Enter/Tab to commit; Escape to cancel. +- A short worked `onCellEdit` example updating React state. + +- [ ] **Step 2: Add the nav entry** — in `apps/website/app/docs/_nav.ts`, add to the "Grid" section `items` (after Clipboard, before "Column layout"): + +```ts + { title: "Editing", href: "/docs/grid/editing" }, +``` + +- [ ] **Step 3: Format + verify build picks up the page** + +Run: `pnpm exec prettier --write apps/website/content/docs/grid/editing.mdx && pnpm --filter @pretable/app-website build` +Expected: build PASS; `/docs/grid/editing` present in the built search index. + +- [ ] **Step 4: Commit** + +```bash +git add apps/website/content/docs/grid/editing.mdx apps/website/app/docs/_nav.ts +git commit -m "docs(website): cell editing page + nav entry" +``` + +--- + +## Task 9: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Workspace tests** — Run: `pnpm -r --filter './packages/*' test` + Expected: PASS (incl. the new grid-core + react edit suites). + +- [ ] **Step 2: Typecheck (repo)** — Run: `pnpm typecheck` + Expected: PASS. + +- [ ] **Step 3: Lint (repo)** — Run: `pnpm lint` + Expected: PASS. + +- [ ] **Step 4: API freshness (required gate)** — Run: `pnpm api:check` + Expected: exit 0 (core + react reports regenerated in Tasks 3 & 7). If it fails, run `pnpm api` in a clean env (`rm -rf node_modules && pnpm install --frozen-lockfile`) and commit the reports. + +- [ ] **Step 5: Website build** — Run: `pnpm --filter @pretable/app-website build` + Expected: PASS; editing docs route generated. + +- [ ] **Step 6: Final commit (any verification fixups)** + +```bash +git add -A && git commit -m "chore: cell editing — verification fixups" +``` + +--- + +## Notes for the executor + +- **The required `API Extractor — report freshness` gate** blocks the PR until `core.api.md` and `react.api.md` are regenerated to match the new `@public` surface. Regenerate with `pnpm api` and, if CI disagrees with a local run, regenerate in a clean env (`rm -rf node_modules && pnpm install --frozen-lockfile`) — see the `project_dependabot_api_extractor_gap` memory. Worktree gotcha: if a run fails with an esbuild error, relink `node_modules/esbuild` to `.pnpm/esbuild@*/node_modules/esbuild`. +- **Don't restructure `pretable-surface.tsx`** — it's large; additions are at named anchors (keydown handler ~line 2266, `onDoubleClick` ~1448, the body-cell render path, the props interface). Match the file's existing local variable names for `grid`/`columns`/`snapshot`/`row`/`rowId`/`column`. +- **Controlled means pessimistic:** the grid shows the draft during `saving`; the row value only changes when the app updates `rows` in its `onCellEdit` handler. Tests use a resolved/rejected `onCellEdit` mock; they assert the callback payload and the `editing` status, not a mutated row. +- **Staleness** is the controller's job (the `token`); the engine transitions also no-op when `editing === null`, a second safety net. +- Keep all code type-correct against `packages/core/core.api.md` / `packages/grid-core/src/types.ts`. From 5dae7f9642715b2ad329a2e274ca47dda31b0f43 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 16:32:46 -0700 Subject: [PATCH 03/13] feat(grid-core): edit lifecycle types (PretableEditState/Status/Input) + column hooks + snapshot.editing --- packages/grid-core/src/types.ts | 66 +++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/packages/grid-core/src/types.ts b/packages/grid-core/src/types.ts index 42587188..aa957f42 100644 --- a/packages/grid-core/src/types.ts +++ b/packages/grid-core/src/types.ts @@ -17,6 +17,47 @@ export type PretableRow = Record; */ export type PretableSortDirection = "asc" | "desc" | null; +/** + * Phase of an in-progress cell edit. + * + * @public + */ +export type PretableEditStatus = + | "checking" + | "editing" + | "validating" + | "saving" + | "error"; + +/** + * Input passed to a column's edit hooks (`editable`, `validate`, `parseEditValue`, + * `formatEditValue`). + * + * @public + */ +export interface PretableEditInput { + rowId: string; + columnId: string; + row: TRow; + column: PretableColumn; + value: unknown; +} + +/** + * In-progress cell edit observed via `PretableGrid.getSnapshot().editing`. + * `error` carries the validation message (status `"editing"`) or the commit + * failure message (status `"error"`). + * + * @public + */ +export interface PretableEditState { + rowId: string; + columnId: string; + draft: unknown; + status: PretableEditStatus; + error?: string; +} + /** * Engine-level column definition. `@pretable/react` extends this with React-specific render fields. * @@ -37,6 +78,16 @@ export interface PretableColumn { maxWidthPx?: number; resizable?: boolean; reorderable?: boolean; + // cell editing (v1): + editable?: + | boolean + | ((input: PretableEditInput) => boolean | Promise); + validate?: ( + value: unknown, + input: PretableEditInput, + ) => (true | string) | Promise; + parseEditValue?: (raw: string, input: PretableEditInput) => unknown; + formatEditValue?: (value: unknown, input: PretableEditInput) => string; } /** @@ -162,6 +213,7 @@ export interface PretableGridSnapshot { totalRowCount: number; visibleRows: PretableVisibleRow[]; visibleRange: PretableRowRange; + editing: PretableEditState | null; } /** @internal */ @@ -199,6 +251,20 @@ export interface PretableEngine { autosizeColumn(columnId: string, options?: AutosizeOptions): void; resetColumnLayout(): void; mergeColumnsFromProps(nextColumns: PretableColumn[]): void; + + // cell editing (v1): + beginEdit( + addr: PretableCellAddress, + opts?: { draft?: unknown; status?: "checking" | "editing" }, + ): void; + setEditDraft(value: unknown): void; + markEditing(): void; + markEditValidating(): void; + markEditSaving(): void; + markEditInvalid(message: string): void; + markEditError(message: string): void; + commitEditSucceeded(): void; + cancelEdit(): void; } /** From 7ce7c157c825dc485992dad43d2226288265c3ff Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 16:40:20 -0700 Subject: [PATCH 04/13] feat(grid-core): implement sync edit-lifecycle transitions + snapshot.editing --- .../src/__tests__/edit-lifecycle.test.ts | 97 +++++++++++++++++++ packages/grid-core/src/create-grid-core.ts | 55 +++++++++++ 2 files changed, 152 insertions(+) create mode 100644 packages/grid-core/src/__tests__/edit-lifecycle.test.ts diff --git a/packages/grid-core/src/__tests__/edit-lifecycle.test.ts b/packages/grid-core/src/__tests__/edit-lifecycle.test.ts new file mode 100644 index 00000000..ad149a2d --- /dev/null +++ b/packages/grid-core/src/__tests__/edit-lifecycle.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; + +import { createGridCore } from "../create-grid-core"; + +const COLUMNS = [{ id: "name" }, { id: "age" }]; +const ROWS = [ + { id: "r1", name: "Ada", age: 36 }, + { id: "r2", name: "Linus", age: 54 }, +]; + +function makeGrid() { + return createGridCore({ + columns: COLUMNS, + rows: ROWS, + getRowId: (r) => r.id, + }); +} + +describe("edit lifecycle", () => { + it("starts with no edit", () => { + expect(makeGrid().getSnapshot().editing).toBeNull(); + }); + + it("beginEdit defaults to status 'editing' with the given draft", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "name" }, { draft: "Ad" }); + expect(g.getSnapshot().editing).toEqual({ + rowId: "r1", + columnId: "name", + draft: "Ad", + status: "editing", + }); + }); + + it("supports the async-editable 'checking' → 'editing' path", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "name" }, { status: "checking" }); + expect(g.getSnapshot().editing?.status).toBe("checking"); + g.markEditing(); + expect(g.getSnapshot().editing?.status).toBe("editing"); + }); + + it("runs validating → saving → success, clearing the edit", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "name" }, { draft: "Ada Lovelace" }); + g.setEditDraft("Ada L."); + g.markEditValidating(); + expect(g.getSnapshot().editing?.status).toBe("validating"); + g.markEditSaving(); + expect(g.getSnapshot().editing?.status).toBe("saving"); + g.commitEditSucceeded(); + expect(g.getSnapshot().editing).toBeNull(); + }); + + it("markEditInvalid returns to 'editing' with a message", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "age" }); + g.markEditValidating(); + g.markEditInvalid("must be a number"); + expect(g.getSnapshot().editing).toMatchObject({ + status: "editing", + error: "must be a number", + }); + }); + + it("markEditError enters 'error' with a message; cancelEdit clears it", () => { + const g = makeGrid(); + g.beginEdit({ rowId: "r1", columnId: "age" }); + g.markEditSaving(); + g.markEditError("network down"); + expect(g.getSnapshot().editing).toMatchObject({ + status: "error", + error: "network down", + }); + g.cancelEdit(); + expect(g.getSnapshot().editing).toBeNull(); + }); + + it("transition methods no-op when there is no active edit (stale-callback safety)", () => { + const g = makeGrid(); + g.markEditSaving(); + g.commitEditSucceeded(); + expect(g.getSnapshot().editing).toBeNull(); + }); + + it("notifies subscribers on edit transitions", () => { + const g = makeGrid(); + let calls = 0; + g.subscribe(() => { + calls += 1; + }); + g.beginEdit({ rowId: "r1", columnId: "name" }); + g.setEditDraft("x"); + g.commitEditSucceeded(); + expect(calls).toBe(3); + }); +}); diff --git a/packages/grid-core/src/create-grid-core.ts b/packages/grid-core/src/create-grid-core.ts index b37365df..1fdf4d8d 100644 --- a/packages/grid-core/src/create-grid-core.ts +++ b/packages/grid-core/src/create-grid-core.ts @@ -9,6 +9,7 @@ import type { PretableCellAddress, PretableCellRange, PretableColumn, + PretableEditState, PretableFocusDirection, PretableFocusState, PretableMoveFocusOptions, @@ -89,6 +90,7 @@ export function createGridCore( let filters: Record = {}; let selection: PretableSelectionState = { ranges: [], anchor: null }; let focus: PretableFocusState = { rowId: null, columnId: null }; + let editing: PretableEditState | null = null; let viewport: PretableViewportState = { scrollTop: 0, scrollLeft: 0, @@ -771,6 +773,58 @@ export function createGridCore( cachedVisibleRows = null; emit(); }, + beginEdit( + addr: PretableCellAddress, + opts?: { draft?: unknown; status?: "checking" | "editing" }, + ) { + editing = { + rowId: addr.rowId, + columnId: addr.columnId, + draft: opts?.draft, + status: opts?.status ?? "editing", + }; + emit(); + }, + setEditDraft(value: unknown) { + if (!editing) return; + editing = { ...editing, draft: value }; + emit(); + }, + markEditing() { + if (!editing || editing.status !== "checking") return; + editing = { ...editing, status: "editing", error: undefined }; + emit(); + }, + markEditValidating() { + if (!editing) return; + editing = { ...editing, status: "validating", error: undefined }; + emit(); + }, + markEditSaving() { + if (!editing) return; + editing = { ...editing, status: "saving", error: undefined }; + emit(); + }, + markEditInvalid(message: string) { + if (!editing) return; + editing = { ...editing, status: "editing", error: message }; + emit(); + }, + markEditError(message: string) { + if (!editing) return; + editing = { ...editing, status: "error", error: message }; + emit(); + }, + commitEditSucceeded() { + if (!editing) return; + editing = null; + emit(); + }, + cancelEdit() { + if (!editing) return; + editing = null; + emit(); + }, }; return store; @@ -811,6 +865,7 @@ export function createGridCore( start: 0, end: visibleRows.length, }, + editing: editing ? { ...editing } : null, }; return cachedSnapshot; From c2885f9286c6c07f390f93032869a3720782e75a Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 16:47:30 -0700 Subject: [PATCH 05/13] feat(core): expose edit lifecycle on PretableGrid + public edit types --- packages/core/core.api.md | 62 ++++++++++++++++++++++++++++++ packages/core/src/create-grid.ts | 9 +++++ packages/core/src/pretable-grid.ts | 14 +++++++ packages/core/src/public_api.ts | 3 ++ packages/core/src/types.ts | 3 ++ packages/grid-core/src/index.ts | 3 ++ 6 files changed, 94 insertions(+) diff --git a/packages/core/core.api.md b/packages/core/core.api.md index 7129ab87..732005ca 100644 --- a/packages/core/core.api.md +++ b/packages/core/core.api.md @@ -41,11 +41,15 @@ export interface PretableCellRange { // @public export interface PretableColumn { + // (undocumented) + editable?: boolean | ((input: PretableEditInput) => boolean | Promise); // (undocumented) filterable?: boolean; // (undocumented) format?: (input: PretableFormatInput) => string; // (undocumented) + formatEditValue?: (value: unknown, input: PretableEditInput) => string; + // (undocumented) header?: string; // (undocumented) id: string; @@ -54,6 +58,8 @@ export interface PretableColumn { // (undocumented) minWidthPx?: number; // (undocumented) + parseEditValue?: (raw: string, input: PretableEditInput) => unknown; + // (undocumented) pinned?: "left"; // (undocumented) reorderable?: boolean; @@ -62,6 +68,8 @@ export interface PretableColumn { // (undocumented) sortable?: boolean; // (undocumented) + validate?: (value: unknown, input: PretableEditInput) => (true | string) | Promise; + // (undocumented) value?: (row: TRow) => unknown; // (undocumented) widthPx?: number; @@ -69,6 +77,37 @@ export interface PretableColumn { wrap?: boolean; } +// @public +export interface PretableEditInput { + // (undocumented) + column: PretableColumn; + // (undocumented) + columnId: string; + // (undocumented) + row: TRow; + // (undocumented) + rowId: string; + // (undocumented) + value: unknown; +} + +// @public +export interface PretableEditState { + // (undocumented) + columnId: string; + // (undocumented) + draft: unknown; + // (undocumented) + error?: string; + // (undocumented) + rowId: string; + // (undocumented) + status: PretableEditStatus; +} + +// @public +export type PretableEditStatus = "checking" | "editing" | "validating" | "saving" | "error"; + // @public export type PretableFocusDirection = "up" | "down" | "left" | "right"; @@ -101,14 +140,33 @@ export interface PretableGrid { // (undocumented) autosizeColumns(options?: AutosizeOptions): void; // (undocumented) + beginEdit(addr: PretableCellAddress, opts?: { + draft?: unknown; + status?: "checking" | "editing"; + }): void; + // (undocumented) + cancelEdit(): void; + // (undocumented) clearFilters(): void; // (undocumented) clearSelection(): void; // (undocumented) + commitEditSucceeded(): void; + // (undocumented) extendRangeFromAnchor(addr: PretableCellAddress): void; getSnapshot(): PretableGridSnapshot; readonly kind: "pretable-grid"; // (undocumented) + markEditError(message: string): void; + // (undocumented) + markEditing(): void; + // (undocumented) + markEditInvalid(message: string): void; + // (undocumented) + markEditSaving(): void; + // (undocumented) + markEditValidating(): void; + // (undocumented) mergeColumnsFromProps(nextColumns: PretableColumn[]): void; // (undocumented) moveColumn(columnId: string, toIndex: number): void; @@ -126,6 +184,8 @@ export interface PretableGrid { // (undocumented) setColumnWidth(columnId: string, width: number): void; // (undocumented) + setEditDraft(value: unknown): void; + // (undocumented) setFilter(columnId: string, value: string): void; // (undocumented) setFocus(addr: PretableCellAddress | null): void; @@ -156,6 +216,8 @@ export interface PretableGridOptions { // @public export interface PretableGridSnapshot { + // (undocumented) + editing: PretableEditState | null; // (undocumented) filters: Record; // (undocumented) diff --git a/packages/core/src/create-grid.ts b/packages/core/src/create-grid.ts index 0b90c68b..51b33d91 100644 --- a/packages/core/src/create-grid.ts +++ b/packages/core/src/create-grid.ts @@ -53,5 +53,14 @@ export function createGrid( resetColumnLayout: engine.resetColumnLayout, mergeColumnsFromProps: engine.mergeColumnsFromProps, applyTransaction: engine.applyTransaction, + beginEdit: engine.beginEdit, + setEditDraft: engine.setEditDraft, + markEditing: engine.markEditing, + markEditValidating: engine.markEditValidating, + markEditSaving: engine.markEditSaving, + markEditInvalid: engine.markEditInvalid, + markEditError: engine.markEditError, + commitEditSucceeded: engine.commitEditSucceeded, + cancelEdit: engine.cancelEdit, }; } diff --git a/packages/core/src/pretable-grid.ts b/packages/core/src/pretable-grid.ts index 644ffe52..87f555a7 100644 --- a/packages/core/src/pretable-grid.ts +++ b/packages/core/src/pretable-grid.ts @@ -70,4 +70,18 @@ export interface PretableGrid { // streaming applyTransaction(transaction: PretableTransaction): void; + + // cell editing (v1) + beginEdit( + addr: PretableCellAddress, + opts?: { draft?: unknown; status?: "checking" | "editing" }, + ): void; + setEditDraft(value: unknown): void; + markEditing(): void; + markEditValidating(): void; + markEditSaving(): void; + markEditInvalid(message: string): void; + markEditError(message: string): void; + commitEditSucceeded(): void; + cancelEdit(): void; } diff --git a/packages/core/src/public_api.ts b/packages/core/src/public_api.ts index 4ceafc8e..4bf6bc08 100644 --- a/packages/core/src/public_api.ts +++ b/packages/core/src/public_api.ts @@ -14,6 +14,9 @@ export type { PretableCellAddress, PretableCellRange, PretableColumn, + PretableEditInput, + PretableEditState, + PretableEditStatus, PretableFocusDirection, PretableFocusState, PretableFormatInput, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 9d77184d..0545f5fa 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -3,6 +3,9 @@ export type { PretableCellAddress, PretableCellRange, PretableColumn, + PretableEditInput, + PretableEditState, + PretableEditStatus, PretableFocusDirection, PretableFocusState, PretableFormatInput, diff --git a/packages/grid-core/src/index.ts b/packages/grid-core/src/index.ts index 9f3c99c3..e5ef4f23 100644 --- a/packages/grid-core/src/index.ts +++ b/packages/grid-core/src/index.ts @@ -8,6 +8,9 @@ export type { PretableCellAddress, PretableCellRange, PretableColumn, + PretableEditInput, + PretableEditState, + PretableEditStatus, PretableFocusDirection, PretableFocusState, PretableFormatInput, From 1b63d17427876ae1e1037434044c60419848fef9 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 17:17:57 -0700 Subject: [PATCH 06/13] feat(react): async cell-edit orchestrator with staleness guard Co-Authored-By: Claude Opus 4.8 (1M context) --- .../use-cell-edit-controller.test.ts | 112 +++++++++++++++ .../react/src/use-cell-edit-controller.ts | 133 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 packages/react/src/__tests__/use-cell-edit-controller.test.ts create mode 100644 packages/react/src/use-cell-edit-controller.ts diff --git a/packages/react/src/__tests__/use-cell-edit-controller.test.ts b/packages/react/src/__tests__/use-cell-edit-controller.test.ts new file mode 100644 index 00000000..d685cfa0 --- /dev/null +++ b/packages/react/src/__tests__/use-cell-edit-controller.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createGrid, type PretableColumn } from "@pretable/core"; + +import { createCellEditController } from "../use-cell-edit-controller"; + +interface Row extends Record { + id: string; + name: string; +} +const ROWS: Row[] = [{ id: "r1", name: "Ada" }]; + +function setup( + columnOverrides: Partial> = {}, + onCellEdit = vi.fn(), +) { + const columns: PretableColumn[] = [ + { id: "name", editable: true, ...columnOverrides }, + ]; + const grid = createGrid({ columns, rows: ROWS, getRowId: (r) => r.id }); + const controller = createCellEditController({ + grid, + getColumns: () => columns, + getRowById: (id) => ROWS.find((r) => r.id === id) ?? null, + onCellEdit, + }); + return { grid, controller, onCellEdit }; +} + +const tick = () => new Promise((r) => setTimeout(r, 0)); + +describe("cell edit controller", () => { + it("begins an edit immediately when editable === true", async () => { + const { grid, controller } = setup(); + await controller.begin({ rowId: "r1", columnId: "name" }); + expect(grid.getSnapshot().editing).toMatchObject({ + rowId: "r1", + status: "editing", + }); + }); + + it("gates begin through 'checking' for async editable", async () => { + let resolve!: (v: boolean) => void; + const { grid, controller } = setup({ + editable: () => new Promise((r) => (resolve = r)), + }); + const p = controller.begin({ rowId: "r1", columnId: "name" }); + expect(grid.getSnapshot().editing?.status).toBe("checking"); + resolve(true); + await p; + expect(grid.getSnapshot().editing?.status).toBe("editing"); + }); + + it("does not begin when async editable resolves false", async () => { + const { grid, controller } = setup({ + editable: () => Promise.resolve(false), + }); + await controller.begin({ rowId: "r1", columnId: "name" }); + expect(grid.getSnapshot().editing).toBeNull(); + }); + + it("validate failure returns to editing with the message", async () => { + const { grid, controller } = setup({ validate: () => "too short" }); + await controller.begin({ rowId: "r1", columnId: "name" }); + grid.setEditDraft("x"); + await controller.commit("down"); + expect(grid.getSnapshot().editing).toMatchObject({ + status: "editing", + error: "too short", + }); + }); + + it("successful async commit calls onCellEdit then clears the edit", async () => { + const onCellEdit = vi.fn().mockResolvedValue(undefined); + const { grid, controller } = setup({}, onCellEdit); + await controller.begin({ rowId: "r1", columnId: "name" }); + grid.setEditDraft("Ada L."); + await controller.commit("down"); + expect(onCellEdit).toHaveBeenCalledWith( + expect.objectContaining({ + rowId: "r1", + columnId: "name", + value: "Ada L.", + }), + ); + expect(grid.getSnapshot().editing).toBeNull(); + }); + + it("commit rejection enters 'error'", async () => { + const onCellEdit = vi.fn().mockRejectedValue(new Error("boom")); + const { grid, controller } = setup({}, onCellEdit); + await controller.begin({ rowId: "r1", columnId: "name" }); + await controller.commit("down"); + expect(grid.getSnapshot().editing).toMatchObject({ + status: "error", + error: "boom", + }); + }); + + it("drops a stale async-editable resolution after cancel (staleness guard)", async () => { + let resolve!: (v: boolean) => void; + const { grid, controller } = setup({ + editable: () => new Promise((r) => (resolve = r)), + }); + const p = controller.begin({ rowId: "r1", columnId: "name" }); + controller.cancel(); + expect(grid.getSnapshot().editing).toBeNull(); + resolve(true); + await p; + expect(grid.getSnapshot().editing).toBeNull(); // stale true did not re-open + }); +}); diff --git a/packages/react/src/use-cell-edit-controller.ts b/packages/react/src/use-cell-edit-controller.ts new file mode 100644 index 00000000..b9a06e05 --- /dev/null +++ b/packages/react/src/use-cell-edit-controller.ts @@ -0,0 +1,133 @@ +import { useMemo } from "react"; + +import type { + PretableCellAddress, + PretableColumn, + PretableEditInput, + PretableFocusDirection, + PretableGrid, + PretableRow, +} from "@pretable/core"; + +export interface CellEditController { + begin(addr: PretableCellAddress, initialDraft?: unknown): Promise; + commit(moveDirection?: PretableFocusDirection): Promise; + cancel(): void; +} + +export interface CellEditControllerOptions { + grid: PretableGrid; + getColumns: () => PretableColumn[]; + getRowById: (rowId: string) => TRow | null; + onCellEdit?: (payload: { + rowId: string; + columnId: string; + value: unknown; + row: TRow; + }) => void | Promise; +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +// Stand-alone factory (tested directly). `useCellEditController` wraps it in useMemo. +export function createCellEditController( + opts: CellEditControllerOptions, +): CellEditController { + const { grid, getColumns, getRowById, onCellEdit } = opts; + // Monotonic token: every begin()/cancel() bumps it, so a stale async + // resolution (editable/commit) can detect it is no longer the active edit. + let token = 0; + + const inputFor = ( + addr: PretableCellAddress, + ): PretableEditInput | null => { + const column = getColumns().find((c) => c.id === addr.columnId); + const row = getRowById(addr.rowId); + if (!column || !row) return null; + const value = column.value ? column.value(row) : row[addr.columnId]; + return { rowId: addr.rowId, columnId: addr.columnId, row, column, value }; + }; + + return { + async begin(addr, initialDraft) { + const input = inputFor(addr); + if (!input) return; + const editable = input.column.editable ?? false; + const seed = + initialDraft !== undefined + ? initialDraft + : input.column.formatEditValue + ? input.column.formatEditValue(input.value, input) + : input.value; + + if (editable === false) return; + if (editable === true) { + grid.beginEdit(addr, { draft: seed, status: "editing" }); + token += 1; + return; + } + // async / function editable + const myToken = (token += 1); + grid.beginEdit(addr, { draft: seed, status: "checking" }); + const allowed = await editable(input); + if (myToken !== token) return; // stale + if (allowed) grid.markEditing(); + else grid.cancelEdit(); + }, + + async commit(moveDirection) { + const editing = grid.getSnapshot().editing; + if (!editing) return; + const addr = { rowId: editing.rowId, columnId: editing.columnId }; + const input = inputFor(addr); + if (!input) return; + const myToken = (token += 1); + const draft = editing.draft; + const value = input.column.parseEditValue + ? input.column.parseEditValue(String(draft ?? ""), input) + : draft; + + if (input.column.validate) { + grid.markEditValidating(); + const result = await input.column.validate(value, input); + if (myToken !== token) return; // stale + if (result !== true) { + grid.markEditInvalid(result); + return; + } + } + + grid.markEditSaving(); + try { + await onCellEdit?.({ + rowId: addr.rowId, + columnId: addr.columnId, + value, + row: input.row, + }); + if (myToken !== token) return; // stale + grid.commitEditSucceeded(); + if (moveDirection) grid.moveFocus(moveDirection); + } catch (err) { + if (myToken !== token) return; // stale + grid.markEditError(errorMessage(err)); + } + }, + + cancel() { + token += 1; + grid.cancelEdit(); + }, + }; +} + +export function useCellEditController( + opts: CellEditControllerOptions, +): CellEditController { + // grid identity is stable for the life of the surface; other opts read via + // closures that always see latest. Recreate only if grid changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => createCellEditController(opts), [opts.grid]); +} From 05a16cdac7634a47a8c5f496b0920ff20d1d28dd Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 17:31:38 -0700 Subject: [PATCH 07/13] feat(react): default CellEditor + renderEditor column hook + PretableEditorInput Co-Authored-By: Claude Opus 4.8 (1M context) --- .../react/src/__tests__/cell-editor.test.tsx | 64 +++++++++++++++++++ packages/react/src/cell-editor.tsx | 49 ++++++++++++++ packages/react/src/types.ts | 20 ++++++ 3 files changed, 133 insertions(+) create mode 100644 packages/react/src/__tests__/cell-editor.test.tsx create mode 100644 packages/react/src/cell-editor.tsx diff --git a/packages/react/src/__tests__/cell-editor.test.tsx b/packages/react/src/__tests__/cell-editor.test.tsx new file mode 100644 index 00000000..9e25461c --- /dev/null +++ b/packages/react/src/__tests__/cell-editor.test.tsx @@ -0,0 +1,64 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, fireEvent } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { CellEditor } from "../cell-editor"; +import type { PretableEditorInput } from "../types"; + +afterEach(() => { + cleanup(); +}); + +function makeInput( + over: Partial = {}, +): PretableEditorInput { + return { + rowId: "r1", + columnId: "name", + row: { id: "r1", name: "Ada" }, + column: { id: "name" }, + value: "Ada", + draft: "Ada", + setDraft: vi.fn(), + commit: vi.fn(), + cancel: vi.fn(), + ...over, + }; +} + +describe("CellEditor (default)", () => { + it("renders a text input seeded with the draft", () => { + render(); + expect(screen.getByRole("textbox")).toHaveValue("Ada"); + }); + + it("pushes keystrokes to setDraft", () => { + const setDraft = vi.fn(); + render(); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Ada L." }, + }); + expect(setDraft).toHaveBeenCalledWith("Ada L."); + }); + + it("commits down on Enter, right on Tab, and cancels on Escape", () => { + const commit = vi.fn(); + const cancel = vi.fn(); + render(); + const box = screen.getByRole("textbox"); + fireEvent.keyDown(box, { key: "Enter" }); + expect(commit).toHaveBeenCalledWith("down"); + fireEvent.keyDown(box, { key: "Tab" }); + expect(commit).toHaveBeenCalledWith("right"); + fireEvent.keyDown(box, { key: "Escape" }); + expect(cancel).toHaveBeenCalled(); + }); + + it("delegates to column.renderEditor when provided", () => { + const input = makeInput({ + column: { id: "name", renderEditor: () => custom }, + }); + render(); + expect(screen.getByText("custom")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/cell-editor.tsx b/packages/react/src/cell-editor.tsx new file mode 100644 index 00000000..5191a7a8 --- /dev/null +++ b/packages/react/src/cell-editor.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; + +import type { PretableEditorInput } from "./types"; + +export interface CellEditorProps { + input: PretableEditorInput; +} + +/** + * Renders a column's `renderEditor` if present, otherwise a default text input + * that drives the active edit's draft and commit/cancel. + */ +export function CellEditor({ input }: CellEditorProps) { + const ref = useRef(null); + + // Autofocus + select on mount so type-to-replace and immediate typing work. + useEffect(() => { + ref.current?.focus(); + ref.current?.select(); + }, []); + + if (input.column.renderEditor) { + return <>{input.column.renderEditor(input)}; + } + + return ( + input.setDraft(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + input.commit("down"); + } else if (e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + input.commit("right"); + } else if (e.key === "Escape" || e.key === "Esc") { + e.preventDefault(); + e.stopPropagation(); + input.cancel(); + } + }} + /> + ); +} diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index b0aff16f..feb8d23a 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -1,6 +1,8 @@ import type { ReactNode } from "react"; import type { PretableColumn as PretableBaseColumn, + PretableEditInput, + PretableFocusDirection, PretableFormatInput, PretableRow, } from "@pretable/core"; @@ -15,6 +17,24 @@ export interface PretableColumn< > extends PretableBaseColumn { render?: (input: PretableCellRenderInput) => ReactNode; renderHeader?: (input: PretableHeaderRenderInput) => ReactNode; + renderEditor?: (input: PretableEditorInput) => ReactNode; +} + +/** + * Input passed to a column's `renderEditor`. Extends the engine edit input with + * draft controls bound to the active edit. `commit` accepts the focus direction + * to move after a successful commit (Enter → "down", Tab → "right"). + * + * @public + */ +export interface PretableEditorInput< + TRow extends PretableRow = PretableRow, +> extends Omit, "column"> { + column: PretableColumn; + draft: unknown; + setDraft: (value: unknown) => void; + commit: (direction?: PretableFocusDirection) => void; + cancel: () => void; } /** From 9ce88b7190ac1ddebeb2e02ade6eea24dc0358b8 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 17:51:55 -0700 Subject: [PATCH 08/13] test(react): remove unused tick helper in cell-edit-controller test Fixes a no-unused-vars lint error that would fail the required repo-lint CI gate. The helper was never wired into any test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react/src/__tests__/use-cell-edit-controller.test.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react/src/__tests__/use-cell-edit-controller.test.ts b/packages/react/src/__tests__/use-cell-edit-controller.test.ts index d685cfa0..8d18b344 100644 --- a/packages/react/src/__tests__/use-cell-edit-controller.test.ts +++ b/packages/react/src/__tests__/use-cell-edit-controller.test.ts @@ -27,8 +27,6 @@ function setup( return { grid, controller, onCellEdit }; } -const tick = () => new Promise((r) => setTimeout(r, 0)); - describe("cell edit controller", () => { it("begins an edit immediately when editable === true", async () => { const { grid, controller } = setup(); From 30cc533c911be9b850adb074ac7ba389378655c2 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Mon, 8 Jun 2026 18:17:56 -0700 Subject: [PATCH 09/13] feat(react): wire cell editing into PretableSurface (triggers, editor render, onCellEdit) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../pretable-surface-editing.test.tsx | 104 +++++++++++++++ packages/react/src/pretable-surface.tsx | 122 +++++++++++++++++- 2 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 packages/react/src/__tests__/pretable-surface-editing.test.tsx diff --git a/packages/react/src/__tests__/pretable-surface-editing.test.tsx b/packages/react/src/__tests__/pretable-surface-editing.test.tsx new file mode 100644 index 00000000..2bf00aae --- /dev/null +++ b/packages/react/src/__tests__/pretable-surface-editing.test.tsx @@ -0,0 +1,104 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { PretableSurface } from "../pretable-surface"; +import type { PretableColumn } from "../types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +interface Row extends Record { + id: string; + name: string; +} +const ROWS: Row[] = [ + { id: "r1", name: "Ada" }, + { id: "r2", name: "Linus" }, +]; +const COLUMNS: PretableColumn[] = [ + { id: "name", header: "Name", editable: true }, +]; + +function renderGrid(onCellEdit = vi.fn()) { + render( + + ariaLabel="people" + columns={COLUMNS} + rows={ROWS} + getRowId={(r) => r.id} + viewportHeight={300} + onCellEdit={onCellEdit} + />, + ); + return { onCellEdit }; +} + +function firstNameCell(): HTMLElement { + // first body row, first cell + return within(screen.getAllByRole("row")[1]).getAllByRole("gridcell")[0]; +} + +describe("PretableSurface editing", () => { + it("enters edit mode on Enter and shows an input", () => { + renderGrid(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); + + it("commits on Enter and fires onCellEdit with the new value", async () => { + const { onCellEdit } = renderGrid(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + const box = screen.getByRole("textbox"); + fireEvent.change(box, { target: { value: "Ada Lovelace" } }); + fireEvent.keyDown(box, { key: "Enter" }); + await Promise.resolve(); + expect(onCellEdit).toHaveBeenCalledWith( + expect.objectContaining({ + rowId: "r1", + columnId: "name", + value: "Ada Lovelace", + }), + ); + }); + + it("reverts on Escape without firing onCellEdit", () => { + const { onCellEdit } = renderGrid(); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "x" } }); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Escape" }); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + expect(onCellEdit).not.toHaveBeenCalled(); + }); + + it("does not enter edit mode for a non-editable column", () => { + render( + + ariaLabel="people" + columns={[{ id: "name", header: "Name" }]} + rows={ROWS} + getRowId={(r) => r.id} + viewportHeight={300} + />, + ); + const cell = firstNameCell(); + fireEvent.click(cell); + fireEvent.keyDown(cell, { key: "Enter" }); + expect(screen.queryByRole("textbox")).not.toBeInTheDocument(); + }); + + it("enters edit mode on double-click of an editable cell", () => { + renderGrid(); + const cell = firstNameCell(); + fireEvent.doubleClick(cell); + expect(screen.getByRole("textbox")).toBeInTheDocument(); + }); +}); diff --git a/packages/react/src/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx index af5ecc71..b7aa82d7 100644 --- a/packages/react/src/pretable-surface.tsx +++ b/packages/react/src/pretable-surface.tsx @@ -26,6 +26,7 @@ import type { import type { PretableCellRenderInput, PretableColumn, + PretableEditorInput, PretableHeaderRenderInput, } from "./types"; @@ -57,6 +58,8 @@ import { export { ROW_SELECT_COLUMN_ID } from "./constants"; import { ROW_SELECT_COLUMN_ID } from "./constants"; +import { useCellEditController } from "./use-cell-edit-controller"; +import { CellEditor } from "./cell-editor"; import { type CopyPayload, type SerializeRangesArgs, @@ -270,6 +273,17 @@ export interface PretableSurfaceProps { * fall back to English defaults. */ messages?: PretableSurfaceMessages; + /** + * Called when a cell edit commits successfully. The grid is controlled: + * update your own `rows` from this callback. Return a promise to keep the + * edit in its `saving` phase until it resolves (rejection enters `error`). + */ + onCellEdit?: (payload: { + rowId: string; + columnId: string; + value: unknown; + row: TRow; + }) => void | Promise; } interface MemoizedCellContentProps { @@ -433,6 +447,7 @@ export function PretableSurface({ onCopy, copyToClipboard, messages, + onCellEdit, }: PretableSurfaceProps) { const [measuredHeights, setMeasuredHeights] = useState< Record @@ -542,6 +557,40 @@ export function PretableSurface({ }); const focusedRowId = snapshot.focus.rowId; const focusedColumnId = snapshot.focus.columnId; + + // Cell editing. `useCellEditController` memoizes on `grid` only, so the + // closures it captures would otherwise go stale across renders. Keep refs to + // the latest columns/rows/onCellEdit and read them through stable wrappers so + // the (memoized) controller always sees current data. Refs are synced in a + // layout effect (every render, no deps) — they only need to be current before + // event handlers / async resolutions read them, which happen post-commit. + const editColumnsRef = useRef(effectiveColumns); + const editVisibleRowsRef = useRef(snapshot.visibleRows); + const onCellEditRef = useRef(onCellEdit); + useLayoutEffect(() => { + editColumnsRef.current = effectiveColumns; + editVisibleRowsRef.current = snapshot.visibleRows; + onCellEditRef.current = onCellEdit; + }); + const editController = useCellEditController({ + grid, + getColumns: useCallback(() => editColumnsRef.current, []), + getRowById: useCallback( + (id: string) => + editVisibleRowsRef.current.find((r) => r.id === id)?.row ?? null, + [], + ), + onCellEdit: useCallback( + (payload: { + rowId: string; + columnId: string; + value: unknown; + row: TRow; + }) => onCellEditRef.current?.(payload), + [], + ), + }); + const pinnedOffsets = useMemo( () => getPinnedLeftOffsets(effectiveColumns), [effectiveColumns], @@ -957,6 +1006,44 @@ export function PretableSurface({ return; } + // Begin-edit triggers (Enter / F2 / type-to-replace). Only when no edit + // is active and the focused cell's column is editable; otherwise fall + // through so Enter/Space keep their row-selection behavior. When an edit + // IS active the editor input owns keystrokes (Enter/Tab/Escape are + // stop-propagated inside CellEditor), so this handler is not reached. + if (!snapshot.editing) { + const focusAddr = + snapshot.focus.rowId && snapshot.focus.columnId + ? { + rowId: snapshot.focus.rowId, + columnId: snapshot.focus.columnId, + } + : null; + const focusedColumn = focusAddr + ? effectiveColumns.find((c) => c.id === focusAddr.columnId) + : undefined; + if (focusAddr && focusedColumn?.editable) { + const cmd = event.metaKey || event.ctrlKey; + if (event.key === "Enter" || event.key === "F2") { + event.preventDefault(); + void editController.begin(focusAddr); + return; + } + // type-to-replace: a single printable, non-whitespace character + // seeds the draft. Space is reserved for row selection. + if ( + event.key.length === 1 && + event.key !== " " && + !cmd && + !event.altKey + ) { + event.preventDefault(); + void editController.begin(focusAddr, event.key); + return; + } + } + } + const isSelectAll = (event.metaKey || event.ctrlKey) && (event.key === "a" || event.key === "A") && @@ -1521,6 +1608,12 @@ export function PretableSurface({ const cellIsFocused = isFocused && snapshot.focus.columnId === column.id; const cellIsSelected = isCellSelected(id, column.id); + const cellEdit = + snapshot.editing && + snapshot.editing.rowId === id && + snapshot.editing.columnId === column.id + ? snapshot.editing + : null; const formattedValue = column.format ? column.format({ value, row, column }) : formatCellValue(value); @@ -1573,6 +1666,7 @@ export function PretableSurface({ isRowSelectCell ? "true" : undefined } data-pretable-selected={cellIsSelected ? "true" : "false"} + data-pretable-edit-status={cellEdit?.status} key={`${id}:${column.id}`} onClick={(event) => { if (column.id === ROW_SELECT_COLUMN_ID) return; @@ -1588,6 +1682,15 @@ export function PretableSurface({ shift: event.shiftKey, }); }} + onDoubleClick={() => { + if (column.id === ROW_SELECT_COLUMN_ID) return; + if (column.editable) { + void editController.begin({ + rowId: id, + columnId: column.id, + }); + } + }} onPointerDown={(event) => { if (event.button !== 0) return; if (column.id === ROW_SELECT_COLUMN_ID) return; @@ -1678,7 +1781,24 @@ export function PretableSurface({ }} tabIndex={cellIsFocused ? 0 : -1} > - {isRowSelectCell ? ( + {cellEdit ? ( + grid.setEditDraft(v), + commit: (dir?: PretableFocusDirection) => + void editController.commit(dir ?? "down"), + cancel: () => editController.cancel(), + } as unknown as PretableEditorInput + } + /> + ) : isRowSelectCell ? (