diff --git a/apps/website/app/docs/_nav.ts b/apps/website/app/docs/_nav.ts
index 65dc58b0..51c97315 100644
--- a/apps/website/app/docs/_nav.ts
+++ b/apps/website/app/docs/_nav.ts
@@ -31,6 +31,7 @@ export const docsNav: DocsNavSection[] = [
{ title: "Selection", href: "/docs/grid/selection" },
{ title: "Keyboard", href: "/docs/grid/keyboard" },
{ title: "Clipboard", href: "/docs/grid/clipboard" },
+ { title: "Editing", href: "/docs/grid/editing" },
{ title: "Column layout", href: "/docs/grid/column-layout" },
{ title: "Cell renderers", href: "/docs/grid/cell-renderers" },
{
diff --git a/apps/website/content/docs/grid/editing.mdx b/apps/website/content/docs/grid/editing.mdx
new file mode 100644
index 00000000..f7fb8684
--- /dev/null
+++ b/apps/website/content/docs/grid/editing.mdx
@@ -0,0 +1,239 @@
+---
+title: Editing
+description: "Controlled inline cell editing with an async editable / validate / commit lifecycle."
+nav: Grid
+order: 7
+---
+
+Inline editing is **controlled**: the grid never mutates your rows. A successful commit fires `onCellEdit({ rowId, columnId, value, row })`, and you write the new value into your own state and feed the updated `rows` back down. The grid owns the in-progress edit (the draft, the lifecycle phase); your app owns the data.
+
+Editing is **off by default** — a cell only becomes editable when its column opts in.
+
+
+ This is single-cell editing. Fill-handle drag, paste-to-edit, multi-cell
+ edits, and undo are not part of this phase.
+
+
+## The controlled model
+
+`onCellEdit` is a prop on both `` and ``. It receives the committed value and the row it came from; return a promise to make the commit await your save:
+
+```tsx
+ row.id}
+ onCellEdit={({ rowId, columnId, value, row }) => {
+ // apply `value` to your own state — the grid does not touch `rows`
+ setRows((prev) =>
+ prev.map((r) => (r.id === rowId ? { ...r, [columnId]: value } : r)),
+ );
+ }}
+/>
+```
+
+The payload is `{ rowId, columnId, value, row }`:
+
+| Field | Type | Notes |
+| ---------- | --------- | --------------------------------------------------------- |
+| `rowId` | `string` | the `getRowId` of the edited row |
+| `columnId` | `string` | `column.id` of the edited cell |
+| `value` | `unknown` | the committed value (after `parseEditValue`, if supplied) |
+| `row` | `TRow` | the row object the edit targets |
+
+## Making a column editable
+
+Set `editable` on the column. It's `false` by default; pass `true` to allow editing unconditionally, or a function to gate it per cell:
+
+```tsx
+import type { PretableColumn } from "@pretable/react";
+
+const columns: PretableColumn[] = [
+ { id: "name", header: "Name", editable: true },
+ {
+ id: "email",
+ header: "Email",
+ // gate per cell — sync or async (return a Promise)
+ editable: ({ row }) => row.status !== "locked",
+ },
+];
+```
+
+The function form receives a `PretableEditInput` (`{ rowId, columnId, row, column, value }`) and may return a `Promise`, so a permission check can hit the network before the editor opens. While an async `editable` is pending, the edit sits in the `checking` phase (see [Lifecycle](#lifecycle)); if it resolves `false`, the edit is cancelled and no editor appears.
+
+## Validating
+
+`validate` runs on commit, before `onCellEdit`. Return `true` to accept, or a **string** to reject — the string becomes the validation message and the cell stays in edit mode so the user can fix it. It can be sync or async:
+
+```tsx
+const columns: PretableColumn[] = [
+ {
+ id: "age",
+ header: "Age",
+ editable: true,
+ parseEditValue: (raw) => Number(raw),
+ validate: (value) => {
+ if (typeof value !== "number" || Number.isNaN(value))
+ return "Enter a number";
+ if (value < 0) return "Age cannot be negative";
+ return true;
+ },
+ },
+];
+```
+
+`validate(value, input)` receives the parsed value and the same `PretableEditInput`. A returned string keeps the edit open with `snapshot.editing.error` set to that message; a `Promise` lets you validate against a server. Commit only proceeds to `onCellEdit` once validation passes.
+
+## Custom editors
+
+The default editor is a text input. To render your own — a ``, a date picker, a numeric stepper — supply `renderEditor`. Pair it with `parseEditValue` (string → your value type) and `formatEditValue` (your value → the string the editor seeds from):
+
+```tsx
+const columns: PretableColumn[] = [
+ {
+ id: "status",
+ header: "Status",
+ editable: true,
+ renderEditor: ({ draft, setDraft, commit, cancel }) => (
+ setDraft(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Escape") cancel();
+ }}
+ onBlur={() => commit()}
+ >
+ Active
+ Paused
+
+ ),
+ },
+];
+```
+
+`renderEditor` receives a `PretableEditorInput` — the edit input (`rowId`, `columnId`, `row`, `column`, `value`) plus the live draft controls:
+
+| Field | Type | Notes |
+| ---------- | ---------------------------------------------- | ------------------------------------------------------------------ |
+| `draft` | `unknown` | the current in-progress value |
+| `setDraft` | `(value: unknown) => void` | update the draft as the user types |
+| `commit` | `(direction?: PretableFocusDirection) => void` | commit the draft, optionally moving focus (`"down"`, `"right"`, …) |
+| `cancel` | `() => void` | discard the edit and restore the cell |
+
+`parseEditValue(raw, input)` turns the editor's string draft into the value handed to `validate` and `onCellEdit`. `formatEditValue(value, input)` produces the initial string shown when the editor opens. Supply both when your stored value isn't a plain string (a number, a `Date`, an enum) so the round-trip stays type-correct.
+
+## Lifecycle
+
+A commit is **pessimistic**: the grid keeps showing the draft while the work runs and only clears the edit once `onCellEdit` resolves. The edit moves through a sequence of phases, observable as `snapshot.editing.status`:
+
+```text
+checking → editing → validating → saving → (cleared)
+ ↑__________| |
+ invalid (validate |
+ returned a string) ↓
+ error (onCellEdit threw)
+```
+
+| Phase | Meaning |
+| ------------ | ----------------------------------------------------------------- |
+| `checking` | an async `editable` is resolving; no editor yet |
+| `editing` | the editor is open and accepting input |
+| `validating` | `validate` is running on the draft |
+| `saving` | validation passed; `onCellEdit` is in flight |
+| `error` | `onCellEdit` rejected; `snapshot.editing.error` holds the message |
+
+When `validate` returns a string the edit returns to `editing` with `snapshot.editing.error` set to that message. When `onCellEdit` throws or rejects, the edit enters `error` (it does **not** clear) so you can surface the failure and let the user retry or cancel.
+
+For most apps the default editor handles all of this and you never touch the phases directly. If you render cells yourself (a custom `render`, or the [headless engine](/docs/headless)), read `grid.getSnapshot().editing` — `{ rowId, columnId, draft, status, error? }` — to drive your own in-cell editor or status affordance:
+
+```tsx
+const { editing } = grid.getSnapshot();
+if (editing?.status === "saving") {
+ // show a spinner in the cell at editing.rowId / editing.columnId
+}
+```
+
+
+ The engine's edit transitions (`beginEdit`, `markEditSaving`, …) are advanced,
+ headless-only primitives. In the controlled `` / ``
+ path you don't call them — `onCellEdit`, the column hooks, and the keyboard drive
+ the lifecycle for you.
+
+
+## Keyboard
+
+Editing reuses the focused cell from the [selection](/docs/grid/selection) model.
+
+| Key | When | Effect |
+| ------------------ | ------------------- | ---------------------------------------------------------------------- |
+| `Enter` / `F2` | cell focused | begin editing the focused cell |
+| Double-click | on an editable cell | begin editing that cell |
+| Any printable char | cell focused | begin editing, seeding the draft with that character (type-to-replace) |
+| `Enter` | editing | commit, then move focus **down** |
+| `Tab` | editing | commit, then move focus **right** |
+| `Escape` | editing | cancel — discard the draft, restore the cell |
+
+A begin trigger on a non-editable column is a no-op. While an editor is open it owns keystrokes; `Enter`, `Tab`, and `Escape` are handled by the editor and don't fall through to grid navigation.
+
+## Worked example
+
+A small grid where `name` is editable and `onCellEdit` updates React state:
+
+```tsx
+import { useState } from "react";
+import { PretableSurface, type PretableColumn } from "@pretable/react";
+
+interface Person extends Record {
+ id: string;
+ name: string;
+ age: number;
+}
+
+const columns: PretableColumn[] = [
+ { id: "name", header: "Name", widthPx: 200, editable: true },
+ {
+ id: "age",
+ header: "Age",
+ widthPx: 100,
+ editable: true,
+ parseEditValue: (raw) => Number(raw),
+ validate: (value) =>
+ typeof value === "number" && !Number.isNaN(value) && value >= 0
+ ? true
+ : "Enter a non-negative number",
+ },
+];
+
+export function EditableGrid() {
+ const [rows, setRows] = useState([
+ { id: "r1", name: "Ada", age: 36 },
+ { id: "r2", name: "Linus", age: 54 },
+ ]);
+
+ return (
+
+ ariaLabel="People"
+ columns={columns}
+ rows={rows}
+ getRowId={(row) => row.id}
+ viewportHeight={300}
+ onCellEdit={({ rowId, columnId, value }) => {
+ setRows((prev) =>
+ prev.map((r) => (r.id === rowId ? { ...r, [columnId]: value } : r)),
+ );
+ }}
+ />
+ );
+}
+```
+
+Click a cell, press `Enter` (or just start typing), edit, and press `Enter` again — `onCellEdit` fires, your state updates, and the new `rows` flow back into the grid.
+
+## See also
+
+- [Selection](/docs/grid/selection) — focus is the cell editing begins on.
+- [Keyboard](/docs/grid/keyboard) — the full keyboard contract.
+- [Headless engine](/docs/headless) — read `snapshot.editing` to build your own editor.
+- [API reference](/docs/grid/api-reference) — `PretableEditInput`, `PretableEditorInput`, `PretableEditState`, `PretableEditStatus` types.
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`.
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.
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/__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;
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,
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;
}
/**
diff --git a/packages/react/react.api.md b/packages/react/react.api.md
index 9425f106..09155535 100644
--- a/packages/react/react.api.md
+++ b/packages/react/react.api.md
@@ -185,9 +185,59 @@ export interface PretableColumn extends
// (undocumented)
render?: (input: PretableCellRenderInput) => ReactNode;
// (undocumented)
+ renderEditor?: (input: PretableEditorInput) => ReactNode;
+ // (undocumented)
renderHeader?: (input: PretableHeaderRenderInput) => ReactNode;
}
+// @public
+export interface PretableEditInput {
+ // (undocumented)
+ column: PretableColumn_2;
+ // (undocumented)
+ columnId: string;
+ // (undocumented)
+ row: TRow;
+ // (undocumented)
+ rowId: string;
+ // (undocumented)
+ value: unknown;
+}
+
+// @public
+export interface PretableEditorInput extends Omit, "column"> {
+ // (undocumented)
+ cancel: () => void;
+ // (undocumented)
+ column: PretableColumn;
+ // (undocumented)
+ commit: (direction?: PretableFocusDirection) => void;
+ // (undocumented)
+ draft: unknown;
+ // (undocumented)
+ setDraft: (value: unknown) => void;
+}
+
+// @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";
+
// @public
export interface PretableFormatInput {
// (undocumented)
@@ -215,9 +265,18 @@ 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;
// Warning: (ae-forgotten-export) The symbol "PretableCellAddress" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@@ -225,10 +284,19 @@ export interface PretableGrid {
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_2[]): void;
// (undocumented)
moveColumn(columnId: string, toIndex: number): void;
- // Warning: (ae-forgotten-export) The symbol "PretableFocusDirection" needs to be exported by the entry point index.d.ts
// Warning: (ae-forgotten-export) The symbol "PretableMoveFocusOptions" needs to be exported by the entry point index.d.ts
//
// (undocumented)
@@ -245,6 +313,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;
@@ -279,6 +349,8 @@ export interface PretableGridOptions {
// @public
export interface PretableGridSnapshot {
+ // (undocumented)
+ editing: PretableEditState | null;
// (undocumented)
filters: Record;
// Warning: (ae-forgotten-export) The symbol "PretableFocusState" needs to be exported by the entry point index.d.ts
@@ -342,6 +414,8 @@ export interface PretableProps {
// (undocumented)
messages?: PretableSurfaceProps["messages"];
// (undocumented)
+ onCellEdit?: PretableSurfaceProps["onCellEdit"];
+ // (undocumented)
onColumnOrderChange?: PretableSurfaceProps["onColumnOrderChange"];
// (undocumented)
onColumnPinnedChange?: PretableSurfaceProps["onColumnPinnedChange"];
@@ -447,6 +521,12 @@ export interface PretableSurfaceProps {
// (undocumented)
getRowProps?: (input: PretableSurfaceRowAttributesInput) => HTMLAttributes | undefined;
messages?: PretableSurfaceMessages;
+ onCellEdit?: (payload: {
+ rowId: string;
+ columnId: string;
+ value: unknown;
+ row: TRow;
+ }) => void | Promise;
// (undocumented)
onColumnOrderChange?: (next: readonly string[]) => void;
// (undocumented)
@@ -609,7 +689,7 @@ export function ɵuseResolvedHeights(rowHeightProp?: number, headerHeightProp?:
// Warnings were encountered during analysis:
//
-// dist/index.d.ts:437:9 - (ae-forgotten-export) The symbol "PretableSortDirection" needs to be exported by the entry point index.d.ts
+// dist/index.d.ts:464:9 - (ae-forgotten-export) The symbol "PretableSortDirection" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)
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/__tests__/pretable-surface-editing.test.tsx b/packages/react/src/__tests__/pretable-surface-editing.test.tsx
new file mode 100644
index 00000000..184a1989
--- /dev/null
+++ b/packages/react/src/__tests__/pretable-surface-editing.test.tsx
@@ -0,0 +1,135 @@
+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();
+ });
+
+ it("does not move grid focus when arrow keys are pressed inside the editor", () => {
+ const onFocusChange = vi.fn();
+ render(
+
+ ariaLabel="people"
+ columns={COLUMNS}
+ rows={ROWS}
+ getRowId={(r) => r.id}
+ viewportHeight={300}
+ onCellEdit={vi.fn()}
+ onFocusChange={onFocusChange}
+ />,
+ );
+ const cell = firstNameCell();
+ fireEvent.click(cell);
+ fireEvent.keyDown(cell, { key: "Enter" });
+ const box = screen.getByRole("textbox");
+ onFocusChange.mockClear();
+ // Arrow keys must drive the text cursor, not the grid's focus model.
+ fireEvent.keyDown(box, { key: "ArrowRight" });
+ fireEvent.keyDown(box, { key: "ArrowDown" });
+ expect(onFocusChange).not.toHaveBeenCalled();
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ });
+});
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..8d18b344
--- /dev/null
+++ b/packages/react/src/__tests__/use-cell-edit-controller.test.ts
@@ -0,0 +1,110 @@
+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 };
+}
+
+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/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/pretable-surface.tsx b/packages/react/src/pretable-surface.tsx
index af5ecc71..724caae6 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],
@@ -909,6 +958,15 @@ export function PretableSurface({
return;
}
+ // While a cell edit is active the editor input owns the keyboard. Its
+ // own keydown handler stop-propagates Enter/Tab/Escape; every other key
+ // (typing, arrows, Home/End, Cmd+A) must drive the input, not the grid,
+ // so bail before any copy/select/navigation handling. Do NOT
+ // preventDefault — the input still needs default text behavior.
+ if (snapshot.editing) {
+ return;
+ }
+
// Cmd/Ctrl+C copy. Skip if focus is in an editable input/textarea.
if (
(event.key === "c" || event.key === "C") &&
@@ -957,6 +1015,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 +1617,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 +1675,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 +1691,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 +1790,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 ? (
{
onColumnWidthsChange?: PretableSurfaceProps["onColumnWidthsChange"];
onColumnOrderChange?: PretableSurfaceProps["onColumnOrderChange"];
onColumnPinnedChange?: PretableSurfaceProps["onColumnPinnedChange"];
+ onCellEdit?: PretableSurfaceProps["onCellEdit"];
}
const VIEWPORT_HEIGHT = 320;
@@ -50,6 +51,7 @@ export function Pretable({
onColumnWidthsChange,
onColumnOrderChange,
onColumnPinnedChange,
+ onCellEdit,
}: PretableProps) {
const resolvedGetRowId =
getRowId ??
@@ -141,6 +143,7 @@ export function Pretable({
onColumnWidthsChange={onColumnWidthsChange}
onColumnOrderChange={onColumnOrderChange}
onColumnPinnedChange={onColumnPinnedChange}
+ onCellEdit={onCellEdit}
viewportStyle={BENCHMARK_VIEWPORT_STYLE}
viewportHeight={VIEWPORT_HEIGHT}
/>
diff --git a/packages/react/src/public_api.ts b/packages/react/src/public_api.ts
index 0d3ecfc1..ec12d7bf 100644
--- a/packages/react/src/public_api.ts
+++ b/packages/react/src/public_api.ts
@@ -43,6 +43,7 @@ export type {
export type {
PretableCellRenderInput,
PretableColumn,
+ PretableEditorInput,
PretableFormatInput,
PretableHeaderRenderInput,
} from "./types";
@@ -57,6 +58,10 @@ export type { DensityHeights } from "@pretable/ui";
// Re-exports from @pretable/core (the engine types react users typically
// touch — full headless surface lives in @pretable/core)
export type {
+ PretableEditInput,
+ PretableEditState,
+ PretableEditStatus,
+ PretableFocusDirection,
PretableGrid,
PretableGridOptions,
PretableGridSnapshot,
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;
}
/**
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]);
+}