From 6df0b3b2095876667309bdaa5eb6bab78efd071a Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 10:13:26 +0530 Subject: [PATCH 01/12] docs(data-table): propose inline cell editing Decision record for building inline cell editing into DataTable: typing into cells with rules (whole numbers, min/max, decimal limits), per-row editability via canEdit(row, { selected }), keyboard entry, and autosave on leave with revert on failure, across every built-in column type. Open for team input; no package changes. Refs tailor-inc/platform-planning#1750, tailor-inc/platform-planning#1428, tailor-inc/platform-planning#1115. Co-Authored-By: Claude Opus 5.5 --- decisions/data-table-inline-editing.md | 160 +++++++++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 decisions/data-table-inline-editing.md diff --git a/decisions/data-table-inline-editing.md b/decisions/data-table-inline-editing.md new file mode 100644 index 00000000..cc0336ca --- /dev/null +++ b/decisions/data-table-inline-editing.md @@ -0,0 +1,160 @@ +# Decision: inline cell editing in DataTable + +> Status: **Open — for team input. No code yet.** +> +> Context: [platform-planning#1750](https://github.com/tailor-inc/platform-planning/issues/1750) (the UI Catalogue inline-edit pattern, left with @itsprade), the Larson IMS request in Slack (`#prj-larson-ims`), and [platform-planning#1428](https://github.com/tailor-inc/platform-planning/issues/1428). Related: [platform-planning#1115](https://github.com/tailor-inc/platform-planning/issues/1115) (optimistic rows) and [platform-planning#1161](https://github.com/tailor-inc/platform-planning/issues/1161) (LineItems). +> +> Scope: editing values in rows a DataTable already shows. Adding, removing or reordering rows is out of scope; that's document line items (#1161). + +## Problem + +Two teams have asked for the same thing: + +- **Larson IMS (Slack).** They're moving about a dozen AG-Grid tables to `DataTable`. Regular lists already work, including filtering, sorting, pagination and row actions. The blocked tables are the ones where people fill in quantities: purchase orders, invoices, receipts, credit notes and stock adjustments. They need two things: + 1. **Typing into a cell, with basic rules**, such as whole numbers only, no negatives, or at most 2 decimal places. + 2. **Some rows editable and others not**, decided by their own logic. For example, a row becomes editable once it's selected, or only while it meets a condition. +- **Opportunity tracker ([#1428](https://github.com/tailor-inc/platform-planning/issues/1428)).** They hand-built popover + save + refetch cells for text, enum and decimal values. They're asking for one standard version, so every app looks and saves the same way. + +Today DataTable only displays data. An app that needs editing draws its own inputs in `render`. That loses DataTable's typed formatting and fights with its row click, cell context menu and truncation. + +## What exists today + +- **DataTable is display-only.** Rows belong to the screen: DataTable renders `data.rows` as given and leaves sorting, filtering and paging to `CollectionControl`. There is no notion of cell focus or keyboard movement. +- **UI Catalogue pattern: "DataTable inline cell edit"** ([ui.tailor.tech](https://ui.tailor.tech/patterns/datatable-inline-edit), [#1750](https://github.com/tailor-inc/platform-planning/issues/1750)). It's built on DataTable `render`: + - Underlined values open a small popover with the input and Cancel / Save. + - Status is a badge plus a chevron menu; picking an option applies it immediately and shows a toast. + - A "Confirmed" flag commits through a checkbox. + - Its guidance: it suits a few edits on small lists. Use a Sheet or form for cross-field checks or large row counts. +- **The `list-dense-scan` pattern rules it out.** `docs-src/patterns/list-dense-scan.docs.outline.md` lines 32 and 89 say: "Inline editable cells — use pattern/detail or pattern/form/modal instead". +- **LineItems** ([#1161](https://github.com/tailor-inc/platform-planning/issues/1161), unmerged app-shell#238) is a document-line editor. It has cell editors but no validation rules, no per-row control, and no filtering or paging. That makes it a reference, not the base for list tables. +- **[#1115](https://github.com/tailor-inc/platform-planning/issues/1115)**: DataTable manages rendering state, and saving with optimistic updates and rollback belongs in a separate `useOptimisticRows` hook. The mutation callbacks were removed from `useDataTable` in app-shell#130. +- **`CsvImporter`**'s review grid, with `onCellEdit(row, columnKey, value)`, is the only inline editing in the package today. + +## Proposal + +Build inline editing into DataTable and configure it per column. + +1. **Type straight into a cell.** Click or Tab into an editable cell and type. There's no popover and no form. This is where the proposal differs from the catalogue pattern (see [Alternatives](#alternatives-considered)). +2. **Show what's editable.** + - Editable cells carry a faint outline, and hover and focus show the full input outline. + - Read-only cells look as they do today. + - Row height and column width don't shift when a row becomes editable. +3. **Block impossible input as it's typed.** + - With "no negatives" there's no minus sign. With "whole numbers" there's no decimal point. With "2 decimals" there's no third decimal digit. + - Pasted text is cleaned up (thousands separators, full-width digits) and then checked. It is never silently rounded. +4. **Explain the other rules.** + - A required value, a maximum, or the screen's own rule ("can't receive more than ordered") turns the cell red, with a tooltip saying why. + - Enter and Tab won't save until it's fixed, and Esc puts the old value back. + - Leaving the cell while it's still invalid reverts it. +5. **Let the screen decide which rows are editable.** The per-row check also knows whether the row is selected, so "editable once ticked" and "only while Draft" are one line each. +6. **Make keyboard data entry fast.** + - Enter saves and moves down to the same column. + - Tab saves and moves to the next editable cell, skipping read-only ones. + - Esc undoes. + - Japanese IME input is respected: pressing Enter to confirm a conversion doesn't save. +7. **Autosave on leave.** + - A value saves when the user leaves the cell or presses Enter or Tab, and only if it actually changed: going from `10` to `10.00` is not a change. + - There is no saving indicator. + - If the screen's save fails, the cell goes back to the old value, and the screen can show its own toast. + - Screens that hold edits for a Save button work the same way; their save just never fails. +8. **Leave everything else alone.** Filtering, sorting, paging, selection, row actions, pinned columns and column settings keep working. Existing tables don't change unless a column opts in. + +### Every column type + +| Column type | How it's edited | What the screen receives | +| ---------------- | ------------------------------------------------------------ | ----------------------------------------------------------------- | +| `text` | Type in the cell | Text, or `null` when emptied | +| `number` | Type in the cell, with the rules above | A number, or `null` | +| `money` | Same as number; decimals follow the currency (USD 2, JPY 0) | A number, or `null` | +| `date` | Pick from a calendar or type; date-time columns add the time | `"YYYY-MM-DD"`, a UTC ISO string for date-time columns, or `null` | +| `badge` (status) | Pick from a list, either one value or several | The value (or list of values), or `null` | +| `link` | Edit the text; it shows as plain text while editable | Text, or `null` | + +## API sketch + +This follows the shapes DataTable already has: + +- **`edit` sits on the column next to `type` / `typeOptions`** and narrows by `type`, like `typeOptions` and `accessor` do today (`packages/core/src/components/data-table/types.ts`). That means the value handed to `onCommit` has the right type for each column type. +- **`canEdit(row, { selected })`** mirrors `rowExpansion.canExpand(row)` and `RowAction.isDisabled(row)`. +- **Nothing changes in the `useDataTable` options or `DataTableContextValue`.** + +```tsx +const { column } = createColumnHelper(); + +column({ + id: "received", + label: "Received", + type: "number", + edit: { + canEdit: (row, { selected }) => selected && row.status === "open", + min: 0, + maxDecimals: 0, // whole numbers only + required: true, + validate: (value, row) => + value > row.ordered ? `Can't exceed ordered (${row.ordered})` : undefined, + // May return a promise; if it rejects, the cell reverts. + onCommit: (row, value) => saveReceivedQty(row.id, value), + }, +}); +``` + +- **The table never stores edits.** It calls `onCommit`, and the screen updates `data`: a local draft for a Save-button screen, or a mutation for autosave. While an `onCommit` promise is pending, the cell keeps showing the new value; if the promise rejects, the cell reverts. Richer optimistic behaviour stays with `useOptimisticRows` (#1115). +- **Rules by type:** + - `text` / `link`: `required` and `validate` only. + - `number` / `money`: `min`, `max` and `maxDecimals`. `maxDecimals` defaults to what the column displays; for money, the currency's decimals. + - `date`: `min` and `max`. + - `badge`: `options` (defaults to the column's enum filter options) and `multiple`. +- **Built from existing components:** `Input`, `DatePicker`, `Select`, `Tooltip` and `Badge`. There are no new dependencies. Rows need an `id` to be editable. + +## Scope contract + +- **Required behaviour:** Larson IMS's received-quantity column works on a paginated, filtered DataTable. It must be integer, ≥ 0 and ≤ ordered, editable only when the row is selected, entered row by row with Enter, and autosaved with a revert on failure. +- **Compatibility:** + - No change to existing tables, the `useDataTable` options or the context. `edit` is optional on every column. + - One display fix comes with it: a number column shows as many decimals as its editor allows. Today an unconfigured number column rounds to whole numbers, so a typed 2.5 would render as "3". +- **Intentionally unsupported in v1:** + - yes/no flags (there's no boolean column type) + - a bring-your-own editor for custom `render` columns + - copying and pasting across several cells, fill-down, and undo + - moving between cells with the arrow keys + - a "changed" marker + - a saving indicator + - comma decimal separators (`1,5`) + - adding or removing rows +- **Validation plan:** + - Unit tests for parsing and rules. + - Interaction tests for each column type: commit, revert, blocked keys, keyboard flow, per-row control and no-op saves. + - Type tests for the value each column type receives. + - A goods-receipt demo in the vite example, checked in the browser, including that row height stays fixed. + +## Alternatives considered + +- **Keep it a pattern (#1750 as it is).** No package change, but every app rebuilds the rules, per-row checks and keyboard flow, which is the per-app drift #1428 describes. +- **Popover editing, like the catalogue pattern.** This matches the existing pattern and suits occasional changes. But a click and a Save for every cell is too slow for filling in quantities down a column, which is what Larson IMS needs. +- **DataTable holds the drafts** (dirty tracking, "get changes"). This goes against #1115's direction that DataTable only renders. The screen already owns the rows. +- **Build on LineItems.** It's unmerged, it has no validation or per-row control, and it has no filtering or paging. + +## Delivery + +- **PR 1, which unblocks Larson IMS:** + - text, number, money and link editing + - rules and errors + - per-row control + - keyboard entry + - autosave and revert + - docs (`docs-src/components/data-table.docs.outline.md`) + - pattern updates: rewrite `list-dense-scan`, and move the #1750 inline-edit pattern into `docs-src/patterns/` + - a vite example + - a `minor` changeset +- **PR 2:** date and badge editing, with the matching docs and example additions. + +## For the call + +- **Naming.** `edit` / `canEdit` / `onCommit`, or Base UI's `onValueCommitted` wording? +- **Leaving an invalid cell reverts it.** This is AG-Grid's default, and it means the data never disagrees with the screen, but the typed value is lost. Keep it? The other option is keeping the red value until it's fixed, and giving screens a way to ask "any invalid cells?" before Save. +- **Enter moves down** to the same column, spreadsheet-style, instead of staying put. OK? +- **Two PRs** as described above? +- **#1115.** Ship the simple pending/revert behaviour with PR 1 and let `useOptimisticRows` build on it later, or wait for #1115? +- **Catalogue pattern.** Move #1750 into `docs-src/patterns/`, rewritten around the built-in feature, and retire the popover version? +- **Bring-your-own editor** (for flags, product search and similar): leave it out until a team asks? +- **Out of scope here.** The two open notes in `docs-src/pages/document-detail.docs.outline.md`, "a shared line-items component" and "in-place editing versus an edit route", stay open. This proposal covers list tables only. From 2d2f81f036d736db574199045317422d1b8430fd Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 10:58:43 +0530 Subject: [PATCH 02/12] feat(data-table): add cell edit rules and keyboard navigation helpers Pure helpers behind inline cell editing: which keystrokes a number cell accepts, how pasted or IME text is cleaned up, the rule order (required, number, decimals, min, max, custom), no-op equality (10 vs "10.00"), currency decimal places, and a focus registry so Enter / Tab can move between editable cells without querying the DOM. Refs tailor-inc/platform-planning#1750 Co-Authored-By: Claude Opus 5.5 --- .../components/data-table/cell-edit.test.ts | 213 ++++++++++++++++++ .../src/components/data-table/cell-edit.ts | 201 +++++++++++++++++ .../use-cell-edit-navigation.test.ts | 76 +++++++ .../data-table/use-cell-edit-navigation.ts | 84 +++++++ 4 files changed, 574 insertions(+) create mode 100644 packages/core/src/components/data-table/cell-edit.test.ts create mode 100644 packages/core/src/components/data-table/cell-edit.ts create mode 100644 packages/core/src/components/data-table/use-cell-edit-navigation.test.ts create mode 100644 packages/core/src/components/data-table/use-cell-edit-navigation.ts diff --git a/packages/core/src/components/data-table/cell-edit.test.ts b/packages/core/src/components/data-table/cell-edit.test.ts new file mode 100644 index 00000000..70e6f436 --- /dev/null +++ b/packages/core/src/components/data-table/cell-edit.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from "vitest"; +import { + currencyFractionDigits, + evaluateNumberDraft, + evaluateTextDraft, + isAllowedNumberText, + isLiveError, + normalizeNumberText, + parseNumberText, + sameNumber, + toNumberEditText, + toNumberValue, + toTextValue, + type NumberEditRules, +} from "./cell-edit"; + +const alwaysCustom = () => "custom"; + +const rules = (overrides: Partial = {}): NumberEditRules => ({ + maxDecimals: 2, + required: false, + ...overrides, +}); + +describe("isAllowedNumberText", () => { + it("accepts digits and the empty string", () => { + expect(isAllowedNumberText("", rules())).toBe(true); + expect(isAllowedNumberText("120", rules())).toBe(true); + }); + + it("blocks a minus sign when negatives aren't allowed", () => { + expect(isAllowedNumberText("-", rules({ min: 0 }))).toBe(false); + expect(isAllowedNumberText("-5", rules({ min: 0 }))).toBe(false); + expect(isAllowedNumberText("-5", rules({ min: -10 }))).toBe(true); + expect(isAllowedNumberText("-", rules())).toBe(true); + }); + + it("only accepts a minus sign in first position", () => { + expect(isAllowedNumberText("5-", rules())).toBe(false); + expect(isAllowedNumberText("--5", rules())).toBe(false); + }); + + it("blocks the decimal point for whole numbers", () => { + expect(isAllowedNumberText("5.", rules({ maxDecimals: 0 }))).toBe(false); + expect(isAllowedNumberText("5", rules({ maxDecimals: 0 }))).toBe(true); + }); + + it("blocks digits past maxDecimals and a second decimal point", () => { + expect(isAllowedNumberText("1.25", rules())).toBe(true); + expect(isAllowedNumberText("1.255", rules())).toBe(false); + expect(isAllowedNumberText("1.2.5", rules())).toBe(false); + }); + + it("rejects letters, exponents and signs Number() would accept", () => { + for (const text of ["e", "1e5", "+5", "0x1f", "1,000", "12a"]) { + expect(isAllowedNumberText(text, rules())).toBe(false); + } + }); +}); + +describe("normalizeNumberText", () => { + it("drops strict thousands grouping", () => { + expect(normalizeNumberText("1,234.5")).toBe("1234.5"); + expect(normalizeNumberText("-1,234,567")).toBe("-1234567"); + }); + + it("leaves a decimal comma alone so it fails instead of becoming 15", () => { + expect(normalizeNumberText("1,5")).toBe("1,5"); + expect(parseNumberText(normalizeNumberText("1,5"))).toBeUndefined(); + }); + + it("converts full-width digits and signs", () => { + expect(normalizeNumberText("12.5")).toBe("12.5"); + expect(normalizeNumberText("-3")).toBe("-3"); + expect(normalizeNumberText("−3")).toBe("-3"); + }); + + it("strips whitespace and currency symbols", () => { + expect(normalizeNumberText(" $1,234.50 ")).toBe("1234.50"); + expect(normalizeNumberText("¥ 1 200")).toBe("1200"); + }); +}); + +describe("parseNumberText", () => { + it("treats an empty draft as null", () => { + expect(parseNumberText("")).toBeNull(); + expect(parseNumberText(" ")).toBeNull(); + }); + + it("parses partial but complete-enough numbers", () => { + expect(parseNumberText("5.")).toBe(5); + expect(parseNumberText(".5")).toBe(0.5); + expect(parseNumberText("-0")).toBe(0); + expect(Object.is(parseNumberText("-0"), -0)).toBe(false); + }); + + it("rejects half-typed and non-numeric drafts", () => { + for (const text of ["-", ".", "-.", "abc", "1,5", String(Number.MAX_SAFE_INTEGER * 2)]) { + expect(parseNumberText(text)).toBeUndefined(); + } + }); +}); + +describe("evaluateNumberDraft", () => { + it("returns the parsed value when every rule passes", () => { + expect(evaluateNumberDraft("12.5", rules({ min: 0, max: 100 }))).toEqual({ + value: 12.5, + error: null, + }); + }); + + it("checks rules in order: required, number, decimals, min, max, custom", () => { + const validate = alwaysCustom; + expect(evaluateNumberDraft("", rules({ required: true }), validate).error).toEqual({ + code: "required", + }); + expect(evaluateNumberDraft("-", rules(), validate).error).toEqual({ code: "number" }); + expect(evaluateNumberDraft("1.234", rules(), validate).error).toEqual({ + code: "decimals", + maxDecimals: 2, + }); + expect(evaluateNumberDraft("-1", rules({ min: 0 }), validate).error).toEqual({ + code: "min", + min: 0, + }); + expect(evaluateNumberDraft("101", rules({ max: 100 }), validate).error).toEqual({ + code: "max", + max: 100, + }); + expect(evaluateNumberDraft("5", rules(), validate).error).toEqual({ + code: "custom", + message: "custom", + }); + }); + + it("ignores trailing zeros when counting decimals", () => { + expect(evaluateNumberDraft("2.50", rules({ maxDecimals: 1 })).error).toBeNull(); + expect(evaluateNumberDraft("3.0", rules({ maxDecimals: 0 })).error).toBeNull(); + }); + + it("passes an empty value to validate only when the column isn't required", () => { + const calls: unknown[] = []; + const validate = (value: number | null) => { + calls.push(value); + return undefined; + }; + evaluateNumberDraft("", rules(), validate); + evaluateNumberDraft("", rules({ required: true }), validate); + expect(calls).toEqual([null]); + }); + + it("treats min and max as inclusive", () => { + expect(evaluateNumberDraft("0", rules({ min: 0, max: 0 })).error).toBeNull(); + }); +}); + +describe("evaluateTextDraft", () => { + it("commits text as typed and an emptied field as null", () => { + expect(evaluateTextDraft(" Hello ", false)).toEqual({ value: " Hello ", error: null }); + expect(evaluateTextDraft("", false)).toEqual({ value: null, error: null }); + }); + + it("reports required and custom errors", () => { + expect(evaluateTextDraft("", true).error).toEqual({ code: "required" }); + expect(evaluateTextDraft("x", false, (v) => (v === "x" ? "No x" : null)).error).toEqual({ + code: "custom", + message: "No x", + }); + }); +}); + +describe("isLiveError", () => { + it("shows errors more typing can't fix right away, and defers the rest", () => { + expect(isLiveError({ code: "max", max: 1 })).toBe(true); + expect(isLiveError({ code: "decimals", maxDecimals: 0 })).toBe(true); + expect(isLiveError({ code: "custom", message: "x" })).toBe(true); + expect(isLiveError({ code: "min", min: 1 })).toBe(false); + expect(isLiveError({ code: "required" })).toBe(false); + expect(isLiveError({ code: "number" })).toBe(false); + }); +}); + +describe("value helpers", () => { + it("compares numbers without floating-point noise", () => { + expect(sameNumber(10, 10)).toBe(true); + expect(sameNumber(0.1 + 0.2, 0.3)).toBe(true); + expect(sameNumber(null, null)).toBe(true); + expect(sameNumber(0, null)).toBe(false); + expect(sameNumber(10, 10.01)).toBe(false); + }); + + it("reads raw values the way the renderers do", () => { + expect(toNumberValue("10.00")).toBe(10); + expect(toNumberValue("")).toBeNull(); + expect(toNumberValue("abc")).toBeNull(); + expect(toTextValue("")).toBeNull(); + expect(toTextValue(42)).toBe("42"); + }); + + it("formats numbers for editing without grouping, exponents or noise", () => { + expect(toNumberEditText(1234567.5)).toBe("1234567.5"); + expect(toNumberEditText(0.1 + 0.2)).toBe("0.3"); + expect(toNumberEditText(1e21)).toBe("1000000000000000000000"); + expect(toNumberEditText(null)).toBe(""); + }); + + it("knows each currency's decimal places", () => { + expect(currencyFractionDigits("USD")).toBe(2); + expect(currencyFractionDigits("JPY")).toBe(0); + expect(currencyFractionDigits("KWD")).toBe(3); + expect(currencyFractionDigits("NOT-A-CODE")).toBe(2); + }); +}); diff --git a/packages/core/src/components/data-table/cell-edit.ts b/packages/core/src/components/data-table/cell-edit.ts new file mode 100644 index 00000000..61f55557 --- /dev/null +++ b/packages/core/src/components/data-table/cell-edit.ts @@ -0,0 +1,201 @@ +/** + * Pure helpers behind DataTable's inline cell editing: which keystrokes a number + * cell accepts, how typed or pasted text becomes a value, which rules a draft + * breaks, and whether a draft is a real change or a cosmetic one (`10` vs + * `10.00`). Kept free of React so every rule can be unit-tested directly. + * + * @internal + */ + +/** Rules a `number` / `money` cell enforces while the user types and on commit. */ +export interface NumberEditRules { + min?: number; + max?: number; + /** Digits allowed after the decimal point. `0` means whole numbers only. */ + maxDecimals: number; + required: boolean; +} + +/** Why a draft can't be committed. The cell maps each code to an i18n message. */ +export type CellEditError = + | { code: "required" } + | { code: "number" } + | { code: "decimals"; maxDecimals: number } + | { code: "min"; min: number } + | { code: "max"; max: number } + | { code: "custom"; message: string }; + +/** A draft's parsed value alongside the first rule it breaks, if any. */ +export interface DraftEvaluation { + value: TValue | null; + error: CellEditError | null; +} + +type Validate = ((value: TValue | null) => string | null | undefined) | undefined; + +/** + * Errors worth showing while the user is still typing. More keystrokes can't + * fix these (a value over `max` only grows), whereas `min`, `required` and a + * half-typed number are ordinary mid-typing states — those wait until the user + * tries to save, so the cell doesn't flash red on every keystroke. + */ +export function isLiveError(error: CellEditError): boolean { + return error.code === "max" || error.code === "decimals" || error.code === "custom"; +} + +/** + * Whether `text` is something the user could be partway through typing into a + * number cell. Characters that can never be valid are rejected here: a minus + * sign when negatives aren't allowed, a decimal point for whole numbers, and + * digits past `maxDecimals`. + */ +export function isAllowedNumberText( + text: string, + rules: Pick, +): boolean { + const sign = rules.min === undefined || rules.min < 0 ? "-?" : ""; + const fraction = rules.maxDecimals > 0 ? `(?:\\.\\d{0,${rules.maxDecimals}})?` : ""; + return new RegExp(`^${sign}\\d*${fraction}$`).test(text); +} + +// Thousands grouping with commas, e.g. "1,234,567.89". +const GROUPED_NUMBER = /^-?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/; + +/** + * Cleans number text that arrived all at once (a paste, or the end of an IME + * composition): full-width digits and signs become ASCII, whitespace and + * currency symbols are dropped, and commas are removed only when they form + * strict thousands groups — so "1,234.5" becomes "1234.5", while "1,5" is left + * alone to fail as "not a number" instead of silently turning into 15. + */ +export function normalizeNumberText(text: string): string { + const compact = text + .normalize("NFKC") + .replace(/[\s\p{Sc}]/gu, "") + .replace(/^−/, "-"); + return GROUPED_NUMBER.test(compact) ? compact.replace(/,/g, "") : compact; +} + +/** `""` → `null`, `"5."` → `5`; `"-"`, `"."` and anything non-numeric → `undefined`. */ +export function parseNumberText(text: string): number | null | undefined { + const trimmed = text.trim(); + if (trimmed === "") return null; + if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(trimmed)) return undefined; + const value = Number(trimmed); + if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) return undefined; + // `+ 0` folds -0 into 0 so "-0" doesn't read as a change from 0. + return value + 0; +} + +// Digits after the decimal point, ignoring trailing zeros ("2.50" has one). +function significantDecimals(text: string): number { + const dot = text.indexOf("."); + if (dot === -1) return 0; + return text.slice(dot + 1).replace(/0+$/, "").length; +} + +/** + * Parses a number draft and checks it against the column's rules, in order: + * required → not a number → decimals → min → max → the consumer's `validate`. + */ +export function evaluateNumberDraft( + text: string, + rules: NumberEditRules, + validate?: Validate, +): DraftEvaluation { + const value = parseNumberText(text); + if (value === undefined) return { value: null, error: { code: "number" } }; + if (value === null) { + return { value, error: rules.required ? { code: "required" } : customError(validate, value) }; + } + if (significantDecimals(text) > rules.maxDecimals) { + return { value, error: { code: "decimals", maxDecimals: rules.maxDecimals } }; + } + if (rules.min !== undefined && value < rules.min) { + return { value, error: { code: "min", min: rules.min } }; + } + if (rules.max !== undefined && value > rules.max) { + return { value, error: { code: "max", max: rules.max } }; + } + return { value, error: customError(validate, value) }; +} + +/** Text drafts: an empty field is `null`; otherwise the text exactly as typed. */ +export function evaluateTextDraft( + text: string, + required: boolean, + validate?: Validate, +): DraftEvaluation { + const value = text === "" ? null : text; + if (value === null && required) return { value, error: { code: "required" } }; + return { value, error: customError(validate, value) }; +} + +function customError(validate: Validate, value: TValue | null) { + const message = validate?.(value); + return message ? ({ code: "custom", message } as const) : null; +} + +/** Reads a raw cell value as a number the way the `number` renderer does. */ +export function toNumberValue(raw: unknown): number | null { + if (raw == null || raw === "") return null; + const value = typeof raw === "number" ? raw : Number(raw); + return Number.isFinite(value) ? value : null; +} + +/** Reads a raw cell value as text; `null`, `undefined` and `""` are all empty. */ +export function toTextValue(raw: unknown): string | null { + if (raw == null || raw === "") return null; + return String(raw); +} + +// 15 significant digits drops floating-point noise (0.1 + 0.2 → 0.3). +function roundNoise(value: number): number { + return Number(value.toPrecision(15)); +} + +/** `10`, `10.0` and `9.999999999999998` are the same number to a user. */ +export function sameNumber(a: number | null, b: number | null): boolean { + if (a === null || b === null) return a === b; + return roundNoise(a) === roundNoise(b); +} + +/** A number as the user types it: no grouping, no exponent, no float noise. */ +export function toNumberEditText(value: number | null): string { + if (value === null) return ""; + return roundNoise(value).toLocaleString("en-US", { + useGrouping: false, + maximumFractionDigits: 20, + }); +} + +const currencyDigits = new Map(); + +/** + * Decimal places a currency uses (USD 2, JPY 0, KWD 3). Fraction digits come + * from ISO 4217 and don't depend on locale. An invalid code resolves to USD's, + * matching the `money` renderer's fallback. + */ +export function currencyFractionDigits(currency: string): number { + let digits = currencyDigits.get(currency); + if (digits === undefined) { + try { + digits = + new Intl.NumberFormat("en-US", { style: "currency", currency }).resolvedOptions() + .maximumFractionDigits ?? 2; + } catch { + digits = 2; + } + currencyDigits.set(currency, digits); + } + return digits; +} + +/** Whether a value `onCommit` returned is a promise-like the cell should wait on. */ +export function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === "object" && + value !== null && + typeof (value as { then?: unknown }).then === "function" + ); +} diff --git a/packages/core/src/components/data-table/use-cell-edit-navigation.test.ts b/packages/core/src/components/data-table/use-cell-edit-navigation.test.ts new file mode 100644 index 00000000..773ba4fb --- /dev/null +++ b/packages/core/src/components/data-table/use-cell-edit-navigation.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from "vitest"; +import { createCellEditNavigation } from "./use-cell-edit-navigation"; + +function cell() { + return { focus: vi.fn() } as unknown as HTMLElement & { focus: ReturnType }; +} + +// A 3×3 grid where only some cells are editable: +// a b c +// r1 x . x +// r2 . . x +// r3 x . . +function setup() { + const navigation = createCellEditNavigation(); + navigation.setOrder(["r1", "r2", "r3"], ["a", "b", "c"]); + const cells = { r1a: cell(), r1c: cell(), r2c: cell(), r3a: cell() }; + navigation.register("r1", "a", cells.r1a); + navigation.register("r1", "c", cells.r1c); + navigation.register("r2", "c", cells.r2c); + navigation.register("r3", "a", cells.r3a); + return { navigation, cells }; +} + +describe("createCellEditNavigation", () => { + it("moves down and up the same column, skipping rows without an editor", () => { + const { navigation, cells } = setup(); + expect(navigation.move({ rowKey: "r1", colKey: "a" }, "down")).toBe(true); + expect(cells.r3a.focus).toHaveBeenCalled(); + expect(navigation.move({ rowKey: "r3", colKey: "a" }, "up")).toBe(true); + expect(cells.r1a.focus).toHaveBeenCalled(); + }); + + it("returns false at the edge of a column", () => { + const { navigation } = setup(); + expect(navigation.move({ rowKey: "r3", colKey: "a" }, "down")).toBe(false); + expect(navigation.move({ rowKey: "r1", colKey: "c" }, "up")).toBe(false); + }); + + it("moves next and prev in row-major order, skipping read-only cells", () => { + const { navigation, cells } = setup(); + navigation.move({ rowKey: "r1", colKey: "a" }, "next"); + expect(cells.r1c.focus).toHaveBeenCalled(); + navigation.move({ rowKey: "r1", colKey: "c" }, "next"); + expect(cells.r2c.focus).toHaveBeenCalled(); + navigation.move({ rowKey: "r2", colKey: "c" }, "prev"); + expect(cells.r1c.focus).toHaveBeenCalledTimes(2); + }); + + it("returns false past the last and first editable cell", () => { + const { navigation } = setup(); + expect(navigation.move({ rowKey: "r3", colKey: "a" }, "next")).toBe(false); + expect(navigation.move({ rowKey: "r1", colKey: "a" }, "prev")).toBe(false); + }); + + it("stops navigating to a cell once it unregisters", () => { + const { navigation, cells } = setup(); + const unregister = navigation.register("r2", "a", cell()); + unregister(); + navigation.move({ rowKey: "r1", colKey: "a" }, "down"); + expect(cells.r3a.focus).toHaveBeenCalled(); + }); + + it("keeps a newer registration when a stale cleanup runs late", () => { + const navigation = createCellEditNavigation(); + navigation.setOrder(["r1", "r2"], ["a"]); + const first = cell(); + const second = cell(); + navigation.register("r1", "a", cell()); + const staleCleanup = navigation.register("r2", "a", first); + navigation.register("r2", "a", second); + staleCleanup(); + expect(navigation.move({ rowKey: "r1", colKey: "a" }, "down")).toBe(true); + expect(second.focus).toHaveBeenCalled(); + expect(first.focus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/data-table/use-cell-edit-navigation.ts b/packages/core/src/components/data-table/use-cell-edit-navigation.ts new file mode 100644 index 00000000..fcbe1ea4 --- /dev/null +++ b/packages/core/src/components/data-table/use-cell-edit-navigation.ts @@ -0,0 +1,84 @@ +import { useState } from "react"; + +export type CellEditDirection = "up" | "down" | "next" | "prev"; + +/** + * Tracks the editable cells a DataTable page has rendered, so a cell can hand + * focus to its neighbour on Enter / Tab without querying the DOM. + * + * Editors register their focusable element through a callback ref; + * `DataTableRows` publishes the current row and column order on every render. + * Moves only ever land on registered cells, so read-only columns and rows are + * skipped rather than trapping focus. + * + * @internal + */ +export interface CellEditNavigation { + /** Registers a cell's editor; returns the matching cleanup. */ + register: (rowKey: string, colKey: string, element: HTMLElement) => () => void; + /** The rendered row keys (top to bottom) and column keys (left to right). */ + setOrder: (rowKeys: readonly string[], colKeys: readonly string[]) => void; + /** Focuses the nearest editable cell in `direction`; `false` when there is none. */ + move: (from: { rowKey: string; colKey: string }, direction: CellEditDirection) => boolean; +} + +const cellId = (rowKey: string, colKey: string) => `${rowKey}\u0000${colKey}`; + +export function createCellEditNavigation(): CellEditNavigation { + const cells = new Map(); + let rowKeys: readonly string[] = []; + let colKeys: readonly string[] = []; + + const find = (from: { rowKey: string; colKey: string }, direction: CellEditDirection) => { + const row = rowKeys.indexOf(from.rowKey); + const col = colKeys.indexOf(from.colKey); + if (row === -1 || col === -1) return undefined; + + if (direction === "up" || direction === "down") { + const step = direction === "down" ? 1 : -1; + for (let r = row + step; r >= 0 && r < rowKeys.length; r += step) { + const cell = cells.get(cellId(rowKeys[r], from.colKey)); + if (cell) return cell; + } + return undefined; + } + + // Row-major order, left to right then top to bottom. + const step = direction === "next" ? 1 : -1; + const total = rowKeys.length * colKeys.length; + for (let i = row * colKeys.length + col + step; i >= 0 && i < total; i += step) { + const r = Math.floor(i / colKeys.length); + const cell = cells.get(cellId(rowKeys[r], colKeys[i % colKeys.length])); + if (cell) return cell; + } + return undefined; + }; + + return { + register(rowKey, colKey, element) { + const id = cellId(rowKey, colKey); + cells.set(id, element); + return () => { + // A re-render can register the replacement element before the previous + // ref's cleanup runs — only drop the entry if it is still ours. + if (cells.get(id) === element) cells.delete(id); + }; + }, + setOrder(nextRowKeys, nextColKeys) { + rowKeys = nextRowKeys; + colKeys = nextColKeys; + }, + move(from, direction) { + const target = find(from, direction); + if (!target) return false; + target.focus(); + return true; + }, + }; +} + +/** One navigation registry per rendered table body. */ +export function useCellEditNavigation(): CellEditNavigation { + const [navigation] = useState(createCellEditNavigation); + return navigation; +} From 3182fdb1a019a0ab05c643053e35aef20e3e0241 Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 10:58:57 +0530 Subject: [PATCH 03/12] feat(data-table): inline cell editing for text, number, money and link columns Give a column an `edit` config and users can type straight into its cells. The table never stores edits: `edit.onCommit(row, value)` runs when the user leaves the cell (or presses Enter / Tab) with a changed value that passes every rule, and the consumer updates `data`. - Rules: `min`, `max`, `maxDecimals`, `required`, `validate`. Characters that can never be valid are blocked as typed; other errors show a red outline and a tooltip; Enter / Tab are blocked until fixed, Esc reverts, and leaving an invalid cell reverts it. - Per-row control: `canEdit(row, { selected })`. - Keyboard: Enter moves down the column, Tab to the next editable cell; IME composition is respected. - Autosave: a promise returned from `onCommit` keeps the new value on screen while pending and reverts the cell if it rejects. - The editor overlays the cell's normal content, so row height and column width never change. Number/money cells display as many decimals as their editor accepts. Refs tailor-inc/platform-planning#1750 Co-Authored-By: Claude Opus 5.5 --- .changeset/brisk-cells-type.md | 21 + .../components/data-table/cell-renderers.tsx | 119 +++- .../data-table/data-table-editing.test.tsx | 614 ++++++++++++++++++ .../src/components/data-table/data-table.tsx | 47 +- .../components/data-table/editable-cell.tsx | 463 +++++++++++++ .../core/src/components/data-table/i18n.ts | 20 + .../core/src/components/data-table/index.ts | 3 + .../core/src/components/data-table/types.ts | 76 ++- 8 files changed, 1334 insertions(+), 29 deletions(-) create mode 100644 .changeset/brisk-cells-type.md create mode 100644 packages/core/src/components/data-table/data-table-editing.test.tsx create mode 100644 packages/core/src/components/data-table/editable-cell.tsx diff --git a/.changeset/brisk-cells-type.md b/.changeset/brisk-cells-type.md new file mode 100644 index 00000000..cdd946a8 --- /dev/null +++ b/.changeset/brisk-cells-type.md @@ -0,0 +1,21 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add inline cell editing to `DataTable`. Give a `text`, `number`, `money` or `link` column an `edit` config and users can type straight into its cells, with rules (`min`, `max`, `maxDecimals`, `required`, `validate`), per-row control (`canEdit(row, { selected })`) and Enter / Tab keyboard entry. The value reaches `edit.onCommit(row, value)` when the user leaves the cell; return a promise to autosave, and the cell reverts if it rejects. + +```tsx +column({ + id: "received", + label: "Received", + type: "number", + edit: { + canEdit: (row, { selected }) => selected, + min: 0, + maxDecimals: 0, // whole numbers only + onCommit: (row, value) => updateLine(row.id, { received: value }), + }, +}); +``` + +A `number` / `money` column with `edit.maxDecimals` also displays up to that many decimals, so an entered 2.5 no longer renders as "3". A `number` column whose `maxDecimals` is below its `minDecimals` no longer throws. diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 83596e88..c3f17cf7 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { Link } from "react-router"; import { BadgeList, toValueArray } from "@/components/badge-list"; +import { currencyFractionDigits } from "./cell-edit"; import type { BadgeCellOptions, Column, @@ -76,50 +77,106 @@ function renderText(value: unknown): ReactNode { } } -function renderNumber(value: unknown, options: NumberCellOptions | undefined): ReactNode { - if (isEmpty(value)) return PLACEHOLDER; - const num = Number(value); - if (Number.isNaN(num)) return PLACEHOLDER; +// `editMaxDecimals` is the column's `edit.maxDecimals`: an editable column +// displays as many decimals as its editor accepts, so a typed 2.5 never reads +// back as "3". The cap never drops below `minDecimals`, which Intl rejects +// with a RangeError. +function formatNumber( + num: number, + options: NumberCellOptions | undefined, + editMaxDecimals: number | undefined, +): string { const min = options?.minDecimals ?? 0; - const formatted = new Intl.NumberFormat(options?.locale, { + return new Intl.NumberFormat(options?.locale, { minimumFractionDigits: min, - maximumFractionDigits: options?.maxDecimals ?? min, + maximumFractionDigits: Math.max(min, options?.maxDecimals ?? min, editMaxDecimals ?? 0), }).format(num); - return {formatted}; } -function renderMoney>( +function renderNumber( value: unknown, - row: TRow, - options: MoneyCellOptions | undefined, + options: NumberCellOptions | undefined, + editMaxDecimals: number | undefined, ): ReactNode { if (isEmpty(value)) return PLACEHOLDER; const num = Number(value); if (Number.isNaN(num)) return PLACEHOLDER; - const currency = - (typeof options?.currency === "function" ? options.currency(row) : options?.currency) || "USD"; + return {formatNumber(num, options, editMaxDecimals)}; +} + +/** + * The ISO 4217 code a `money` column uses for a row. Default: `"USD"`. + * + * @internal + */ +export function resolveMoneyCurrency>( + options: MoneyCellOptions | undefined, + row: TRow, +): string { + return ( + (typeof options?.currency === "function" ? options.currency(row) : options?.currency) || "USD" + ); +} + +function formatMoney>( + num: number, + row: TRow, + options: MoneyCellOptions | undefined, + editMaxDecimals: number | undefined, +): string { + const currency = resolveMoneyCurrency(options, row); // `maxDecimals` raises the cap above the currency default while keeping the // minimum at the currency default (e.g. 2 for USD). Lets a JPY column stay - // at 0 decimals while a USD price-detail column shows up to 4. + // at 0 decimals while a USD price-detail column shows up to 4. An editable + // column's `edit.maxDecimals` raises it the same way, but never lowers it. const formatOptions: Intl.NumberFormatOptions = { style: "currency", currency, }; - if (options?.maxDecimals != null) { - formatOptions.maximumFractionDigits = options.maxDecimals; + const editCap = editMaxDecimals ?? 0; + if (options?.maxDecimals != null || editCap > currencyFractionDigits(currency)) { + formatOptions.maximumFractionDigits = Math.max(options?.maxDecimals ?? 0, editCap); } - let formatted: string; try { - formatted = new Intl.NumberFormat(options?.locale, formatOptions).format(num); + return new Intl.NumberFormat(options?.locale, formatOptions).format(num); } catch { // Fall back to USD if the currency code is invalid — Intl throws on bad ISO codes. - formatted = new Intl.NumberFormat(options?.locale, { + return new Intl.NumberFormat(options?.locale, { style: "currency", currency: "USD", }).format(num); } - return {formatted}; +} + +function renderMoney>( + value: unknown, + row: TRow, + options: MoneyCellOptions | undefined, + editMaxDecimals: number | undefined, +): ReactNode { + if (isEmpty(value)) return PLACEHOLDER; + const num = Number(value); + if (Number.isNaN(num)) return PLACEHOLDER; + return ( + {formatMoney(num, row, options, editMaxDecimals)} + ); +} + +/** + * Formats a bare number the way a `number` / `money` column displays it. Used + * for the bounds in inline-editing messages ("Must be $1,000.00 or less"). + * + * @internal + */ +export function formatColumnNumber>( + row: TRow, + col: Column, + value: number, +): string { + if (col.type === "money") return formatMoney(value, row, col.typeOptions, col.edit?.maxDecimals); + if (col.type === "number") return formatNumber(value, col.typeOptions, col.edit?.maxDecimals); + return String(value); } function renderDate(value: unknown, options: DateCellOptions | undefined): ReactNode { @@ -164,18 +221,34 @@ export function renderTypedCell>( row: TRow, col: Column, ): ReactNode { - const value = getCellValue(row, col); + return renderTypedValue(row, col, getCellValue(row, col)); +} + +/** + * Render `value` with the column's built-in `type` renderer. Editable cells use + * it to show a save that is still in flight before the new value reaches + * `data`, and pass `linkAsText` because a `link` cell that can be edited is an + * input, not a link. + * + * @internal + */ +export function renderTypedValue>( + row: TRow, + col: Column, + value: unknown, + options?: { linkAsText?: boolean }, +): ReactNode { switch (col.type) { case "number": - return renderNumber(value, col.typeOptions); + return renderNumber(value, col.typeOptions, col.edit?.maxDecimals); case "money": - return renderMoney(value, row, col.typeOptions); + return renderMoney(value, row, col.typeOptions, col.edit?.maxDecimals); case "date": return renderDate(value, col.typeOptions); case "badge": return renderBadge(value, col.typeOptions); case "link": - return renderLink(value, row, col.typeOptions); + return options?.linkAsText ? renderText(value) : renderLink(value, row, col.typeOptions); case "text": default: return renderText(value); diff --git a/packages/core/src/components/data-table/data-table-editing.test.tsx b/packages/core/src/components/data-table/data-table-editing.test.tsx new file mode 100644 index 00000000..8c469e8f --- /dev/null +++ b/packages/core/src/components/data-table/data-table-editing.test.tsx @@ -0,0 +1,614 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { StrictMode, useState } from "react"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import { createColumnHelper } from "./field-helpers"; +import type { CellEditState, Column, UseDataTableOptions } from "./types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +type Line = { + id: string; + sku: string; + ordered: number; + received: number | null; + price: number; + currency: string; + note: string | null; + supplier: string; +}; + +const LINES: Line[] = [ + { + id: "1", + sku: "TS-M", + ordered: 24, + received: 12, + price: 9.5, + currency: "USD", + note: "Box 1", + supplier: "Acme", + }, + { + id: "2", + sku: "TS-L", + ordered: 24, + received: null, + price: 1200, + currency: "JPY", + note: null, + supplier: "Globex", + }, + { + id: "3", + sku: "TS-XL", + ordered: 12, + received: 10, + price: 4, + currency: "USD", + note: "", + supplier: "Acme", + }, +]; + +const noop = () => {}; + +type Update = (id: string, patch: Partial) => void; + +function Harness({ + columns, + rows: initialRows = LINES, + options, +}: { + columns: (update: Update) => Column[]; + rows?: Line[]; + options?: Partial>; +}) { + const [rows, setRows] = useState(initialRows); + const update: Update = (id, patch) => + setRows((prev) => prev.map((row) => (row.id === id ? { ...row, ...patch } : row))); + const table = useDataTable({ columns: columns(update), data: { rows }, ...options }); + return ( + + + + ); +} + +function renderTable(ui: React.ReactElement, locale = "en") { + const user = userEvent.setup(); + render({ui}, { wrapper: createAppShellWrapper(locale) }); + return user; +} + +const { column } = createColumnHelper(); + +const skuColumn = column({ id: "sku", label: "SKU", type: "text" }); + +// "Received": whole numbers, not below 0, not above what was ordered. +function receivedColumn( + onCommit: (id: string, value: number | null) => void, + update: Update, + edit: Partial<{ + canEdit: (row: Line, state: CellEditState) => boolean; + min: number; + max: number; + maxDecimals: number; + }> = {}, +): Column { + return column({ + id: "received", + label: "Received", + type: "number", + edit: { + min: 0, + maxDecimals: 0, + validate: (value, row) => + value !== null && value > row.ordered ? `Can't exceed ordered (${row.ordered})` : undefined, + onCommit: (row, value) => { + onCommit(row.id, value); + update(row.id, { received: value }); + }, + ...edit, + }, + }); +} + +const errorText = (input: HTMLElement) => + document.getElementById(input.getAttribute("aria-describedby") ?? "")?.textContent; + +// Focuses a cell and replaces its value. (A real click selects the whole value; +// user-event collapses that selection, so clear explicitly.) +async function retype(user: ReturnType, input: HTMLElement, text: string) { + await user.click(input); + await user.clear(input); + if (text) await user.keyboard(text); +} + +describe("DataTable inline editing", () => { + describe("rendering", () => { + it("renders an editor only where canEdit allows it, named by the column label", () => { + renderTable( + [ + skuColumn, + receivedColumn(vi.fn(), update, { canEdit: (row) => row.id !== "2" }), + ]} + />, + ); + const inputs = screen.getAllByRole("textbox", { name: "Received" }); + expect(inputs).toHaveLength(2); + expect(inputs.map((input) => (input as HTMLInputElement).value)).toEqual(["12", "10"]); + // Read-only columns don't get an editor at all. + expect(screen.queryByRole("textbox", { name: "SKU" })).toBeNull(); + }); + + it("keeps an editable link cell as text and a read-only one as a link", () => { + renderTable( + [ + column({ + id: "supplier", + label: "Supplier", + type: "link", + typeOptions: { href: (row) => `/suppliers/${row.supplier}` }, + edit: { canEdit: (row) => row.id === "1", onCommit: () => {} }, + }), + ]} + />, + ); + expect(screen.getAllByRole("textbox", { name: "Supplier" })).toHaveLength(1); + expect(screen.getAllByRole("link").map((link) => link.textContent)).toEqual([ + "Globex", + "Acme", + ]); + }); + + it("keeps rows without an id read-only and warns once", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + renderTable( + rest as unknown as Line)} + columns={(update) => [receivedColumn(vi.fn(), update)]} + />, + ); + expect(screen.queryAllByRole("textbox")).toHaveLength(0); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain("has no `id`"); + }); + + it("shows as many decimals as the editor accepts", async () => { + const user = renderTable( + [receivedColumn(vi.fn(), update, { maxDecimals: 2 })]} />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "2.5{Enter}"); + // A `number` column with no typeOptions rounds to whole numbers; the + // editor's `maxDecimals` raises that so 2.5 doesn't read back as "3". + expect(input.parentElement?.textContent).toContain("2.5"); + }); + }); + + describe("keyboard", () => { + it("commits once on Enter and moves down to the next editable row", async () => { + const onCommit = vi.fn(); + const user = renderTable( + + [ + receivedColumn(onCommit, update, { canEdit: (row) => row.id !== "2" }), + ]} + /> + , + ); + const [first, third] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, first, "20{Enter}"); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(onCommit).toHaveBeenCalledWith("1", 20); + // Row 2 isn't editable, so Enter skips to row 3. + expect(document.activeElement).toBe(third); + expect((first as HTMLInputElement).value).toBe("20"); + }); + + it("moves up on Shift+Enter", async () => { + const user = renderTable( [receivedColumn(vi.fn(), update)]} />); + const inputs = screen.getAllByRole("textbox", { name: "Received" }); + await user.click(inputs[2]); + await user.keyboard("{Shift>}{Enter}{/Shift}"); + expect(document.activeElement).toBe(inputs[1]); + }); + + it("moves to the next editable cell on Tab, skipping read-only columns", async () => { + const user = renderTable( + [ + receivedColumn(vi.fn(), update), + skuColumn, + column({ + id: "note", + label: "Note", + type: "text", + edit: { onCommit: (row, value) => update(row.id, { note: value }) }, + }), + ]} + />, + ); + const received = screen.getAllByRole("textbox", { name: "Received" }); + const notes = screen.getAllByRole("textbox", { name: "Note" }); + await user.click(received[0]); + await user.tab(); + expect(document.activeElement).toBe(notes[0]); + await user.tab(); + expect(document.activeElement).toBe(received[1]); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(notes[0]); + }); + + it("reverts the draft on Escape without committing", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [receivedColumn(onCommit, update)]} />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "7{Escape}"); + expect((input as HTMLInputElement).value).toBe("12"); + await user.tab(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("selects the whole value when a cell receives focus", () => { + renderTable( [receivedColumn(vi.fn(), update)]} />); + const [input] = screen.getAllByRole("textbox", { name: "Received" }) as HTMLInputElement[]; + act(() => input.focus()); + expect([input.selectionStart, input.selectionEnd]).toEqual([0, input.value.length]); + }); + + it("ignores Enter while an IME is composing and normalizes full-width digits", async () => { + const onCommit = vi.fn(); + renderTable( [receivedColumn(onCommit, update)]} />); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + act(() => input.focus()); + fireEvent.compositionStart(input); + fireEvent.change(input, { target: { value: "15" } }); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect(onCommit).not.toHaveBeenCalled(); + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onCommit).toHaveBeenCalledWith("1", 15); + }); + }); + + describe("rules", () => { + it("blocks characters that can never be valid", async () => { + const user = renderTable( + [ + receivedColumn(vi.fn(), update), + column({ + id: "price", + label: "Price", + type: "money", + typeOptions: { currency: (row) => row.currency }, + edit: { onCommit: (row, value) => update(row.id, { price: value ?? 0 }) }, + }), + ]} + />, + ); + const [received] = screen.getAllByRole("textbox", { name: "Received" }); + // min: 0 blocks "-", maxDecimals: 0 blocks "." + await retype(user, received, "-3.5"); + expect((received as HTMLInputElement).value).toBe("35"); + + const [usd, jpy] = screen.getAllByRole("textbox", { name: "Price" }); + await retype(user, usd, "9.999"); + // USD allows two decimals; the third digit is dropped. + expect((usd as HTMLInputElement).value).toBe("9.99"); + await retype(user, jpy, "12.5"); + // JPY has no minor unit, so the decimal point can't be typed. + expect((jpy as HTMLInputElement).value).toBe("125"); + }); + + it("judges text inserted in one go (autofill, dictation) instead of dropping it", () => { + renderTable( [receivedColumn(vi.fn(), update)]} />); + const [input] = screen.getAllByRole("textbox", { name: "Received" }) as HTMLInputElement[]; + act(() => input.focus()); + fireEvent.input(input, { + target: { value: "3.5" }, + data: "3.5", + inputType: "insertText", + }); + expect(input.value).toBe("3.5"); + expect(errorText(input)).toBe("Enter a whole number. Press Esc to undo"); + }); + + it("shows an over-the-limit error while typing and blocks Enter", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [receivedColumn(onCommit, update, { max: 24 })]} />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "30"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(errorText(input)).toBe("Must be 24 or less. Press Esc to undo"); + await user.keyboard("{Enter}"); + expect(onCommit).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(input); + }); + + it("waits for a save attempt before reporting min and required", async () => { + const onCommit = vi.fn(); + const user = renderTable( + + [ + column({ + id: "received", + label: "Received", + type: "number", + edit: { + min: 10, + required: true, + onCommit: (row, value) => { + onCommit(row.id, value); + update(row.id, { received: value }); + }, + }, + }), + ] satisfies Column[] + } + />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "5"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + await user.keyboard("{Enter}"); + expect(errorText(input)).toBe("Must be 10 or more. Press Esc to undo"); + await user.keyboard("{Backspace}"); + expect(errorText(input)).toBe("Required. Press Esc to undo"); + await user.keyboard("15{Enter}"); + expect(onCommit).toHaveBeenCalledWith("1", 15); + }); + + it("shows the consumer's own validation message", async () => { + const user = renderTable( [receivedColumn(vi.fn(), update)]} />); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "25"); + expect(errorText(input)).toBe("Can't exceed ordered (24). Press Esc to undo"); + }); + + it("reverts an invalid value when the cell loses focus", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [receivedColumn(onCommit, update)]} />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "99"); + await user.click(document.body); + expect(onCommit).not.toHaveBeenCalled(); + expect((input as HTMLInputElement).value).toBe("12"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + }); + + it("localizes messages", async () => { + const user = renderTable( + [receivedColumn(vi.fn(), update, { max: 24 })]} />, + "ja", + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "30"); + expect(errorText(input)).toBe("24以下で入力してください. Escキーで元に戻せます"); + }); + }); + + describe("committing", () => { + it("saves a changed value when the cell loses focus", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [receivedColumn(onCommit, update)]} />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "18"); + await user.click(document.body); + expect(onCommit).toHaveBeenCalledWith("1", 18); + }); + + it("treats a value that reads the same as unchanged", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [receivedColumn(onCommit, update, { maxDecimals: 2 })]} />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "12.00{Enter}"); + await user.click(input); + await user.keyboard("{Enter}"); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("cleans up pasted numbers and reports text it can't read", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [ + column({ + id: "received", + label: "Received", + type: "number", + edit: { + maxDecimals: 1, + onCommit: (row, value) => { + onCommit(row.id, value); + update(row.id, { received: value }); + }, + }, + }), + ]} + />, + ); + const [first, second] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, first, ""); + await user.paste("1,234.5"); + await user.keyboard("{Enter}"); + expect(onCommit).toHaveBeenCalledWith("1", 1234.5); + + await retype(user, second, ""); + await user.paste("abc"); + await user.keyboard("{Enter}"); + expect(errorText(second)).toBe("Enter a number. Press Esc to undo"); + expect(onCommit).toHaveBeenCalledTimes(1); + }); + + it("commits text as typed and an emptied cell as null", async () => { + const onCommit = vi.fn(); + const user = renderTable( + [ + column({ + id: "note", + label: "Note", + type: "text", + edit: { + onCommit: (row, value) => { + onCommit(row.id, value); + update(row.id, { note: value }); + }, + }, + }), + ]} + />, + ); + const [first] = screen.getAllByRole("textbox", { name: "Note" }); + await retype(user, first, "Box 2{Enter}"); + await retype(user, first, "{Enter}"); + expect(onCommit.mock.calls).toEqual([ + ["1", "Box 2"], + ["1", null], + ]); + }); + + it("keeps showing a pending save, and reverts when it fails", async () => { + let reject = noop; + let resolve = noop; + const onCommit = vi + .fn() + .mockImplementationOnce(() => new Promise((_, r) => (reject = () => r(new Error())))) + .mockImplementationOnce(() => new Promise((r) => (resolve = r))); + const user = renderTable( + [ + column({ id: "received", label: "Received", type: "number", edit: { onCommit } }), + ]} + />, + ); + const [input] = screen.getAllByRole("textbox", { name: "Received" }); + await retype(user, input, "20{Enter}"); + expect((input as HTMLInputElement).value).toBe("20"); + await act(async () => reject()); + expect((input as HTMLInputElement).value).toBe("12"); + + await retype(user, input, "21{Enter}"); + await act(async () => resolve()); + // Saved, but `data` never changed: the saved value stays on screen. + expect((input as HTMLInputElement).value).toBe("21"); + }); + }); + + describe("interplay", () => { + it("lets canEdit follow row selection", async () => { + const user = renderTable( + [ + receivedColumn(vi.fn(), update, { canEdit: (_, { selected }) => selected }), + ]} + options={{ onSelectionChange: () => {} }} + />, + ); + expect(screen.queryAllByRole("textbox")).toHaveLength(0); + const [selectFirst] = screen.getAllByRole("checkbox", { name: "Select row" }); + await user.click(selectFirst); + expect(screen.getAllByRole("textbox", { name: "Received" })).toHaveLength(1); + await user.click(selectFirst); + expect(screen.queryAllByRole("textbox")).toHaveLength(0); + }); + + it("never fires onClickRow from an editable cell", async () => { + const onClickRow = vi.fn(); + const user = renderTable( + [skuColumn, receivedColumn(vi.fn(), update)]} + options={{ onClickRow }} + />, + ); + await user.click(screen.getAllByRole("textbox", { name: "Received" })[0]); + expect(onClickRow).not.toHaveBeenCalled(); + await user.click(screen.getByText("TS-M")); + expect(onClickRow).toHaveBeenCalledTimes(1); + }); + }); + + it("types each column's edit config", () => { + column({ + id: "received", + type: "number", + edit: { + canEdit: (row, state) => { + expectTypeOf(row).toEqualTypeOf(); + expectTypeOf(state).toEqualTypeOf(); + return true; + }, + onCommit: (_row, value) => { + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }); + column({ + id: "received", + type: "money", + edit: { + // `required` is enforced at runtime; the callbacks keep one signature. + required: true, + validate: (value, row) => { + expectTypeOf(value).toEqualTypeOf(); + expectTypeOf(row).toEqualTypeOf(); + return undefined; + }, + onCommit: (_row, value) => { + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }); + column({ + id: "note", + type: "text", + edit: { + onCommit: (_row, value) => { + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }); + column({ + id: "supplier", + type: "link", + typeOptions: { href: () => "/" }, + edit: { onCommit: async () => {} }, + }); + // @ts-expect-error — `min` is a number / money rule + column({ id: "note", type: "text", edit: { min: 0, onCommit: () => {} } }); + // @ts-expect-error — date columns can't be edited yet + column({ id: "sku", type: "date", edit: { onCommit: () => {} } }); + // @ts-expect-error — columns without a `type` have no built-in editor + column({ id: "sku", render: () => null, edit: { onCommit: () => {} } }); + column({ + id: "received", + type: "number", + // @ts-expect-error — the committed value can be null (an emptied cell) + edit: { onCommit: (_row, _value: number) => {} }, + }); + }); +}); diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 97271cde..fe9490b3 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -36,6 +36,8 @@ import { useDataTableT } from "./i18n"; import { getCellValue, renderTypedCell } from "./cell-renderers"; import { isTemporalFilterType, normalizeTemporalFilterValue } from "./filter-value-utils"; import { useCellContextMenu, type CellContextMenuState } from "./use-cell-context-menu"; +import { useCellEditNavigation } from "./use-cell-edit-navigation"; +import { DataTableEditableCell, isEditableColumn } from "./editable-cell"; import { DataTableToolbar, DataTableFilters, @@ -1226,16 +1228,24 @@ function DataTableRows>({ handleContextMenuCloseComplete: handleCellContextMenuCloseComplete, getCellContextMenuHandlers, } = useCellContextMenu(); + const navigation = useCellEditNavigation(); + // Namespaced so an id-less row's index fallback can't collide with a real + // id of the same digits — React reconciles duplicate keys by position, + // pairing a detail panel with the wrong row. + const rowKeys = rows.map((row, rowIndex) => { + const rowId = (row as Record)["id"]; + return rowId != null ? `id:${String(rowId)}` : `idx:${rowIndex}`; + }); + // Enter / Tab move between editable cells in the order they render. + navigation.setOrder(rowKeys, ordered?.map((col) => keys.get(col) as string) ?? []); + const warnedMissingIdRef = useRef(false); return ( <> {rows.map((row, rowIndex) => { const rowId = (row as Record)["id"]; const selected = isRowSelected?.(row) ?? false; - // Namespaced so an id-less row's index fallback can't collide with a real - // id of the same digits — React reconciles duplicate keys by position, - // pairing a detail panel with the wrong row. - const rowKey = rowId != null ? `id:${String(rowId)}` : `idx:${rowIndex}`; + const rowKey = rowKeys[rowIndex]; // Expansion is keyed by id, so a row without one gets no chevron at all // rather than a disabled one — it must never be un-toggleable (D5). const expandable = hasExpand && rowId != null && (rowExpansion?.canExpand?.(row) ?? true); @@ -1312,7 +1322,18 @@ function DataTableRows>({ })()} {ordered?.map((col) => { const key = keys.get(col) as string; - const content = col.render ? col.render(row) : renderTypedCell(row, col); + const editableColumn = isEditableColumn(col); + if (editableColumn && rowId == null && !warnedMissingIdRef.current) { + warnedMissingIdRef.current = true; + console.warn( + `[DataTable] Column "${key}" has an \`edit\` config, but a row has no \`id\`. Rows without an \`id\` are shown read-only.`, + ); + } + const editable = + editableColumn && rowId != null && (col.edit?.canEdit?.(row, { selected }) ?? true); + // An editable cell renders its own display; skip rendering it twice. + let content: ReactNode; + if (!editable) content = col.render ? col.render(row) : renderTypedCell(row, col); const { style: cellStyle, className: cellClassName } = pinCellProps( placements.get(col), @@ -1364,6 +1385,22 @@ function DataTableRows>({ className: cellClassName, ...cellContextMenuHandlers, }; + if (editable) { + return ( + + ); + } + const cellElement = ; if (tooltipLabel !== undefined) { diff --git a/packages/core/src/components/data-table/editable-cell.tsx b/packages/core/src/components/data-table/editable-cell.tsx new file mode 100644 index 00000000..c967155c --- /dev/null +++ b/packages/core/src/components/data-table/editable-cell.tsx @@ -0,0 +1,463 @@ +import { + useCallback, + useId, + useRef, + useState, + type ChangeEvent, + type ComponentProps, + type CompositionEvent, + type FocusEvent, + type KeyboardEvent, + type MouseEvent, + type ReactNode, + type TouchEvent, +} from "react"; +import { cn } from "@/lib/utils"; +import { Input } from "@/components/input"; +import { Table } from "@/components/table"; +import { Tooltip } from "@/components/tooltip"; +import type { Column } from "./types"; +import { + formatColumnNumber, + getCellValue, + renderTypedValue, + resolveMoneyCurrency, +} from "./cell-renderers"; +import { + currencyFractionDigits, + evaluateNumberDraft, + evaluateTextDraft, + isAllowedNumberText, + isLiveError, + isPromiseLike, + normalizeNumberText, + sameNumber, + toNumberEditText, + toNumberValue, + toTextValue, + type CellEditError, + type DraftEvaluation, + type NumberEditRules, +} from "./cell-edit"; +import type { CellEditNavigation } from "./use-cell-edit-navigation"; +import { useDataTableT } from "./i18n"; + +/** + * A column whose cells can be edited in place. + * + * @internal + */ +export type EditableColumn> = Extract< + Column, + { type: "text" | "number" | "money" | "link" } +>; + +/** + * Whether the column carries an `edit` config on a type that supports it. + * + * @internal + */ +export function isEditableColumn>( + col: Column, +): col is EditableColumn { + if (col.edit === undefined) return false; + return ( + col.type === "text" || col.type === "number" || col.type === "money" || col.type === "link" + ); +} + +// The editor sits over the cell's static content (the sizer) instead of taking +// part in layout, so a cell becoming editable — or being edited — never +// changes row height or column width. Reaching 6px past the content on every +// side leaves the 2px focus ring inside the cell's 8px padding. +const EDITOR_CLASS_NAME = cn( + "astw:absolute astw:-top-1.5 astw:-left-1.5 astw:h-[calc(100%+0.75rem)] astw:w-[calc(100%+0.75rem)]", + "astw:rounded-sm astw:px-[5px] astw:py-0 astw:shadow-none astw:bg-transparent astw:dark:bg-transparent", + "astw:border-input/60 astw:hover:border-input astw:focus-visible:ring-2", +); + +interface PendingCommit { + id: number; + value: unknown; + /** The row object the save was made against. */ + row: TRow; + settled: boolean; +} + +type CellValidate = ((value: unknown, row: TRow) => string | null | undefined) | undefined; + +function defaultMaxDecimals>( + col: EditableColumn, + row: TRow, +): number { + if (col.type === "money") { + return ( + col.typeOptions?.maxDecimals ?? + currencyFractionDigits(resolveMoneyCurrency(col.typeOptions, row)) + ); + } + if (col.type === "number") { + return col.typeOptions?.maxDecimals ?? col.typeOptions?.minDecimals ?? 0; + } + return 0; +} + +function resolveNumberRules>( + col: EditableColumn, + row: TRow, +): NumberEditRules | undefined { + if (col.type !== "number" && col.type !== "money") return undefined; + const edit = col.edit; + return { + min: edit?.min, + max: edit?.max, + maxDecimals: edit?.maxDecimals ?? defaultMaxDecimals(col, row), + required: edit?.required === true, + }; +} + +// iOS number pads have no minus key, so a column that allows negatives keeps +// the full keyboard. +function numberInputMode(rules: NumberEditRules): "numeric" | "decimal" | "text" { + if (rules.min === undefined || rules.min < 0) return "text"; + return rules.maxDecimals === 0 ? "numeric" : "decimal"; +} + +interface DataTableEditableCellProps> { + row: TRow; + col: EditableColumn; + rowKey: string; + colKey: string; + /** Accessible name for the editor — the column's label. */ + label: string; + align: "left" | "right"; + navigation: CellEditNavigation; + /** `data-slot`, style, class and context-menu handlers shared with static cells. */ + cellProps: ComponentProps; +} + +/** + * A body cell whose value can be typed over in place (`text`, `number`, + * `money`, `link`). The value belongs to the consumer: the cell only holds a + * draft while it is focused, and hands the parsed value to `edit.onCommit` + * when the user leaves the cell or presses Enter / Tab. + * + * @internal + */ +export function DataTableEditableCell>({ + row, + col, + rowKey, + colKey, + label, + align, + navigation, + cellProps, +}: DataTableEditableCellProps) { + const t = useDataTableT(); + const errorId = useId(); + const edit = col.edit; + const validate = edit?.validate as CellValidate; + + const [draft, setDraftState] = useState(null); + // Read by handlers that run before the next render: the blur fired by moving + // focus right after Enter must see that Enter already committed the draft. + const draftRef = useRef(null); + const setDraft = (next: string | null) => { + draftRef.current = next; + setDraftState(next); + }; + const [focused, setFocused] = useState(false); + // Errors that typing can still fix wait for a save attempt; after one fails, + // every error shows (and clears) live until the draft is committed or reverted. + const [showAllErrors, setShowAllErrors] = useState(false); + const [pending, setPending] = useState | null>(null); + const commitIdRef = useRef(0); + const pastingRef = useRef(false); + const composingRef = useRef(false); + const selectOnMouseUpRef = useRef(false); + const inputRef = useRef(null); + + // A save still in flight — or settled but not yet reflected in `data` — keeps + // showing its value. Fresh data for the row (a new row object) supersedes it. + const inFlight = pending && (!pending.settled || pending.row === row) ? pending : null; + const current = inFlight ? inFlight.value : getCellValue(row, col); + + const rules = resolveNumberRules(col, row); + const baselineText = rules + ? toNumberEditText(toNumberValue(current)) + : (toTextValue(current) ?? ""); + const text = draft ?? baselineText; + + const evaluate = (value: string): DraftEvaluation => { + const check = (parsed: unknown) => validate?.(parsed, row); + return rules + ? evaluateNumberDraft(value, rules, check) + : evaluateTextDraft(value, edit?.required === true, check); + }; + + // A draft that reads the same as the stored value (`10.00` over `10`) is a + // no-op: it neither commits nor reports an error. + const isUnchanged = (draftText: string, result: DraftEvaluation) => { + if (draftText === baselineText) return true; + if (result.error?.code === "number") return false; + return rules + ? sameNumber(result.value as number | null, toNumberValue(current)) + : result.value === toTextValue(current); + }; + + const evaluation = draft === null ? null : evaluate(draft); + const error = + draft !== null && evaluation && !isUnchanged(draft, evaluation) ? evaluation.error : null; + const visibleError = error && (showAllErrors || isLiveError(error)) ? error : null; + + const describe = (e: CellEditError): string => { + switch (e.code) { + case "required": + return t("editRequired"); + case "number": + return t("editNotANumber"); + case "decimals": + return e.maxDecimals === 0 + ? t("editWholeNumber") + : t("editMaxDecimals", { count: e.maxDecimals }); + case "min": + return t("editMin", { min: formatColumnNumber(row, col, e.min) }); + case "max": + return t("editMax", { max: formatColumnNumber(row, col, e.max) }); + case "custom": + return e.message; + } + }; + const message = visibleError ? describe(visibleError) : undefined; + + const save = (value: unknown) => { + const id = ++commitIdRef.current; + const returned = (edit?.onCommit as ((row: TRow, value: unknown) => unknown) | undefined)?.( + row, + value, + ); + if (!isPromiseLike(returned)) { + setPending(null); + return; + } + setPending({ id, value, row, settled: false }); + // Only the latest save for this cell may settle it, so an older request + // failing late can't revert a newer value. + returned.then( + () => setPending((p) => (p?.id === id ? { ...p, settled: true } : p)), + () => setPending((p) => (p?.id === id ? null : p)), + ); + }; + + const revert = () => { + setDraft(null); + setShowAllErrors(false); + }; + + // Commits the draft if it changed and passes every rule. Returns whether focus + // may leave the cell — `false` when a rule blocks the save. + const commitDraft = (): boolean => { + const draftText = draftRef.current; + if (draftText === null) return true; + const result = evaluate(draftText); + if (isUnchanged(draftText, result)) { + revert(); + return true; + } + if (result.error) { + setShowAllErrors(true); + return false; + } + revert(); + save(result.value); + return true; + }; + + const registerInput = useCallback( + (element: HTMLInputElement | null) => { + inputRef.current = element; + if (!element) return; + const unregister = navigation.register(rowKey, colKey, element); + return () => { + inputRef.current = null; + unregister(); + }; + }, + [navigation, rowKey, colKey], + ); + + const handleChange = (event: ChangeEvent) => { + const next = event.target.value; + if (!rules || composingRef.current) { + setDraft(next); + return; + } + // Text that arrives in one go — a paste, a drop, autofill, dictation — is + // cleaned up, then left for the rules to judge: never silently dropped or + // rounded the way single keystrokes are filtered below. + const { inputType, data } = event.nativeEvent as Partial; + const bulk = + pastingRef.current || + inputType === "insertFromPaste" || + inputType === "insertFromDrop" || + (typeof data === "string" && data.length > 1); + if (bulk) { + pastingRef.current = false; + setDraft(normalizeNumberText(next)); + return; + } + // Characters that can never be valid are dropped: leaving the draft + // untouched makes React restore the input's previous value. + if (isAllowedNumberText(next, rules)) setDraft(next); + }; + + const handleCompositionEnd = (event: CompositionEvent) => { + composingRef.current = false; + if (rules) setDraft(normalizeNumberText(event.currentTarget.value)); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + pastingRef.current = false; + // While an IME is composing, Enter confirms the conversion, not the cell. + if (event.nativeEvent.isComposing || composingRef.current || event.keyCode === 229) return; + const from = { rowKey, colKey }; + switch (event.key) { + case "Enter": + event.preventDefault(); + if (commitDraft()) navigation.move(from, event.shiftKey ? "up" : "down"); + return; + case "Tab": + if (!commitDraft()) { + event.preventDefault(); + return; + } + // At the first / last editable cell, native Tab leaves the table. + if (navigation.move(from, event.shiftKey ? "prev" : "next")) event.preventDefault(); + return; + case "Escape": + if (draftRef.current === null || draftRef.current === baselineText) return; + // Handled here, so an enclosing dialog doesn't also close on this Esc. + event.preventDefault(); + event.stopPropagation(); + revert(); + return; + } + }; + + const handleFocus = (event: FocusEvent) => { + setFocused(true); + event.currentTarget.select(); + }; + + // Leaving the cell saves a valid change and quietly reverts an invalid one. + const handleBlur = () => { + setFocused(false); + if (!commitDraft()) revert(); + }; + + const handleMouseDown = (event: MouseEvent) => { + if (focused) return; + // A right-click on a resting cell opens DataTable's cell menu instead of editing. + if (event.button === 2) { + event.preventDefault(); + return; + } + selectOnMouseUpRef.current = true; + }; + + // The click that focuses the cell keeps the select-all from `handleFocus`; + // otherwise mouseup would collapse it to a caret. + const handleMouseUp = (event: MouseEvent) => { + if (!selectOnMouseUpRef.current) return; + selectOnMouseUpRef.current = false; + event.preventDefault(); + }; + + // While editing, the browser's own menu and long-press (paste, select) win + // over DataTable's cell menu. + const stopWhileFocused = (event: MouseEvent | TouchEvent) => { + if (focused) event.stopPropagation(); + }; + + let display: ReactNode; + if (inFlight) display = renderTypedValue(row, col, inFlight.value, { linkAsText: true }); + else if (col.render) display = col.render(row); + else display = renderTypedValue(row, col, current, { linkAsText: true }); + + return ( + event.stopPropagation()} + // A click in the cell's padding, outside the editor, still starts editing. + onMouseDown={(event) => { + if (event.target !== event.currentTarget || event.button !== 0) return; + event.preventDefault(); + inputRef.current?.focus(); + }} + > + + + + { + pastingRef.current = true; + }} + onCompositionStart={() => { + composingRef.current = true; + }} + onCompositionEnd={handleCompositionEnd} + onMouseDown={handleMouseDown} + onMouseUp={handleMouseUp} + onContextMenu={stopWhileFocused} + onTouchStart={stopWhileFocused} + /> + } + /> + + {message} + {t("editRevertHint")} + + + {message !== undefined && ( + + {`${message}. ${t("editRevertHint")}`} + + )} + + + ); +} diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 9243962f..b641b989 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -117,6 +117,16 @@ export const dataTableLabels = defineI18nLabels({ `${props.column} ${props.operator} ${props.value}`, filterChipLabelEnum: (props: { column: string; operator: string; value: string }) => `${props.column} ${props.operator}: ${props.value}`, + + // Inline cell editing — rule errors (tooltip + screen-reader description) + editRequired: "Required", + editNotANumber: "Enter a number", + editWholeNumber: "Enter a whole number", + editMaxDecimals: (props: { count: number }) => + props.count === 1 ? "Use up to 1 decimal place" : `Use up to ${props.count} decimal places`, + editMin: (props: { min: string }) => `Must be ${props.min} or more`, + editMax: (props: { max: string }) => `Must be ${props.max} or less`, + editRevertHint: "Press Esc to undo", }, ja: { loading: "読み込み中...", @@ -222,6 +232,16 @@ export const dataTableLabels = defineI18nLabels({ `${props.column}: ${props.value} ${props.operator}`, filterChipLabelEnum: (props: { column: string; operator: string; value: string }) => `${props.column} ${props.operator}: ${props.value}`, + + // Inline cell editing + editRequired: "入力してください", + editNotANumber: "数値を入力してください", + editWholeNumber: "整数で入力してください", + editMaxDecimals: (props: { count: number }) => + `小数点以下${props.count}桁までで入力してください`, + editMin: (props: { min: string }) => `${props.min}以上で入力してください`, + editMax: (props: { max: string }) => `${props.max}以下で入力してください`, + editRevertHint: "Escキーで元に戻せます", }, }); diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index 27c3ad96..896db84a 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -11,6 +11,7 @@ export { createColumnHelper } from "./field-helpers"; export type { BadgeCellOptions, BadgeVariant, + CellEditState, Column, ColumnBase, ColumnCellType, @@ -22,8 +23,10 @@ export type { LinkCellOptions, MetadataFieldOptions, MoneyCellOptions, + NumberCellEditOptions, NumberCellOptions, RowAction, + TextCellEditOptions, UseDataTableOptions, UseDataTableReturn, } from "./types"; diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index ddcd71af..a1156744 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -84,6 +84,70 @@ export interface LinkCellOptions> { href: (row: TRow) => string | null | undefined; } +// ============================================================================= +// Inline editing +// ============================================================================= + +/** Row state passed to `edit.canEdit` alongside the row. */ +export interface CellEditState { + /** Whether the row is selected. Always `false` when row selection is off. */ + selected: boolean; +} + +interface CellEditBase, TValue> { + /** + * Decides, per row, whether the cell can be edited. Receives the row and + * whether it is selected, so "editable once selected" is + * `(_, { selected }) => selected`. Default: every row with an `id`. + */ + canEdit?: (row: TRow, state: CellEditState) => boolean; + /** + * Rejects an emptied cell with a "Required" message, so `validate` and + * `onCommit` never receive `null` at runtime. Default: `false`. + */ + required?: boolean; + /** + * The screen's own rule, checked after the built-in ones. Return a message to + * block the save — it shows in the cell's tooltip — or nothing to allow it. + */ + validate?: (value: TValue | null, row: TRow) => string | null | undefined; + /** + * Runs when the user leaves the cell (or presses Enter / Tab) with a value + * that changed and passes every rule. Update `data` from here. It may return + * a promise: while it is pending the cell keeps showing the new value, and + * if it rejects the cell goes back to the old one. + */ + onCommit: (row: TRow, value: TValue | null) => void | Promise; +} + +/** + * `edit` config for `type: "text"` and `type: "link"` columns. The value is the + * text as typed; an emptied cell commits `null`. A `link` column shows its label + * as plain text while the cell can be edited. + */ +export type TextCellEditOptions> = CellEditBase; + +/** + * `edit` config for `type: "number"` and `type: "money"` columns. Characters + * that can never be valid are blocked as the user types; `max` and `validate` + * errors show immediately, `min` and `required` when the user tries to save. + */ +export interface NumberCellEditOptions> extends CellEditBase< + TRow, + number +> { + /** Smallest allowed value. With `min >= 0` a minus sign can't be typed. */ + min?: number; + /** Largest allowed value. */ + max?: number; + /** + * Digits allowed after the decimal point; `0` means whole numbers only. + * Defaults to what the cell displays — `typeOptions.maxDecimals` for + * `number` (else `0`), the currency's decimals for `money` (USD 2, JPY 0). + */ + maxDecimals?: number; +} + /** * Header render context for non-sortable columns. */ @@ -286,40 +350,50 @@ export interface ColumnBase> { * and `undefined` are always allowed: every built-in renderer maps them to the * `—` placeholder. * + * `edit` makes the cells editable in place and also narrows per branch, so + * `onCommit` receives the value type the column holds. `text`, `number`, + * `money` and `link` columns support it. + * * Prefer `Column` in most cases; this is exported so consumers can * compose more specific column types. */ export type ColumnTypeBranch> = - | { type?: undefined; typeOptions?: never; accessor?: (row: TRow) => unknown } + | { type?: undefined; typeOptions?: never; accessor?: (row: TRow) => unknown; edit?: never } | { type: "text"; typeOptions?: never; accessor?: (row: TRow) => string | number | boolean | bigint | null | undefined; + edit?: TextCellEditOptions; } | { type: "number"; typeOptions?: NumberCellOptions; accessor?: (row: TRow) => number | null | undefined; + edit?: NumberCellEditOptions; } | { type: "money"; typeOptions?: MoneyCellOptions; accessor?: (row: TRow) => number | null | undefined; + edit?: NumberCellEditOptions; } | { type: "date"; typeOptions?: DateCellOptions; accessor?: (row: TRow) => Date | string | number | null | undefined; + edit?: never; } | { type: "badge"; typeOptions?: BadgeCellOptions; accessor?: (row: TRow) => string | string[] | number | boolean | null | undefined; + edit?: never; } | { type: "link"; typeOptions: LinkCellOptions; accessor?: (row: TRow) => string | number | boolean | null | undefined; + edit?: TextCellEditOptions; }; /** From cb1e07229982bd71da6ea996655edfef5bb466ab Mon Sep 17 00:00:00 2001 From: itsprade Date: Tue, 29 Sep 2026 10:59:22 +0530 Subject: [PATCH 04/12] docs(data-table): document inline editing and add a goods-receipt demo - docs-src: new "Inline editing" section on the DataTable page (options, rules and errors, keyboard, saving, limits) and an `edit` column in the per-type field table; docs regenerated with `pnpm docs:sync`. - list-dense-scan pattern: replace "no inline editable cells" with an inline-entry variant and guidance on when to use detail/form instead. - vite example: goods-receipt table in the DataTable lab (received qty gated by selection, autosaving unit price with a failing save, notes, supplier link). - Decision record: note that PR 1's scope is implemented here. Refs tailor-inc/platform-planning#1750 Co-Authored-By: Claude Opus 5.5 --- decisions/data-table-inline-editing.md | 4 +- docs-manifest.json | 14 +- .../components/data-table.docs.outline.md | 111 ++++++++- .../patterns/list-dense-scan.docs.outline.md | 6 +- docs/components/data-table.md | 111 ++++++++- docs/patterns/list-dense-scan.md | 4 +- .../data-table-lab/inline-editing-demo.tsx | 210 ++++++++++++++++++ .../pages/showcase/data-table-lab/page.tsx | 17 ++ 8 files changed, 445 insertions(+), 32 deletions(-) create mode 100644 examples/vite-app/src/pages/showcase/data-table-lab/inline-editing-demo.tsx diff --git a/decisions/data-table-inline-editing.md b/decisions/data-table-inline-editing.md index cc0336ca..12904455 100644 --- a/decisions/data-table-inline-editing.md +++ b/decisions/data-table-inline-editing.md @@ -1,6 +1,6 @@ # Decision: inline cell editing in DataTable -> Status: **Open — for team input. No code yet.** +> Status: **Open — for team input.** PR 1's scope (text, number, money and link) is implemented in the same PR, so the proposal can be tried in the vite example (`/showcase/data-table-lab`) while the questions below are settled. > > Context: [platform-planning#1750](https://github.com/tailor-inc/platform-planning/issues/1750) (the UI Catalogue inline-edit pattern, left with @itsprade), the Larson IMS request in Slack (`#prj-larson-ims`), and [platform-planning#1428](https://github.com/tailor-inc/platform-planning/issues/1428). Related: [platform-planning#1115](https://github.com/tailor-inc/platform-planning/issues/1115) (optimistic rows) and [platform-planning#1161](https://github.com/tailor-inc/platform-planning/issues/1161) (LineItems). > @@ -91,7 +91,7 @@ column({ maxDecimals: 0, // whole numbers only required: true, validate: (value, row) => - value > row.ordered ? `Can't exceed ordered (${row.ordered})` : undefined, + value !== null && value > row.ordered ? `Can't exceed ordered (${row.ordered})` : undefined, // May return a promise; if it rejects, the cell reverts. onCommit: (row, value) => saveReceivedQty(row.id, value), }, diff --git a/docs-manifest.json b/docs-manifest.json index 718997a2..c1051f56 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -444,9 +444,9 @@ ], "hashes": { "typeSurface": "014a9036a3b9b0af", - "outline": "f1bea006bdeb43a4", + "outline": "eb8fea8efabf78e8", "snapshot": null, - "outputMd": "dd2da884d7550daf", + "outputMd": "544600b16cda9131", "examples": null } }, @@ -961,9 +961,9 @@ "symbols": [], "hashes": { "typeSurface": null, - "outline": "165e45ba5f128d96", + "outline": "c4f4be1b89d6ffa2", "snapshot": null, - "outputMd": "8062ea0df9403fbb", + "outputMd": "40542a69ee40b972", "examples": "c0a2bd60e8a04474" } }, @@ -1605,7 +1605,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "dd2da884d7550daf", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "544600b16cda9131", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "33902be121ff69db", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", @@ -1665,9 +1665,9 @@ "packages/core/skills/app-shell-patterns/references/patterns/interaction-confirm.md": "2db70f423c6fdf97", "packages/core/skills/app-shell-patterns/references/patterns/interaction-multi-select.md": "09e99e5035da6e81", "packages/core/skills/app-shell-patterns/references/patterns/interaction-toast.md": "f68bdab855730f48", - "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "8062ea0df9403fbb", + "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "40542a69ee40b972", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "c3fb2588a0699c26", "packages/core/skills/app-shell-patterns/references/migrations.md": "799fd5010635b2c2", - "packages/core/skills/app-shell-patterns/SKILL.md": "814325f4ea06ffae" + "packages/core/skills/app-shell-patterns/SKILL.md": "4bda10150c2e8868" } } diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 9c10ea07..7ac54153 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -2,7 +2,7 @@ kind: code-backed group: data-table title: DataTable -description: Compound data table component with sortable columns, filter chips, cursor-based pagination, row actions, and multi-row selection +description: Compound data table component with sortable columns, filter chips, cursor-based pagination, row actions, multi-row selection, and inline cell editing sources: - packages/core/src/components/data-table/** - packages/core/src/hooks/use-collection-variables.ts @@ -374,6 +374,97 @@ The trigger is a native `