diff --git a/.changeset/quiet-cell-errors.md b/.changeset/quiet-cell-errors.md new file mode 100644 index 00000000..86ad2d8d --- /dev/null +++ b/.changeset/quiet-cell-errors.md @@ -0,0 +1,67 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add built-in error popovers to DataTable inline editing. When `onValueChange` or `onValueCommitted` throws or returns a rejected Promise, the affected cell shows an error. Throw the exported `CellEditError` with a translated, user-facing message to display that text; other failures display the localized message “Could not confirm this change”. + +Alongside this feedback, the editor now retains pending and failed drafts for correction or retry. Confirmation waits for any pending `onValueChange` check to succeed, and the draft clears only after confirmation succeeds. Drafts remain editable without trapping cell navigation. For direct inputs, Tab and Shift+Tab retain an already failed commit without retrying it; press Enter to retry. Data adoption, persistence, and rollback remain consumer-owned. + +Each new failure automatically opens its popover without moving focus. Closing the details retains the draft and error, which can be inspected again with the error icon or Alt+ArrowDown. Tab or Shift+Tab to another cell or out of the table dismisses the details without trapping focus; Tab within interactive error content remains available. Esc closes open details first; a subsequent Esc discards the draft and error together. The standard popover adds no reset action. + +For example, reject negative quantities with a user-facing message. Invalid drafts remain editable; successful confirmation updates the consumer-owned rows: + +```tsx +import { useState } from "react"; +import { + CellEditError, + DataTable, + createColumnHelper, + useDataTable, +} from "@tailor-platform/app-shell"; + +type Item = { id: string; quantity: number | null }; +const { column } = createColumnHelper(); + +function EditableItems() { + const [rows, setRows] = useState([{ id: "item-1", quantity: 5 }]); + const columns = [ + column({ + id: "quantity", + label: "Quantity", + type: "number", + edit: { + onValueCommitted: (row, value) => { + if (value !== null && value < 0) { + throw new CellEditError("Quantity must be zero or greater"); + } + setRows((current) => + current.map((item) => (item.id === row.id ? { ...item, quantity: value } : item)), + ); + }, + }, + }), + ]; + const table = useDataTable({ columns, data: { rows } }); + + return ( + + + + ); +} +``` + +The standard error popover needs no configuration. To customize its body or include consumer-owned actions, add `error.render` to the column's `edit` config. The renderer receives the current row and the original thrown value: + +```tsx +error: { + render: ({ row, error }) => ( +
+

Could not update item {row.id}

+

{error instanceof CellEditError ? error.message : "Please try again later"}

+
+ ), +}, +``` + +If the callback handles its own failures and feedback instead, set `edit.error: false` to disable callback interception and retain eager edit completion. diff --git a/docs-manifest.json b/docs-manifest.json index 28adc0c7..032f95a3 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -412,6 +412,7 @@ "symbols": [ "BadgeCellEditOptions", "BuildQueryVariables", + "CellEditError", "CellEditState", "CollectionControl", "CollectionControlProvider", @@ -471,10 +472,10 @@ "withURLCollectionState" ], "hashes": { - "typeSurface": "05cbf8a4bdb7a863", - "outline": "93a2a1e3c380f5b9", + "typeSurface": "33eb2bdd6be1c774", + "outline": "8ca624c7ec761939", "snapshot": "a97e067c66e46ef6", - "outputMd": "94d4e9340fdb20f3", + "outputMd": "b1ee3ccb6cb4a1f4", "examples": "10abd548e6affb9d" } }, @@ -1786,7 +1787,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": "94d4e9340fdb20f3", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "b1ee3ccb6cb4a1f4", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "bc69f14829004f9d", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index b5e06d94..63ee9f41 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -19,6 +19,7 @@ sources: ```tsx import { + CellEditError, DataTable, useDataTable, useDataTableContext, @@ -437,7 +438,7 @@ The trigger is a native ` + + ), + }, + }, + }), + ]; + const table = useDataTable({ columns, data: { rows } }); + + return ( +
+
    +
  • + In Draft note, type ! to throw from{" "} + onValueChange. The failed draft stays visible and cannot be confirmed until + you remove !. Type !! to see a longer message wrap. Error + details open automatically without moving focus. Close them with × or Esc; a subsequent + Esc restores the adopted note and clears its error. +
  • +
  • + Set Async quantity to 13 and press Enter. The returned + Promise rejects after 600 ms; 13 stays visible while pending and after + failure. Tab closes the error details and moves on without retrying the failed draft; + Enter retries it. Change it to 14 to save successfully, or close its error + details and press Esc to restore the adopted quantity. To test stale failures, confirm 13 + and start typing another value before the rejection arrives. +
  • +
  • + Set Custom code to bad and press Enter. Its error popover + opens automatically; press Alt+ArrowDown to focus the details and use{" "} + Show valid code example to try interactive custom content. The failed{" "} + bad draft remains editable; change it to SKU-101 and confirm to + adopt a valid code. Tab out of the cell keeps the failed draft and error icon but closes + the details; Tab within the details still moves between their buttons. +
  • +
+ + Click an error icon or press Alt+ArrowDown in its editor to inspect the popover without + confirming or discarding the draft. Pending and failed drafts survive Tab or blur without + trapping focus. Only successful confirmation clears the draft; only the consumer adopts + successful values. The standard popover adds no reset action. Closing error details keeps + the draft; Esc in the editor cancels the draft and its error together. + + + + +
+ + Saves in flight: {savingCount} + + }> + {feedback} + +
+
+
+
+ ); +} diff --git a/examples/vite-app/src/pages/showcase/data-table-lab/inline-editing-demo.tsx b/examples/vite-app/src/pages/showcase/data-table-lab/inline-editing-demo.tsx index 6de14e0c..768bfbc6 100644 --- a/examples/vite-app/src/pages/showcase/data-table-lab/inline-editing-demo.tsx +++ b/examples/vite-app/src/pages/showcase/data-table-lab/inline-editing-demo.tsx @@ -1,4 +1,5 @@ import { + CellEditError, DataTable, aggregations, createColumnHelper, @@ -231,8 +232,7 @@ export function InlineEditingDemo() { canEdit: (row) => row.status === "pending", onValueCommitted: (row, value) => { if (value === null) { - reject(`${row.id}: Supplier is required`); - return; + throw new CellEditError(`${row.id}: Supplier is required`); } update(row.id, { supplierId: value }, `Supplier → ${value}`); }, @@ -268,8 +268,9 @@ export function InlineEditingDemo() { canEdit: (_row, { selected }) => selected, onValueCommitted: (row, value) => { if (value !== null && (!Number.isInteger(value) || value < 0 || value > row.ordered)) { - reject(`${row.id}: Received must be a whole number from 0 to ${row.ordered}`); - return; + throw new CellEditError( + `${row.id}: Received must be a whole number from 0 to ${row.ordered}`, + ); } update(row.id, { received: value }, `Received → ${value ?? "empty"}`); }, @@ -285,10 +286,9 @@ export function InlineEditingDemo() { onValueCommitted: (row, value) => { const scale = row.currency === "JPY" ? 1 : 100; if (value === null || value < 0 || Math.round(value * scale) / scale !== value) { - reject( + throw new CellEditError( `${row.id}: Unit price is required, non-negative, and must fit ${row.currency} precision`, ); - return; } const request = priceSaves.current.get(row.id)!; const revision = ++request.revision; @@ -329,8 +329,7 @@ export function InlineEditingDemo() { edit: { onValueCommitted: (row, value) => { if (value === null || value < 0 || value > 100 || Math.round(value * 10) / 10 !== value) { - reject(`${row.id}: Discount must be 0–100 with at most one decimal`); - return; + throw new CellEditError(`${row.id}: Discount must be 0–100 with at most one decimal`); } update(row.id, { discount: value }, `Discount → ${value}%`); }, @@ -360,8 +359,9 @@ export function InlineEditingDemo() { value !== null && (value < 0 || value > 500 || Math.round(value * 1000) / 1000 !== value) ) { - reject(`${row.id}: Weight must be 0–500 kg with at most three decimals`); - return; + throw new CellEditError( + `${row.id}: Weight must be 0–500 kg with at most three decimals`, + ); } update(row.id, { weightKg: value }, `Weight → ${value ?? "empty"} kg`); }, @@ -375,8 +375,7 @@ export function InlineEditingDemo() { edit: { onValueCommitted: (row, value) => { if (value === null || value.trim() === "") { - reject(`${row.id}: Lot no. is required`); - return; + throw new CellEditError(`${row.id}: Lot no. is required`); } update(row.id, { lot: value }, `Lot → ${value}`); }, @@ -390,8 +389,7 @@ export function InlineEditingDemo() { edit: { onValueCommitted: (row, value) => { if (value !== null && value < "2026-09-01") { - reject(`${row.id}: Expected must be September 2026 or later`); - return; + throw new CellEditError(`${row.id}: Expected must be September 2026 or later`); } update(row.id, { expected: value }, `Expected → ${value ?? "cleared"}`); }, @@ -418,8 +416,7 @@ export function InlineEditingDemo() { edit: { onValueCommitted: (row, value) => { if (value !== null && (value < "2026-10-01" || value > "2027-12-31")) { - reject(`${row.id}: Best before must be October 2026–December 2027`); - return; + throw new CellEditError(`${row.id}: Best before must be October 2026–December 2027`); } update(row.id, { bestBefore: value }, `Best before → ${value ?? "cleared"}`); }, @@ -448,8 +445,7 @@ export function InlineEditingDemo() { edit: { onValueCommitted: (row, value) => { if (value === null) { - reject(`${row.id}: Status is required`); - return; + throw new CellEditError(`${row.id}: Status is required`); } update(row.id, { status: value as LineStatus }, `Status → ${value}`); }, @@ -498,9 +494,10 @@ export function InlineEditingDemo() {

Consumer-owned rules and saving. Editors notify on Enter, Tab, blur, or a - pick. This screen accepts or rejects the value: rejected business values return to the - existing data, without adding cell errors or blocking Tab navigation. Saving has no built-in - indicator, rollback, or leave-page prompt. + pick. Invalid business values throw CellEditError, retaining the draft with a + cell error without trapping Tab navigation. This screen owns saving indicators and + optimistic rollback; saving failures still use its own feedback. There is no leave-page + prompt.

{feedback && (

{ A goods receipt using onValueCommitted — scroll right for every editor. Editable cells are tinted; hover a dropdown or date cell to see its editor icon, then click anywhere in a cell. Enter confirms without moving focus, Tab moves to the next - editable cell, and Esc undoes a draft.{" "} - The screen owns validation and saving: a rejected business value - returns to the current data with a message outside the table; it does not block Tab - navigation or create a cell error. Tick a row to edit Received (whole - numbers from 0 to ordered), try 150 in Discount % (0–100, one - decimal), or more than 500 in Weight (kg) (three decimals).{" "} - Unit price updates locally before saving; values above 10,000 fail - after a delay and are rolled back by this screen. Supplier and Status reject "None"; + editable cell, and Esc closes open error details first, then undoes a draft.{" "} + The screen owns validation and saving: invalid business values throw{" "} + CellEditError, retaining the draft with a cell error without trapping Tab + navigation. Tick a row to edit Received (whole numbers from 0 to + ordered), try 150 in Discount % (0–100, one decimal), or more than + 500 in Weight (kg) (three decimals). Unit price{" "} + updates locally before saving; values above 10,000 fail after a delay, are rolled back + by this screen, and show consumer-owned feedback. Supplier and Status reject "None"; Lot no. rejects empty text. Warehouse, QC, and Note can be cleared. Dates use an unconstrained calendar: Expected rejects days before September 2026, Best before accepts October 2026–December 2027, and Received at adds a time field. There is no @@ -581,6 +582,18 @@ const DataTableLabPage = () => { +

+ Callback failures use a standard cell-error popover by default. Only the Custom code + column supplies error.render; the other columns need no error config. + + } + > + +
+
{ expect(input.closest("td")?.textContent).toContain("ABC"); }); - it("does not keep optimistic cell state or wait for a returned promise", async () => { + it("retains a pending draft without adopting it or blocking focus movement", async () => { let finish = noop; const onValueCommitted = vi.fn(() => new Promise((resolve) => (finish = resolve))); const user = renderTable( @@ -607,10 +607,12 @@ describe("DataTable inline editing", () => { const [first, second] = screen.getAllByRole("textbox", { name: "Received" }); await retype(user, first, "20{Enter}"); expect(onValueCommitted).toHaveBeenCalledTimes(1); - expect((first as HTMLInputElement).value).toBe("12"); + expect((first as HTMLInputElement).value).toBe("20"); expect(document.activeElement).toBe(first); await user.tab(); expect(document.activeElement).toBe(second); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + expect((first as HTMLInputElement).value).toBe("20"); await act(async () => finish()); expect((first as HTMLInputElement).value).toBe("12"); }); diff --git a/packages/core/src/components/data-table/data-table-errors.test.tsx b/packages/core/src/components/data-table/data-table-errors.test.tsx new file mode 100644 index 00000000..4f6ad8ce --- /dev/null +++ b/packages/core/src/components/data-table/data-table-errors.test.tsx @@ -0,0 +1,1049 @@ +import { StrictMode } from "react"; +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { CellEditError, type TextCellEditOptions } from "@/index"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import type { Column } from "./types"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +type Row = { id: string; value: string | null }; +const ROWS: Row[] = [{ id: "1", value: "Original" }]; +const noop = () => {}; + +function Harness({ edit, rows = ROWS }: { edit: TextCellEditOptions; rows?: Row[] }) { + const columns: Column[] = [{ id: "value", label: "Value", type: "text", edit }]; + const table = useDataTable({ columns, data: { rows } }); + return ( + <> + + + + + + + ); +} + +function renderEditor( + edit: TextCellEditOptions, + { locale = "en", rows = ROWS }: { locale?: string; rows?: Row[] } = {}, +) { + const user = userEvent.setup(); + const ui = (nextRows: Row[]) => ( + + + + + + ); + const view = render(ui(rows), { wrapper: createAppShellWrapper(locale) }); + const inputs = screen.getAllByRole("textbox", { name: "Value" }) as HTMLInputElement[]; + return { + ...view, + user, + input: inputs[0], + inputs, + updateRows: (nextRows: Row[]) => view.rerender(ui(nextRows)), + }; +} + +function change(input: HTMLInputElement, value = "Changed") { + act(() => input.focus()); + fireEvent.change(input, { target: { value } }); +} + +function deferred() { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function rejectRequest(request: ReturnType, reason: unknown) { + await act(async () => { + request.reject(reason); + await request.promise.catch(noop); + }); +} + +async function resolveRequest(request: ReturnType) { + await act(async () => { + request.resolve(); + await request.promise; + }); +} + +function renderColumn(column: Column, rows = ROWS) { + function TableHarness() { + const table = useDataTable({ columns: [column], data: { rows } }); + return ( + + + + ); + } + render( + + + , + { wrapper: createAppShellWrapper("en") }, + ); + return userEvent.setup(); +} + +const errorTrigger = () => screen.getByRole("button", { name: "Show error for Value" }); +const queryErrorTrigger = () => screen.queryByRole("button", { name: "Show error for Value" }); + +describe("DataTable callback error presentation", () => { + it("exports CellEditError as a named native error", () => { + const error = new CellEditError("Enter a quantity"); + expect(error).toBeInstanceOf(Error); + expect(error.name).toBe("CellEditError"); + expect(error.message).toBe("Enter a quantity"); + }); + + it.each(["change", "commit"] as const)( + "automatically shows a synchronous %s failure without moving focus", + async (source) => { + const failure = new CellEditError("Cannot accept this value"); + const fail = vi.fn(() => { + throw failure; + }); + const onValueCommitted = source === "commit" ? fail : vi.fn(); + const { input } = renderEditor({ + onValueChange: source === "change" ? fail : undefined, + onValueCommitted, + }); + change(input); + if (source === "commit") fireEvent.keyDown(input, { key: "Enter" }); + + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect( + document.getElementById(input.getAttribute("aria-describedby")!)?.textContent, + ).toContain(failure.message); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(within(popup).getByText(failure.message)).toBeDefined(); + expect(within(popup).getByRole("button", { name: "Close error details" })).toBeDefined(); + expect(document.activeElement).toBe(input); + expect(fail).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + expect(input.value).toBe("Changed"); + expect(input.readOnly).toBe(false); + if (source === "change") { + fireEvent.keyDown(input, { key: "Enter" }); + expect(onValueCommitted).not.toHaveBeenCalled(); + } + }, + ); + + it.each(["change", "commit"] as const)( + "retains the draft while a returned %s promise is pending and after rejection", + async (source) => { + const request = deferred(); + const { input } = renderEditor({ + onValueChange: source === "change" ? () => request.promise : undefined, + onValueCommitted: source === "commit" ? () => request.promise : noop, + }); + change(input); + if (source === "commit") { + fireEvent.keyDown(input, { key: "Enter" }); + expect(input.closest("td")?.getAttribute("aria-busy")).toBe("true"); + } + expect(input.readOnly).toBe(false); + expect(input.value).toBe("Changed"); + expect(queryErrorTrigger()).toBeNull(); + await rejectRequest(request, new CellEditError("Server rejected the value")); + expect(input.value).toBe("Changed"); + expect(input.readOnly).toBe(false); + expect(input.closest("td")?.getAttribute("aria-busy")).toBeNull(); + expect( + within(await screen.findByRole("dialog", { name: "Error in Value" })).getByText( + "Server rejected the value", + ), + ).toBeDefined(); + expect(document.activeElement).toBe(input); + }, + ); + + it("dismisses only the details until a new failure and supports mouse and keyboard reopening", async () => { + const onValueChange = vi.fn(() => { + throw new Error("Draft rejected"); + }); + const onValueCommitted = vi.fn(); + const { input, user, updateRows } = renderEditor({ onValueChange, onValueCommitted }); + change(input); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + await user.click(within(popup).getByRole("button", { name: "Close error details" })); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(queryErrorTrigger()).not.toBeNull(); + updateRows([{ ...ROWS[0] }]); + await user.click(screen.getByRole("button", { name: "Outside" })); + await user.click(input); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueCommitted).not.toHaveBeenCalled(); + + await user.click(errorTrigger()); + const reopened = await screen.findByRole("dialog", { name: "Error in Value" }); + await vi.waitFor(() => + expect(document.activeElement).toBe( + within(reopened).getByRole("button", { name: "Close error details" }), + ), + ); + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + const keyboardPopup = await screen.findByRole("dialog", { name: "Error in Value" }); + const close = within(keyboardPopup).getByRole("button", { name: "Close error details" }); + await vi.waitFor(() => expect(document.activeElement).toBe(close)); + await user.keyboard("{Enter}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + + await user.click(input); + fireEvent.change(input, { target: { value: "New draft" } }); + expect(await screen.findByRole("dialog", { name: "Error in Value" })).toBeDefined(); + expect(document.activeElement).toBe(input); + expect(input.value).toBe("New draft"); + expect(onValueChange).toHaveBeenCalledTimes(2); + expect(onValueCommitted).not.toHaveBeenCalled(); + }); + + it.each(["outside", "next cell"] as const)( + "auto-opens an async failure after moving to %s without stealing focus on open or dismissal", + async (destination) => { + const save = deferred(); + const onValueCommitted = vi.fn(() => save.promise); + const { input, inputs, user } = renderEditor( + { onValueCommitted }, + { rows: [...ROWS, { id: "2", value: "Second" }] }, + ); + const target = + destination === "outside" ? screen.getByRole("button", { name: "Outside" }) : inputs[1]; + change(input); + if (destination === "outside") await user.click(target); + else await user.tab(); + expect(document.activeElement).toBe(target); + await rejectRequest(save, new CellEditError("Save failed")); + expect( + within(await screen.findByRole("dialog", { name: "Error in Value" })).getByText( + "Save failed", + ), + ).toBeDefined(); + expect(document.activeElement).toBe(target); + await user.click(target); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(document.activeElement).toBe(target); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + }, + ); + + it.each(["automatic", "manual"] as const)( + "preserves the outside click destination when dismissing %s details", + async (opening) => { + const onValueCommitted = vi.fn(() => { + throw new CellEditError("Save failed"); + }); + const { input, user } = renderEditor({ onValueCommitted }); + change(input); + await user.keyboard("{Enter}"); + await screen.findByRole("dialog", { name: "Error in Value" }); + if (opening === "manual") { + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await user.click(errorTrigger()); + await screen.findByRole("dialog", { name: "Error in Value" }); + } + + const outside = screen.getByRole("button", { name: "Outside" }); + await user.click(outside); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(document.activeElement).toBe(outside); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + }, + ); + + it("supports custom error content, keyboard inspection, and interactive actions without committing the draft", async () => { + const failure = { code: "INVALID" }; + const onValueCommitted = vi.fn(); + const onAction = vi.fn(); + const renderError = vi.fn(({ row, error }: { row: Row; error: unknown }) => ( + <> +

{`${row.id}: ${(error as typeof failure).code}`}

+ + + )); + const { input, user } = renderEditor({ + onValueChange: () => { + throw failure; + }, + onValueCommitted, + error: { render: renderError }, + }); + change(input); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(document.activeElement).toBe(input); + await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + expect(document.activeElement).toBe(popup); + await user.tab(); + expect(within(popup).getByText("1: INVALID")).toBeDefined(); + expect(renderError).toHaveBeenCalledWith({ row: ROWS[0], error: failure }); + const action = within(popup).getByRole("button", { name: "Explain this error" }); + expect(within(popup).getAllByRole("button")).toEqual([ + action, + within(popup).getByRole("button", { name: "Close error details" }), + ]); + await vi.waitFor(() => expect(document.activeElement).toBe(action)); + await user.tab(); + expect(document.activeElement).toBe( + within(popup).getByRole("button", { name: "Close error details" }), + ); + expect(screen.getByRole("dialog", { name: "Error in Value" })).toBe(popup); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(action); + await user.keyboard("{Enter}"); + expect(onAction).toHaveBeenCalledTimes(1); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(input.value).toBe("Changed"); + + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(input.value).toBe("Changed"); + await user.click(input); + expect(input.value).toBe("Changed"); + expect(onValueCommitted).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Outside" })); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + }); + + it.each([ + new Error("Internal server details"), + new TypeError("Internal type details"), + Object.assign(new Error("Not a CellEditError instance"), { name: "CellEditError" }), + new CellEditError(""), + null, + undefined, + false, + "not an Error", + ])("uses generic visual and accessible feedback for thrown %j", async (failure) => { + const { input } = renderEditor({ + onValueChange: () => { + throw failure; + }, + onValueCommitted: noop, + }); + change(input); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(within(popup).getByText("Could not confirm this change")).toBeDefined(); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toBe( + "Could not confirm this change. Press Alt+ArrowDown for error details", + ); + expect(popup.textContent).toBe("Could not confirm this change"); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + }); + + it.each(["change", "commit"] as const)( + "hides unexpected errors from rejected %s promises", + async (source) => { + const request = deferred(); + const { input } = renderEditor({ + onValueChange: source === "change" ? () => request.promise : undefined, + onValueCommitted: source === "commit" ? () => request.promise : noop, + }); + change(input); + if (source === "commit") fireEvent.keyDown(input, { key: "Enter" }); + await rejectRequest(request, new Error("Internal server details")); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(popup.textContent).toBe("Could not confirm this change"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toBe( + "Could not confirm this change. Press Alt+ArrowDown for error details", + ); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + }, + ); + + it("passes unexpected errors unchanged to custom renderers without leaking them into accessible feedback", async () => { + const failure = new Error("Internal server details"); + const renderError = vi.fn(() =>

Custom feedback

); + const { input } = renderEditor({ + onValueCommitted: () => { + throw failure; + }, + error: { render: renderError }, + }); + change(input); + fireEvent.keyDown(input, { key: "Enter" }); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(popup.textContent).toBe("Custom feedback"); + expect(renderError).toHaveBeenCalledWith({ row: ROWS[0], error: failure }); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toBe( + "Could not confirm this change. Press Alt+ArrowDown for error details", + ); + }); + + it.each([ + { failure: new Error("Internal server details"), message: "変更を確定できませんでした" }, + { failure: new CellEditError("数量を入力してください"), message: "数量を入力してください" }, + ])("localizes popup labels and displays $message", async ({ failure, message }) => { + const { input } = renderEditor( + { + onValueChange: () => { + throw failure; + }, + onValueCommitted: noop, + }, + { locale: "ja" }, + ); + change(input); + const popup = await screen.findByRole("dialog", { name: "Valueのエラー" }); + expect(within(popup).getByText(message)).toBeDefined(); + expect(within(popup).getByRole("button", { name: "エラーの詳細を閉じる" })).toBeDefined(); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toBe( + `${message}. Alt+↓キーでエラーの詳細を表示できます`, + ); + }); + + it("clears an error on a newer successful input and ignores older rejection results", async () => { + const old = deferred(); + const current = deferred(); + const { input } = renderEditor({ + onValueChange: (_row, draft) => { + if (draft === "Older") return old.promise; + if (draft === "Current") return current.promise; + return undefined; + }, + onValueCommitted: noop, + }); + change(input, "Older"); + fireEvent.change(input, { target: { value: "Current" } }); + await rejectRequest(current, new CellEditError("Current failure")); + expect(input.getAttribute("aria-invalid")).toBe("true"); + await rejectRequest(old, new Error("Outdated failure")); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toContain( + "Current failure", + ); + fireEvent.change(input, { target: { value: "Newer" } }); + expect(queryErrorTrigger()).toBeNull(); + expect(input.getAttribute("aria-invalid")).toBeNull(); + }); + + it("does not attach a rejected save to a newer unfinished draft", async () => { + const request = deferred(); + const { input } = renderEditor({ onValueCommitted: () => request.promise }); + change(input); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.change(input, { target: { value: "New draft" } }); + await rejectRequest(request, new Error("Old save failure")); + expect(input.value).toBe("New draft"); + expect(queryErrorTrigger()).toBeNull(); + }); + + it.each(["resolve", "reject"] as const)( + "waits for the latest change check to %s before attempting confirmation", + async (outcome) => { + const check = deferred(); + const onValueCommitted = vi.fn(); + const { input } = renderEditor({ + onValueChange: () => check.promise, + onValueCommitted, + }); + change(input); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(input.value).toBe("Changed"); + expect(input.closest("td")?.getAttribute("aria-busy")).toBe("true"); + expect(onValueCommitted).not.toHaveBeenCalled(); + if (outcome === "resolve") { + await resolveRequest(check); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + expect(input.value).toBe("Original"); + expect(input.readOnly).toBe(true); + expect(queryErrorTrigger()).toBeNull(); + } else { + await rejectRequest(check, new Error("Draft check failed")); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(input.value).toBe("Changed"); + expect(input.readOnly).toBe(false); + expect(input.getAttribute("aria-invalid")).toBe("true"); + } + expect(input.closest("td")?.getAttribute("aria-busy")).toBeNull(); + }, + ); + + it("deduplicates Enter and blur while confirming, then clears the draft only on success", async () => { + const save = deferred(); + const onValueCommitted = vi.fn(() => save.promise); + const { input, user } = renderEditor({ onValueCommitted }); + change(input); + fireEvent.keyDown(input, { key: "Enter" }); + fireEvent.keyDown(input, { key: "Enter" }); + const outside = screen.getByRole("button", { name: "Outside" }); + await user.click(outside); + expect(document.activeElement).toBe(outside); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + expect(input.value).toBe("Changed"); + expect(input.readOnly).toBe(false); + expect(input.closest("td")?.getAttribute("aria-busy")).toBe("true"); + await resolveRequest(save); + expect(input.value).toBe("Original"); + expect(input.readOnly).toBe(true); + expect(input.closest("td")?.getAttribute("aria-busy")).toBeNull(); + expect(document.activeElement).toBe(outside); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + }); + + it("retains an async blur failure through refocus and unchanged blur, then permits an Enter retry", async () => { + const save = deferred(); + let rejected = true; + const onValueCommitted = vi.fn(() => (rejected ? save.promise : undefined)); + const { input, user } = renderEditor({ onValueCommitted }); + const outside = screen.getByRole("button", { name: "Outside" }); + change(input); + await user.click(outside); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + await rejectRequest(save, new CellEditError("Temporary save failure")); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + await user.click(input); + expect(input.getAttribute("aria-invalid")).toBe("true"); + await user.click(outside); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toContain( + "Temporary save failure", + ); + expect(queryErrorTrigger()).not.toBeNull(); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + await user.click(input); + rejected = false; + fireEvent.keyDown(input, { key: "Enter" }); + expect(onValueCommitted).toHaveBeenCalledTimes(2); + expect(onValueCommitted).toHaveBeenLastCalledWith(ROWS[0], "Changed"); + expect(input.value).toBe("Original"); + expect(input.readOnly).toBe(true); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + }); + + it.each([ + { name: "Tab to the next cell", index: 0, shift: false, outside: false }, + { name: "Shift+Tab to the previous cell", index: 1, shift: true, outside: false }, + { name: "Tab out of the table", index: 1, shift: false, outside: true }, + { name: "Shift+Tab out of the table", index: 0, shift: true, outside: true }, + ])( + "retains an async commit failure without retrying on $name", + async ({ index, shift, outside }) => { + const save = deferred(); + const retry = deferred(); + const onValueCommitted = vi + .fn() + .mockImplementationOnce(() => save.promise) + .mockImplementationOnce(() => retry.promise); + const { inputs, user } = renderEditor( + { onValueCommitted }, + { rows: [...ROWS, { id: "2", value: "Second" }] }, + ); + const input = inputs[index]; + change(input); + await user.keyboard("{Enter}"); + await rejectRequest(save, new CellEditError("Save failed")); + await screen.findByRole("dialog", { name: "Error in Value" }); + + await user.tab({ shift }); + expect(document.activeElement).toBe( + outside + ? screen.getByRole("button", { name: shift ? "Before table" : "Outside" }) + : inputs[1 - index], + ); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.closest("td")?.getAttribute("aria-busy")).toBeNull(); + expect(queryErrorTrigger()).not.toBeNull(); + expect( + document.getElementById(input.getAttribute("aria-describedby")!)?.textContent, + ).toContain("Save failed"); + expect(screen.queryByRole("dialog")).toBeNull(); + + await user.click(input); + expect(screen.queryByRole("dialog")).toBeNull(); + await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + const reopened = await screen.findByRole("dialog", { name: "Error in Value" }); + await vi.waitFor(() => expect(reopened.contains(document.activeElement)).toBe(true)); + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(input.closest("td")?.contains(document.activeElement)).toBe(true); + expect(input.value).toBe("Changed"); + await user.click(input); + await user.keyboard("{Enter}"); + expect(onValueCommitted).toHaveBeenCalledTimes(2); + expect(input.closest("td")?.getAttribute("aria-busy")).toBe("true"); + await resolveRequest(retry); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + }, + ); + + it.each(["next cell", "outside"] as const)( + "keeps a synchronous Tab failure's details closed when moving to %s", + async (destination) => { + const onValueCommitted = vi.fn(() => { + throw new CellEditError("Save failed"); + }); + const { input, inputs, user } = renderEditor( + { onValueCommitted }, + { rows: destination === "next cell" ? [...ROWS, { id: "2", value: "Second" }] : ROWS }, + ); + change(input); + + await user.tab(); + expect(document.activeElement).toBe( + destination === "next cell" ? inputs[1] : screen.getByRole("button", { name: "Outside" }), + ); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(queryErrorTrigger()).not.toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); + await user.click(input); + expect(screen.queryByRole("dialog")).toBeNull(); + }, + ); + + it("confirms a changed draft on Tab after an earlier commit failed", async () => { + const save = deferred(); + const onValueCommitted = vi.fn().mockImplementationOnce(() => save.promise); + const { input, inputs, user } = renderEditor( + { onValueCommitted }, + { rows: [...ROWS, { id: "2", value: "Second" }] }, + ); + change(input); + await user.keyboard("{Enter}"); + await rejectRequest(save, new CellEditError("Save failed")); + fireEvent.change(input, { target: { value: "Corrected" } }); + + await user.tab(); + expect(document.activeElement).toBe(inputs[1]); + expect(onValueCommitted).toHaveBeenCalledTimes(2); + expect(onValueCommitted).toHaveBeenLastCalledWith(ROWS[0], "Corrected"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + }); + + it("retains the exact numeric draft rather than its normalized rejected output", () => { + const onValueCommitted = vi.fn(() => { + throw new Error("Quantity rejected"); + }); + renderColumn({ id: "value", label: "Value", type: "number", edit: { onValueCommitted } }, [ + { id: "1", value: "12" }, + ]); + const input = screen.getByRole("textbox", { name: "Value" }) as HTMLInputElement; + change(input, "13.50"); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith({ id: "1", value: "12" }, 13.5); + expect(input.value).toBe("13.50"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.value).toBe("13.50"); + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.value).toBe("12"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + }); + + it("uses Escape to revert a failed draft and error together to the latest consumer data", () => { + const onValueChange = vi.fn(); + const onValueCommitted = vi.fn(() => { + throw new Error("Not accepted"); + }); + const { input, updateRows } = renderEditor({ onValueChange, onValueCommitted }); + change(input); + fireEvent.keyDown(input, { key: "Enter" }); + const latest = { id: "1", value: "Updated externally" }; + updateRows([latest]); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.value).toBe("Changed"); + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.value).toBe("Updated externally"); + expect(input.readOnly).toBe(true); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(onValueChange).toHaveBeenLastCalledWith(latest, "Updated externally"); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + }); + + it("supports keyboard error inspection, focus return, Tab navigation, and Escape cancellation", async () => { + const onValueCommitted = vi.fn(() => { + throw new CellEditError("Not accepted"); + }); + const { input, inputs, user } = renderEditor( + { onValueCommitted }, + { rows: [...ROWS, { id: "2", value: "Second" }] }, + ); + change(input); + await user.keyboard("{Enter}"); + expect(input.getAttribute("aria-keyshortcuts")).toBe("Alt+ArrowDown"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toContain( + "Press Alt+ArrowDown for error details", + ); + await user.keyboard("{Alt>}{ArrowDown}{/Alt}"); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(within(popup).getByText("Not accepted")).toBeDefined(); + expect(document.activeElement).toBe(popup); + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + await vi.waitFor(() => + expect(input.closest("td")?.contains(document.activeElement)).toBe(true), + ); + expect(input.value).toBe("Changed"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + await user.tab(); + expect(document.activeElement).toBe(inputs[1]); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + expect(input.getAttribute("aria-invalid")).toBe("true"); + await user.tab({ shift: true }); + expect(document.activeElement).toBe(input); + expect(screen.queryByRole("dialog")).toBeNull(); + await user.keyboard("{Escape}"); + expect(input.value).toBe("Original"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + }); + + it("clears cancellation errors and ignores pending results after Escape", async () => { + const request = deferred(); + const onValueCommitted = vi.fn(); + const { input } = renderEditor({ + onValueChange: (_row, draft) => { + if (draft === "Original") throw new Error("Even restoring can fail"); + return request.promise; + }, + onValueCommitted, + }); + change(input); + fireEvent.keyDown(input, { key: "Escape" }); + await rejectRequest(request, new Error("Cancelled failure")); + expect(input.value).toBe("Original"); + expect(queryErrorTrigger()).toBeNull(); + expect(onValueCommitted).not.toHaveBeenCalled(); + }); + + it("discards errors on unmount without leaking a late rejection into another cell instance", async () => { + const request = deferred(); + const view = renderEditor({ onValueChange: () => request.promise, onValueCommitted: noop }); + change(view.input); + view.unmount(); + const { input } = renderEditor({ onValueCommitted: noop }); + await rejectRequest(request, new Error("Unmounted failure")); + expect(input.value).toBe("Original"); + expect(queryErrorTrigger()).toBeNull(); + }); + + it("keeps failed drafts cell-local and allows Tab navigation without confirming them", () => { + const onValueCommitted = vi.fn(); + const { inputs } = renderEditor( + { + onValueChange: () => { + throw new Error("Draft failure"); + }, + onValueCommitted, + }, + { rows: [...ROWS, { id: "2", value: "Second" }] }, + ); + change(inputs[0]); + expect(inputs[0].getAttribute("aria-invalid")).toBe("true"); + expect(inputs[1].getAttribute("aria-invalid")).toBeNull(); + fireEvent.keyDown(inputs[0], { key: "Tab" }); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(document.activeElement).toBe(inputs[1]); + expect(inputs[0].value).toBe("Changed"); + expect(inputs[0].getAttribute("aria-invalid")).toBe("true"); + expect(queryErrorTrigger()).not.toBeNull(); + act(() => inputs[0].focus()); + expect(inputs[0].value).toBe("Changed"); + expect(inputs[0].getAttribute("aria-invalid")).toBe("true"); + }); + + it.each(["text", "badge"] as const)( + "retains a rejected %s choice through error inspection until cancellation", + async (type) => { + const onValueCommitted = vi.fn(() => { + throw new CellEditError("Choice rejected"); + }); + const user = renderColumn({ + id: "value", + label: "Value", + type, + edit: { + options: [ + { value: "Original", label: "Original" }, + { value: "Changed", label: "Changed" }, + ], + onValueCommitted, + }, + }); + const input = screen.getByRole("combobox", { name: "Value" }) as HTMLInputElement; + await user.click(input); + await user.click(await screen.findByRole("option", { name: "Changed" })); + expect(within(await screen.findByRole("dialog")).getByText("Choice rejected")).toBeDefined(); + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(input.value).toBe("Changed"); + expect(input.readOnly).toBe(false); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: "Escape" }); + expect(input.value).toBe("Original"); + expect(input.readOnly).toBe(true); + expect(queryErrorTrigger()).toBeNull(); + }, + ); + + it("does not confirm a selected choice until its async change check succeeds", async () => { + const check = deferred(); + const onValueCommitted = vi.fn(); + const user = renderColumn({ + id: "value", + label: "Value", + type: "text", + edit: { + options: [ + { value: "Original", label: "Original" }, + { value: "Changed", label: "Changed" }, + ], + onValueChange: () => check.promise, + onValueCommitted, + }, + }); + const input = screen.getByRole("combobox", { name: "Value" }) as HTMLInputElement; + await user.click(input); + await user.click(await screen.findByRole("option", { name: "Changed" })); + expect(input.value).toBe("Changed"); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(input.closest("td")?.getAttribute("aria-busy")).toBe("true"); + await resolveRequest(check); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + expect(input.value).toBe("Original"); + expect(input.readOnly).toBe(true); + expect(input.closest("td")?.getAttribute("aria-busy")).toBeNull(); + }); + + it("retains a failed date candidate outside its calendar until the edit is reverted", async () => { + const onValueCommitted = vi.fn(() => { + throw new Error("Date rejected"); + }); + const user = renderColumn( + { + id: "value", + label: "Value", + type: "date", + edit: { onValueCommitted }, + }, + [{ id: "1", value: "2026-10-02" }], + ); + const trigger = screen.getByRole("button", { name: /^Value/ }); + const initialLabel = trigger.getAttribute("aria-label"); + await user.click(trigger); + const calendar = document.querySelector('[data-slot="data-table-cell-calendar"]')!; + const day = within(calendar) + .getAllByRole("button") + .find((button) => button.textContent === "20" && !button.dataset.outsideMonth)!; + await user.click(day); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith( + { id: "1", value: "2026-10-02" }, + "2026-10-20", + ); + await user.click(document.body); + expect(trigger.getAttribute("aria-label")).toBe("Value, 2026-10-20"); + expect(trigger.closest("td")?.textContent).toContain("2026-10-20"); + expect(trigger.getAttribute("aria-invalid")).toBe("true"); + await user.click(errorTrigger()); + await screen.findByRole("dialog", { name: "Error in Value" }); + await user.keyboard("{Escape}"); + await vi.waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(trigger.getAttribute("aria-label")).toBe("Value, 2026-10-20"); + fireEvent.keyDown(trigger, { key: "Escape" }); + expect(trigger.getAttribute("aria-label")).toBe(initialLabel); + expect(trigger.getAttribute("aria-invalid")).toBeNull(); + expect(queryErrorTrigger()).toBeNull(); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + }); + + it("does not bypass a failed date change check by clearing the same draft twice", async () => { + const onValueChange = vi.fn(() => { + throw new Error("A date is required"); + }); + const onValueCommitted = vi.fn(); + const user = renderColumn( + { id: "value", label: "Value", type: "date", edit: { onValueChange, onValueCommitted } }, + [{ id: "1", value: "2026-10-02" }], + ); + await user.click(screen.getByRole("button", { name: /^Value/ })); + const clear = screen.getByRole("button", { name: "Clear" }); + await user.click(clear); + await user.click(clear); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueCommitted).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Value, —" }).getAttribute("aria-invalid")).toBe( + "true", + ); + }); + + it("waits for the date change check when the same Clear action is repeated", async () => { + const check = deferred(); + const onValueChange = vi.fn(() => check.promise); + const onValueCommitted = vi.fn(); + const user = renderColumn( + { id: "value", label: "Value", type: "date", edit: { onValueChange, onValueCommitted } }, + [{ id: "1", value: "2026-10-02" }], + ); + await user.click(screen.getByRole("button", { name: /^Value/ })); + const clear = screen.getByRole("button", { name: "Clear" }); + await user.click(clear); + await user.click(clear); + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueCommitted).not.toHaveBeenCalled(); + await resolveRequest(check); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith( + { id: "1", value: "2026-10-02" }, + null, + ); + }); + + it("does not duplicate a pending date confirmation when Clear is repeated", async () => { + const request = deferred(); + const onValueCommitted = vi.fn(() => request.promise); + const user = renderColumn( + { id: "value", label: "Value", type: "date", edit: { onValueCommitted } }, + [{ id: "1", value: "2026-10-02" }], + ); + await user.click(screen.getByRole("button", { name: /^Value/ })); + const clear = screen.getByRole("button", { name: "Clear" }); + await user.click(clear); + await user.click(clear); + expect(onValueCommitted).toHaveBeenCalledTimes(1); + await rejectRequest(request, new Error("Clear failed")); + expect(screen.getByRole("button", { name: "Value, —" }).getAttribute("aria-invalid")).toBe( + "true", + ); + }); + + it("preserves a date-time draft while inspecting its error and clears it on cancellation", async () => { + const onValueCommitted = vi.fn(); + const user = renderColumn({ + id: "value", + label: "Value", + type: "date", + typeOptions: { dateFormat: "datetime" }, + edit: { + onValueChange: () => { + throw new CellEditError("Time rejected"); + }, + onValueCommitted, + }, + }); + await user.click(screen.getByRole("button", { name: /^Value/ })); + const time = screen.getByLabelText("Value (Choose time)") as HTMLInputElement; + fireEvent.change(time, { target: { value: "14:30" } }); + const popup = await screen.findByRole("dialog", { name: "Error in Value" }); + expect(within(popup).getByText("Time rejected")).toBeDefined(); + expect(time.isConnected).toBe(true); + expect(time.value).toBe("14:30"); + expect(onValueCommitted).not.toHaveBeenCalled(); + await user.keyboard("{Escape}"); + await vi.waitFor(() => + expect(screen.queryByRole("dialog", { name: "Error in Value" })).toBeNull(), + ); + expect(time.value).toBe("14:30"); + await user.click(time); + await user.keyboard("{Escape}"); + expect(queryErrorTrigger()).toBeNull(); + expect(onValueCommitted).not.toHaveBeenCalled(); + }); + + it("finishes eagerly and leaves promise failures to the consumer when error:false", async () => { + const request = deferred(); + const handled = request.promise.catch(noop); + const onValueCommitted = vi.fn(() => request.promise); + const { input } = renderEditor({ onValueCommitted, error: false }); + change(input); + fireEvent.keyDown(input, { key: "Enter" }); + expect(onValueCommitted).toHaveBeenCalledExactlyOnceWith(ROWS[0], "Changed"); + expect(input.value).toBe("Original"); + expect(input.readOnly).toBe(true); + expect(input.closest("td")?.getAttribute("aria-busy")).toBeNull(); + await act(async () => { + request.reject(new Error("Consumer-owned failure")); + await handled; + }); + expect(queryErrorTrigger()).toBeNull(); + }); +}); + +it("types error:false and custom content without empty, true, presentation, or cellContent options", () => { + const disabled: TextCellEditOptions = { onValueCommitted: noop, error: false }; + const custom: TextCellEditOptions = { + onValueCommitted: async (_row, value) => { + expectTypeOf(value).toEqualTypeOf(); + }, + error: { + render: (ctx) => { + expectTypeOf(ctx.row).toEqualTypeOf(); + expectTypeOf(ctx.error).toEqualTypeOf(); + // @ts-expect-error — inline presentation is not part of this API + expect(ctx.cellContent).toBeUndefined(); + return

Custom error

; + }, + }, + }; + const empty: TextCellEditOptions = { + onValueCommitted: noop, + // @ts-expect-error — custom settings require render + error: {}, + }; + const enabled: TextCellEditOptions = { + onValueCommitted: noop, + // @ts-expect-error — standard errors are enabled by default, not error:true + error: true, + }; + const presentation: TextCellEditOptions = { + onValueCommitted: noop, + error: { + // @ts-expect-error — only popover presentation is supported, with no type option + type: "inline", + render: () => "ERROR", + }, + }; + expect(disabled.error).toBe(false); + expect(custom.error).toBeDefined(); + expect(empty.error).toEqual({}); + expect(enabled.error).toBe(true); + expect(presentation.error).toBeDefined(); +}); diff --git a/packages/core/src/components/data-table/editable-cell.tsx b/packages/core/src/components/data-table/editable-cell.tsx index b3a3ad56..4d1847e2 100644 --- a/packages/core/src/components/data-table/editable-cell.tsx +++ b/packages/core/src/components/data-table/editable-cell.tsx @@ -1,6 +1,7 @@ import { useCallback, useId, + useLayoutEffect, useMemo, useRef, useState, @@ -10,12 +11,13 @@ import { type KeyboardEvent, type MouseEvent, type ReactNode, + type RefObject, type TouchEvent, } from "react"; import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; import { Popover } from "@base-ui/react/popover"; import type { CalendarDate } from "@internationalized/date"; -import { CalendarDays, ChevronDown } from "lucide-react"; +import { CalendarDays, ChevronDown, CircleAlert, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { isComposing } from "@/lib/is-composing"; import { useTimeZone } from "@/contexts/appshell-context"; @@ -28,6 +30,7 @@ import { Table } from "@/components/table"; import { Tooltip } from "@/components/tooltip"; import type { Column, DateTimeDraft } from "./types"; import { getCellValue, renderTypedValue } from "./cell-renderers"; +import { CellEditError } from "./cell-edit-error"; import { normalizeNumberText, parseNumberText, @@ -43,6 +46,7 @@ import { } from "./cell-edit"; import type { CellEditNavigation } from "./use-cell-edit-navigation"; import { useDataTableT } from "./i18n"; +import { useCellEditSession, type CellEditFailure } from "./use-cell-edit-session"; /** * A column whose cells can be edited in place. @@ -124,9 +128,9 @@ const CELL_FOCUS_CLASS_NAME = cn( "astw:focus-visible:ring-2 astw:focus-visible:ring-inset astw:focus-visible:ring-primary", ); -// Only malformed numeric input is handled here; business errors belong to the consumer. +// Parsing and callback errors share the cell's error outline. const CELL_ERROR_CLASS_NAME = - "astw:ring-2 astw:ring-inset astw:ring-destructive astw:focus:ring-destructive astw:focus-visible:ring-destructive"; + "astw:ring-[1.5px] astw:ring-inset astw:ring-danger-solid astw:focus:ring-[1.5px] astw:focus:ring-danger-solid astw:focus-visible:ring-[1.5px] astw:focus-visible:ring-danger-solid"; // An editable cell says so at rest with a subtle tint instead of an icon. It's // a background *image* layered over whatever colour the cell already has (the @@ -169,9 +173,38 @@ const CELL_ICON_CLASS_NAME = cn( "astw:[&_svg]:size-3.5", ); +interface CellErrorUI { + failure: CellEditFailure; + id: string; + message: string; + content: ReactNode; + triggerRef: RefObject; + popupRef: RefObject; + containsTarget: (target: EventTarget | null) => boolean; +} + +// The standard popover body: the alert icon makes it read as an error rather +// than a tooltip. +function DefaultCellErrorContent({ message }: { message: string }) { + return ( +
+ {/* One line-height tall, so the icon centres on the first line of text. */} + + +

{message}

+
+ ); +} + function useCellValue>( row: TRow, col: EditableColumn, + failure: CellEditFailure | undefined, dateRenderer?: (row: TRow) => ReactNode, ) { const { value: timeZone } = useTimeZone(); @@ -180,13 +213,43 @@ function useCellValue>( const display = render ? render(row) : renderTypedValue(row, col, current, { linkAsText: true, timeZone }); - const change = (value: unknown) => { - (col.edit?.onValueChange as ((row: TRow, value: unknown) => void) | undefined)?.(row, value); - }; - const commit = (value: unknown) => { - (col.edit?.onValueCommitted as ((row: TRow, value: unknown) => void) | undefined)?.(row, value); - }; - return { current, display, change, commit }; + const t = useDataTableT(); + const errorId = useId(); + const triggerRef = useRef(null); + const popupRef = useRef(null); + const containsErrorTarget = (target: EventTarget | null) => + target instanceof Node && + !!(triggerRef.current?.contains(target) || popupRef.current?.contains(target)); + const change = (value: unknown) => + (col.edit?.onValueChange as ((row: TRow, value: unknown) => unknown) | undefined)?.(row, value); + const commit = (value: unknown) => + (col.edit?.onValueCommitted as ((row: TRow, value: unknown) => unknown) | undefined)?.( + row, + value, + ); + const errorConfig = col.edit?.error; + const renderError = errorConfig && errorConfig.render; + const message = + failure?.error instanceof CellEditError && failure.error.message + ? failure.error.message + : t("editCallbackError"); + const callbackError: CellErrorUI | undefined = + failure && failure.source !== "parse" + ? { + failure, + id: errorId, + message: renderError ? t("editCallbackError") : message, + content: renderError ? ( + renderError({ row, error: failure.error }) + ) : ( + + ), + triggerRef, + popupRef, + containsTarget: containsErrorTarget, + } + : undefined; + return { current, display, change, commit, callbackError, containsErrorTarget }; } /** @@ -218,6 +281,12 @@ function EditableCellFrame({ icon, tooltipLabel, editing, + callbackError, + label, + onEditorLeave, + onEditorCancel, + getFailure, + confirming, children, }: { cellProps: ComponentProps; @@ -225,21 +294,94 @@ function EditableCellFrame({ truncate?: boolean; tooltipLabel?: string; editing: boolean; + callbackError?: CellErrorUI; + label: string; + onEditorLeave?: () => void; + onEditorCancel: () => void; + getFailure: () => CellEditFailure | undefined; + confirming: boolean; /** Typed-into cells hide the display while the input shows the text. */ hideDisplay?: "focused" | "forced-colors"; /** Shown on hover or focus; the cell's tint marks editability at rest. */ icon?: ReactNode; children: ReactNode; }) { + const t = useDataTableT(); const [tooltipOpen, setTooltipOpen] = useState(false); - const showTooltip = tooltipLabel !== undefined && !editing; + const [dismissedFailure, setDismissedFailure] = useState(null); + const [manuallyOpenedFailure, setManuallyOpenedFailure] = useState(null); + const [navigationDismissedFailure, setNavigationDismissedFailure] = + useState(null); + const errorActionsRef = useRef(null); + const tabNavigationRef = useRef(false); + const errorOpen = !!callbackError && dismissedFailure !== callbackError.failure; + const showTooltip = tooltipLabel !== undefined && !editing && !callbackError; + const dismissForNavigation = () => { + const failure = getFailure(); + if (!failure || failure.source === "parse") return; + setNavigationDismissedFailure(failure); + setDismissedFailure(failure); + }; + useLayoutEffect(() => { + // Remove closing focus guards before native Tab chooses its destination. + if (!errorOpen && navigationDismissedFailure === callbackError?.failure) + errorActionsRef.current?.unmount(); + }, [errorOpen, callbackError?.failure, navigationDismissedFailure]); const cell = ( { + if ( + event.currentTarget.contains(event.relatedTarget) || + callbackError?.containsTarget(event.relatedTarget) + ) + return; + if (tabNavigationRef.current) dismissForNavigation(); + onEditorLeave?.(); + }} + onKeyDown={(event) => { + if ( + event.key === "Tab" && + !event.defaultPrevented && + event.currentTarget.contains(event.target as Node) + ) + dismissForNavigation(); + tabNavigationRef.current = false; + }} + onKeyDownCapture={(event) => { + tabNavigationRef.current = + event.key === "Tab" && event.currentTarget.contains(event.target as Node); + if ( + callbackError && + event.key === "Escape" && + !callbackError.popupRef.current?.contains(event.target as Node) + ) { + event.preventDefault(); + event.stopPropagation(); + if (errorOpen) setDismissedFailure(callbackError.failure); + else onEditorCancel(); + return; + } + if (callbackError && event.altKey && event.key === "ArrowDown") { + event.preventDefault(); + event.stopPropagation(); + setNavigationDismissedFailure(null); + setManuallyOpenedFailure(callbackError.failure); + setDismissedFailure(null); + if (errorOpen) callbackError.popupRef.current?.focus({ preventScroll: true }); + } + }} // Editing a cell must never fire `onClickRow`. onClick={(event) => event.stopPropagation()} > @@ -247,7 +389,7 @@ function EditableCellFrame({ aria-hidden="true" className={cn( "astw:block", - icon !== undefined && ICON_SPACE_CLASS_NAME, + (icon !== undefined || callbackError) && ICON_SPACE_CLASS_NAME, truncate && "astw:truncate", // Transparent input text is forced visible in forced-colors mode; // hide the display instead so the two don't overlap. @@ -258,7 +400,76 @@ function EditableCellFrame({ {display} {children} - {icon !== undefined && ( + {callbackError && ( + <> + + {callbackError.message}. {t("editErrorHint")} + + { + setDismissedFailure(open ? null : callbackError.failure); + if (open) { + setNavigationDismissedFailure(null); + setManuallyOpenedFailure(callbackError.failure); + } + }} + > + event.stopPropagation()} + > + + + {/* Opens directly under the cell, not under the small icon trigger. */} + callbackError.triggerRef.current?.closest("td") ?? null} + sideOffset={2} + side="bottom" + align="start" + > + event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") event.stopPropagation(); + }} + > +
+
{callbackError.content}
+ } + > + +
+
+
+
+
+ + )} + {icon !== undefined && !callbackError && ( @@ -309,11 +520,12 @@ export function DataTableEditableCell>( props: DataTableEditableCellProps, ) { const { col } = props; - if (col.type === "date") return ; + const mode = col.edit?.error === false ? "unhandled" : "handled"; + if (col.type === "date") return ; if (col.type === "badge" || ((col.type === "text" || col.type === "link") && col.edit?.options)) { - return ; + return ; } - return ; + return ; } // ── Typing: text, number, money, link ─────────────────────────────────────── @@ -331,21 +543,18 @@ function TypingEditCell>({ }: DataTableEditableCellProps) { const t = useDataTableT(); const errorId = useId(); - const { current, display, change, commit } = useCellValue(row, col); + const session = useCellEditSession(col.edit?.error !== false); + const { state } = session; + const failure = state.status === "failed" ? state : undefined; + const { current, display, change, commit, callbackError, containsErrorTarget } = useCellValue( + row, + col, + failure, + ); const register = useNavigationRef(navigation, rowKey, colKey); - - 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(draft); - const setDraft = (next: string | null) => { - draftRef.current = next; - setDraftState(next); - }; + const editing = state.status !== "idle"; const [focused, setFocused] = useState(false); - const [editing, setEditing] = useState(false); const [hovered, setHovered] = useState(false); - const [showParseError, setShowParseError] = useState(false); const composingRef = useRef(false); const selectOnMouseUpRef = useRef(false); @@ -353,27 +562,27 @@ function TypingEditCell>({ const baselineText = numeric ? toNumberEditText(toNumberValue(current)) : (toTextValue(current) ?? ""); - const text = draft ?? baselineText; - const malformed = - numeric && draft !== null && parseNumberText(normalizeNumberText(draft)) === undefined; - const message = showParseError && malformed ? t("editNotANumber") : undefined; + const text = state.status === "idle" ? baselineText : state.draft; + const message = failure?.source === "parse" ? t("editNotANumber") : undefined; const revert = (notify = false) => { - const previous = draftRef.current; - setDraft(null); - setShowParseError(false); - if (notify && previous !== null && previous !== baselineText) change(baselineText); + const previous = session.getState(); + session.cancel( + notify && previous.status !== "idle" && previous.draft !== baselineText + ? () => change(baselineText) + : undefined, + ); }; - // Clear the draft before notifying: moving focus immediately fires blur. const commitDraft = (): boolean => { - const draftText = draftRef.current; - if (draftText === null) return true; + const active = session.getState(); + if (active.status === "idle" || active.status === "confirming") return true; + const draftText = active.draft; const value = numeric ? parseNumberText(normalizeNumberText(draftText)) : toTextValue(draftText); if (value === undefined) { - setShowParseError(true); + session.fail(undefined, "parse"); return false; } const unchanged = @@ -381,16 +590,15 @@ function TypingEditCell>({ (numeric ? sameNumber(value as number | null, toNumberValue(current)) : value === toTextValue(current)); - revert(); - if (!unchanged) commit(value); + session.confirm(() => (unchanged ? undefined : commit(value))); return true; }; const handleChange = (event: ChangeEvent) => { const next = event.target.value; - const previous = draftRef.current ?? baselineText; - setDraft(next); - if (next !== previous) change(next); + const active = session.getState(); + const previous = active.status === "idle" ? baselineText : active.draft; + if (next !== previous) session.change(next, () => change(next)); }; // Keep IME output raw until cell confirmation, just like other draft changes. @@ -405,44 +613,50 @@ function TypingEditCell>({ switch (event.key) { case "Enter": event.preventDefault(); - if (!editing) { - setDraft(baselineText); - setEditing(true); + if (session.getState().status === "idle") { + session.begin(baselineText); event.currentTarget.select(); - } else if (commitDraft()) { - setEditing(false); + } else { + commitDraft(); } return; - case "Tab": - if (!commitDraft()) { + case "Tab": { + const active = session.getState(); + const failedCommit = active.status === "failed" && active.source === "commit"; + if (!failedCommit && !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) return; + if (session.getState().status === "idle") return; // Handled here, so an enclosing dialog doesn't also close on this Esc. event.preventDefault(); event.stopPropagation(); revert(true); - setEditing(false); return; } }; const handleFocus = (event: FocusEvent) => { + const withinCell = focused || containsErrorTarget(event.relatedTarget); setFocused(true); - setEditing(true); - setDraft(baselineText); + if (withinCell) return; + session.begin(baselineText); event.currentTarget.select(); }; - // Malformed text cannot be emitted as a number; blur restores consumer data. const handleBlur = () => { setFocused(false); - setEditing(false); + const active = session.getState(); + if (active.status === "failed") { + // Preserve callback failures; malformed numeric blur still cancels the edit. + if (active.source === "parse") revert(true); + return; + } if (!commitDraft()) revert(true); }; @@ -453,8 +667,7 @@ function TypingEditCell>({ event.preventDefault(); return; } - setDraft(baselineText); - setEditing(true); + session.begin(baselineText); if (focused) event.currentTarget.select(); selectOnMouseUpRef.current = true; }; @@ -483,6 +696,12 @@ function TypingEditCell>({ hideDisplay={editing ? "focused" : "forced-colors"} tooltipLabel={tooltipLabel} editing={editing} + callbackError={callbackError} + label={label} + onEditorLeave={handleBlur} + onEditorCancel={() => revert(true)} + getFailure={session.getFailure} + confirming={state.status === "confirming"} > {message !== undefined && ( @@ -498,8 +717,9 @@ function TypingEditCell>({ value={text} readOnly={!editing} aria-label={label} - aria-invalid={message !== undefined ? true : undefined} - aria-describedby={message !== undefined ? errorId : undefined} + aria-invalid={message !== undefined || callbackError ? true : undefined} + aria-describedby={message !== undefined ? errorId : callbackError?.id} + aria-keyshortcuts={callbackError ? "Alt+ArrowDown" : undefined} inputMode={numeric ? "text" : undefined} enterKeyHint="done" autoComplete="off" @@ -509,13 +729,13 @@ function TypingEditCell>({ !editing && "astw:text-transparent", numeric && "astw:tabular-nums", align === "right" && "astw:text-right", - message !== undefined && CELL_ERROR_CLASS_NAME, + (message !== undefined || callbackError) && CELL_ERROR_CLASS_NAME, + callbackError && ICON_INPUT_CLASS_NAME, )} style={editing ? { WebkitTouchCallout: "default" } : undefined} onChange={handleChange} onKeyDown={handleKeyDown} onFocus={handleFocus} - onBlur={handleBlur} onCompositionStart={() => { composingRef.current = true; }} @@ -560,6 +780,8 @@ interface Choice { label: string; } +type ChoiceDraft = Choice & { notified?: boolean }; + // Case- and width-insensitive searching: full-width letters and half-width // katakana match their usual forms. const searchable = (text: string) => text.normalize("NFKC").toLocaleLowerCase().trim(); @@ -580,7 +802,13 @@ function ChoiceEditCell>({ tooltipLabel, }: DataTableEditableCellProps>) { const t = useDataTableT(); - const { current, display, change, commit } = useCellValue(row, col); + const session = useCellEditSession(col.edit?.error !== false); + const { state } = session; + const { current, display, change, commit, callbackError, containsErrorTarget } = useCellValue( + row, + col, + state.status === "failed" ? state : undefined, + ); const register = useNavigationRef(navigation, rowKey, colKey); const value = toTextValue(current); const noneLabel = t("editNone"); @@ -599,12 +827,8 @@ function ChoiceEditCell>({ const selected = items.find((item) => item.value === value) ?? null; const [open, setOpen] = useState(false); const [focused, setFocused] = useState(false); - const [editing, setEditingState] = useState(false); - const editingRef = useRef(false); - const setEditing = (next: boolean) => { - editingRef.current = next; - setEditingState(next); - }; + const editing = state.status !== "idle"; + const activeChoice = state.status === "idle" ? selected : state.draft; // The displayed search text is local while editing, even if consumer data changes. const [query, setQuery] = useState(null); // Keep the closing list filtered until its animation completes. @@ -632,27 +856,44 @@ function ChoiceEditCell>({ }, [items, listQuery]); const startEditing = () => { - setEditing(true); + session.begin(selected ?? { value, label: value ?? noneLabel }); setQuery(selected?.label ?? ""); setListQuery(null); highlightedRef.current = undefined; explicitHighlightRef.current = undefined; }; - const finishEditing = () => { - // Clear synchronous state before closing or moving focus can fire blur again. - setEditing(false); + const closeEditor = () => { setQuery(null); explicitHighlightRef.current = undefined; setOpen(false); }; + const cancelEditing = () => { + const active = session.getState(); + session.cancel( + active.status !== "idle" && active.draft.notified && active.draft.value !== value + ? () => change(value) + : undefined, + ); + closeEditor(); + }; + + const leaveEditor = () => { + const active = session.getState(); + if (active.status === "failed" || active.status === "confirming") closeEditor(); + else cancelEditing(); + }; + const pick = (item: Choice) => { - finishEditing(); - if (item.value !== value) { - change(item.value); - commit(item.value); - } + const active = session.getState(); + if (active.status === "confirming" && active.draft.value === item.value) return; + session.change( + { ...item, notified: item.value !== value }, + item.value !== value ? () => change(item.value) : undefined, + ); + setQuery(item.label); + session.confirm(() => (item.value !== value ? commit(item.value) : undefined), closeEditor); }; const explicitCandidate = () => { @@ -679,55 +920,66 @@ function ChoiceEditCell>({ case "Enter": { event.preventDefault(); event.preventBaseUIHandler(); + const active = session.getState(); + if (active.status === "confirming") return; if (!open) { - if (!editingRef.current) startEditing(); + if (active.status === "failed" && active.source === "commit") { + session.confirm( + () => (active.draft.value !== value ? commit(active.draft.value) : undefined), + closeEditor, + ); + return; + } + if (active.status === "idle") startEditing(); setOpen(true); return; } const item = keyboardCandidate(); if (item) pick(item); - else finishEditing(); + else leaveEditor(); return; } case "Tab": { event.preventBaseUIHandler(); const item = - editingRef.current && open + session.getState().status !== "idle" && open ? (explicitCandidate() ?? (searchable(listQuery ?? "") ? keyboardCandidate() : undefined)) : undefined; if (item) pick(item); - else finishEditing(); + else leaveEditor(); if (navigation.move(from, event.shiftKey ? "prev" : "next")) event.preventDefault(); return; } case "Escape": - if (!editingRef.current && !open) return; + if (session.getState().status === "idle" && !open) return; event.preventDefault(); event.stopPropagation(); event.preventBaseUIHandler(); - finishEditing(); + cancelEditing(); return; } }; const handleFocus = (event: FocusEvent) => { + const withinCell = focused || containsErrorTarget(event.relatedTarget); setFocused(true); - if (!editingRef.current) startEditing(); + if (withinCell) return; + if (session.getState().status === "idle") startEditing(); event.currentTarget.select(); }; const handleBlur = () => { setFocused(false); - finishEditing(); + leaveEditor(); }; const handleMouseDown = (event: MouseEvent) => { - if (event.button === 2 && !editingRef.current) { + if (event.button === 2 && session.getState().status === "idle") { event.preventDefault(); return; } - if (!editingRef.current) { + if (session.getState().status === "idle") { startEditing(); setOpen(true); } @@ -741,7 +993,7 @@ function ChoiceEditCell>({ }; const stopWhileEditing = (event: MouseEvent | TouchEvent) => { - if (editingRef.current) event.stopPropagation(); + if (session.getState().status !== "idle") event.stopPropagation(); }; const renderChoice = (item: Choice) => { @@ -762,17 +1014,29 @@ function ChoiceEditCell>({ icon={} tooltipLabel={tooltipLabel} editing={editing} + callbackError={callbackError} + label={label} + onEditorLeave={handleBlur} + onEditorCancel={cancelEditing} + getFailure={session.getFailure} + confirming={state.status === "confirming"} > items={items} filteredItems={filtered} - value={selected} + value={activeChoice} onValueChange={(item, details) => { if (details.reason === "item-press" && item) pick(item); }} - inputValue={query ?? selected?.label ?? ""} + inputValue={query ?? activeChoice?.label ?? ""} onInputValueChange={(next, details) => { if (details.reason === "input-change" || details.reason === "input-clear") { + const active = session.getState(); + session.change( + active.status === "idle" + ? (selected ?? { value, label: value ?? noneLabel }) + : active.draft, + ); setQuery(next); setListQuery(next); explicitHighlightRef.current = undefined; @@ -781,17 +1045,26 @@ function ChoiceEditCell>({ open={open} onOpenChange={(next, details) => { if (next) { - if (!editingRef.current) startEditing(); + if (session.getState().status === "idle") startEditing(); setOpen(true); if (details.reason !== "input-change") setListQuery(null); return; } + if ( + containsErrorTarget(details.event.target) || + ("relatedTarget" in details.event && + containsErrorTarget(details.event.relatedTarget as EventTarget | null)) + ) { + details.cancel(); + return; + } // Clearing search text is not selection or cancellation. if (details.reason === "input-clear") { setOpen(false); return; } - finishEditing(); + if (details.reason === "escape-key") cancelEditing(); + else leaveEditor(); }} onOpenChangeComplete={(isOpen) => { if (!isOpen) setListQuery(null); @@ -809,6 +1082,9 @@ function ChoiceEditCell>({ >({ ICON_INPUT_CLASS_NAME, !editing && "astw:text-transparent astw:cursor-pointer", align === "right" && "astw:text-right", + callbackError && CELL_ERROR_CLASS_NAME, )} onKeyDown={handleKeyDown} onFocus={handleFocus} - onBlur={handleBlur} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onContextMenu={stopWhileEditing} @@ -870,77 +1146,121 @@ function DateEditCell>({ const t = useDataTableT(); const { value: timeZone } = useTimeZone(); const withTime = col.typeOptions?.dateFormat === "datetime"; - const { current, display, change, commit } = useCellValue(row, col, dateRenderer); + const session = useCellEditSession(col.edit?.error !== false); + const { state } = session; + const { current, display, change, commit, callbackError, containsErrorTarget } = useCellValue( + row, + col, + state.status === "failed" ? state : undefined, + dateRenderer, + ); const register = useNavigationRef(navigation, rowKey, colKey); const baseline: DateTimeDraft = { day: toCalendarDate(current, timeZone)?.toString() ?? null, time: toTimeText(current, timeZone) || null, }; const [open, setOpen] = useState(false); - const [draft, setDraftState] = useState(null); - // Close notifications can run before the state update from a pick is rendered. - const draftRef = useRef(null); const differs = (a: DateTimeDraft, b: DateTimeDraft) => a.day !== b.day || (withTime && a.time !== b.time); const notifyDraft = (next: DateTimeDraft) => change(withTime ? { ...next } : next.day); const setDraft = (next: DateTimeDraft) => { - const previous = draftRef.current ?? baseline; - draftRef.current = next; - setDraftState(next); - if (differs(previous, next)) notifyDraft(next); + const active = session.getState(); + const previous = active.status === "idle" ? baseline : active.draft; + if (!differs(previous, next)) { + session.begin(next); + return; + } + session.change(next, () => notifyDraft(next)); }; - const discardDraft = () => { - draftRef.current = null; - setDraftState(null); + const cancelEditing = () => { + const active = session.getState(); + session.cancel( + active.status !== "idle" && differs(active.draft, baseline) + ? () => notifyDraft(baseline) + : undefined, + ); + setOpen(false); }; const finish = (next: string | null) => { - discardDraft(); - setOpen(false); - if (!sameDate(next, current, withTime, timeZone)) commit(next); + session.confirm( + () => (sameDate(next, current, withTime, timeZone) ? undefined : commit(next)), + () => setOpen(false), + ); }; - const activeDraft = draft ?? baseline; + const activeDraft = state.status === "idle" ? baseline : state.draft; const pickedDay = toCalendarDate(activeDraft.day, timeZone); const timestamp = withTime && pickedDay && activeDraft.time ? toIsoDateTime(pickedDay, activeDraft.time, timeZone) : undefined; + const draftDisplay = withTime + ? `${activeDraft.day ?? "—"} ${activeDraft.time ?? "—"}` + : (activeDraft.day ?? "—"); + const visibleDisplay = state.status === "idle" ? display : draftDisplay; // The trigger's accessible name carries the date hidden in the visual display. - const accessibleName = typeof display === "string" ? `${label}, ${display}` : label; - - const handleOpenChange = (next: boolean) => { + const accessibleName = typeof visibleDisplay === "string" ? `${label}, ${visibleDisplay}` : label; + + const handleOpenChange = (next: boolean, details?: Popover.Root.ChangeEventDetails) => { + if ( + !next && + details && + (containsErrorTarget(details.event.target) || + ("relatedTarget" in details.event && + containsErrorTarget(details.event.relatedTarget as EventTarget | null))) + ) { + details.cancel(); + return; + } if (next) { - if (draftRef.current === null) { - draftRef.current = baseline; - setDraftState(baseline); - } + session.begin(baseline); } else { - const previous = draftRef.current; - discardDraft(); - if (previous && differs(previous, baseline)) notifyDraft(baseline); + const active = session.getState(); + if ( + details?.reason === "escape-key" || + (active.status !== "failed" && active.status !== "confirming") + ) { + cancelEditing(); + return; + } } setOpen(next); }; const handlePick = (day: CalendarDate) => { - setDraft({ ...(draftRef.current ?? baseline), day: day.toString() }); + const active = session.getState(); + setDraft({ ...(active.status === "idle" ? baseline : active.draft), day: day.toString() }); if (!withTime) finish(day.toString()); }; return ( } tooltipLabel={tooltipLabel} editing={open} + callbackError={callbackError} + label={label} + onEditorCancel={cancelEditing} + getFailure={session.getFailure} + confirming={state.status === "confirming"} > { + if (event.key === "Escape" && !open && session.getState().status !== "idle") { + event.preventDefault(); + event.stopPropagation(); + cancelEditing(); + return; + } if (event.key === "ArrowDown" && !open) { event.preventDefault(); handleOpenChange(true); @@ -970,7 +1290,7 @@ function DateEditCell>({ value={activeDraft.time ?? ""} onChange={(event) => setDraft({ - ...(draftRef.current ?? baseline), + ...activeDraft, time: event.target.value || null, }) } diff --git a/packages/core/src/components/data-table/i18n.ts b/packages/core/src/components/data-table/i18n.ts index 86486189..65fb0a73 100644 --- a/packages/core/src/components/data-table/i18n.ts +++ b/packages/core/src/components/data-table/i18n.ts @@ -139,6 +139,11 @@ export const dataTableLabels = defineI18nLabels({ editNoMatches: "No matches", editClear: "Clear", editDone: "Done", + editCallbackError: "Could not confirm this change", + editShowError: (props: { label: string }) => `Show error for ${props.label}`, + editErrorDialog: (props: { label: string }) => `Error in ${props.label}`, + editCloseError: "Close error details", + editErrorHint: "Press Alt+ArrowDown for error details", }, ja: { loading: "読み込み中...", @@ -261,6 +266,11 @@ export const dataTableLabels = defineI18nLabels({ editNoMatches: "一致する項目がありません", editClear: "クリア", editDone: "完了", + editCallbackError: "変更を確定できませんでした", + editShowError: (props: { label: string }) => `${props.label}のエラーを表示`, + editErrorDialog: (props: { label: string }) => `${props.label}のエラー`, + editCloseError: "エラーの詳細を閉じる", + editErrorHint: "Alt+↓キーでエラーの詳細を表示できます", }, }); diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index d1edeaee..2afcc51f 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -1,6 +1,7 @@ // DataTable compound component export { DataTable, type DataTablePaginationProps, type DataTableRootProps } from "./data-table"; +export { CellEditError } from "./cell-edit-error"; export { useDataTable } from "./use-data-table"; export { useDataTableContext, type DataTableContextValue } from "./data-table-context"; diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 5ce3edd2..6c221591 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -112,16 +112,36 @@ interface CellEditBase, TValue, TDraft = TV * Observes editor drafts, including incomplete input and restored drafts on * cancellation. Direct inputs emit raw strings; choices emit selected values, * not search text or highlights. Updating `data` here is outside the supported - * coordination contract. The return value is ignored. + * coordination contract. A failed change check retains its draft and blocks + * confirmation; pending change checks finish before `onValueCommitted` runs. + * Cancellation notifications cannot create a new cell error after discarding + * the draft. Pass `error: false` for unobserved callback behavior. */ onValueChange?: (row: TRow, draft: TDraft) => void; /** - * Notifies a changed, confirmed editor output, compared with current `data`. - * Empty cells confirm `null`. Confirmation does not imply business validation, - * adoption, or persistence; update `data` here to adopt it. Return values are - * ignored. Output types follow the editor, not the row field's runtime type. + * Attempts to confirm a changed editor output, compared with current `data`. + * Empty cells confirm `null`. A normal return or fulfilled Promise completes + * the edit; throw/rejection retains the draft and error for correction or retry. + * Focus may leave while pending. Update `data` here to adopt a successful value; + * DataTable does not persist or roll back consumer state. Output types follow + * the editor, not the row field's runtime type. */ onValueCommitted: (row: TRow, value: TValue | null) => void; + /** + * Callback failures automatically open an error popover without moving focus. + * Only non-empty `CellEditError` messages are displayed by the standard UI; + * other failures use localized generic feedback. Translate messages before throwing. + * Pass `false` to disable interception and retain eager edit completion, or + * `render` to replace the popover's body. Closing the details retains the draft + * and error; reopen with the error icon or Alt+ArrowDown. Esc closes open details + * first, then restores current data and clears the error. The standard popover + * adds no reset action; custom content may include consumer-owned actions. + */ + error?: + | false + | { + render: (ctx: { row: TRow; error: unknown }) => ReactNode; + }; } /** diff --git a/packages/core/src/components/data-table/use-cell-edit-session.test.tsx b/packages/core/src/components/data-table/use-cell-edit-session.test.tsx new file mode 100644 index 00000000..82977831 --- /dev/null +++ b/packages/core/src/components/data-table/use-cell-edit-session.test.tsx @@ -0,0 +1,350 @@ +import { StrictMode, type ReactNode } from "react"; +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useCellEditSession } from "./use-cell-edit-session"; + +afterEach(cleanup); + +function Wrapper({ children }: { children: ReactNode }) { + return {children}; +} + +function setup(enabled = true) { + return renderHook(() => useCellEditSession(enabled), { wrapper: Wrapper }); +} + +function deferred() { + let resolve!: () => void; + let reject!: (reason: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function settle(request: ReturnType, failure?: { error: unknown }) { + await act(async () => { + if (failure) request.reject(failure.error); + else request.resolve(); + await request.promise.catch(() => undefined); + }); +} + +describe("useCellEditSession", () => { + it("begins only from idle and keeps the synchronous state aligned before rendering", () => { + const { result } = setup(); + const callback = vi.fn(); + act(() => { + result.current.confirm(callback); + result.current.begin("Original"); + expect(result.current.getState()).toEqual({ status: "editing", draft: "Original" }); + result.current.begin("Must not replace the draft"); + }); + expect(callback).not.toHaveBeenCalled(); + expect(result.current.state).toEqual({ status: "editing", draft: "Original" }); + act(() => result.current.cancel()); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("finishes synchronous confirmation synchronously and ignores successful return values", () => { + const { result } = setup(); + const onSuccess = vi.fn(() => { + expect(result.current.getState()).toEqual({ status: "idle" }); + }); + act(() => { + result.current.change("Changed", () => 42); + result.current.confirm(() => false, onSuccess); + expect(result.current.getState()).toEqual({ status: "idle" }); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it.each([new Error("Invalid"), null, undefined, false])( + "retains a draft with a change failure %j and blocks confirmation", + (error) => { + const { result } = setup(); + const commit = vi.fn(); + act(() => { + result.current.change("Invalid draft", () => { + throw error; + }); + result.current.confirm(commit); + }); + expect(commit).not.toHaveBeenCalled(); + expect(result.current.state).toEqual({ + status: "failed", + draft: "Invalid draft", + error, + source: "change", + }); + act(() => result.current.change("Corrected")); + expect(result.current.state).toEqual({ status: "editing", draft: "Corrected" }); + }, + ); + + it("awaits a pending change check before confirming and deduplicates repeated confirmation", async () => { + const change = deferred(); + const commit = deferred(); + const onCommit = vi.fn(() => commit.promise); + const duplicate = vi.fn(); + const onSuccess = vi.fn(); + const { result } = setup(); + act(() => { + result.current.change("Candidate", () => change.promise); + result.current.confirm(onCommit, onSuccess); + result.current.confirm(duplicate, duplicate); + }); + expect(result.current.state).toEqual({ status: "confirming", draft: "Candidate" }); + expect(onCommit).not.toHaveBeenCalled(); + await settle(change); + expect(onCommit).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ status: "confirming", draft: "Candidate" }); + act(() => result.current.confirm(duplicate)); + await settle(commit); + expect(result.current.state).toEqual({ status: "idle" }); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(duplicate).not.toHaveBeenCalled(); + }); + + it("does not confirm after a pending change check rejects", async () => { + const request = deferred(); + const error = new Error("Draft rejected"); + const commit = vi.fn(); + const { result } = setup(); + act(() => { + result.current.change("Candidate", () => request.promise); + result.current.confirm(commit); + }); + await settle(request, { error }); + expect(commit).not.toHaveBeenCalled(); + expect(result.current.state).toEqual({ + status: "failed", + draft: "Candidate", + error, + source: "change", + }); + act(() => result.current.confirm(commit)); + expect(commit).not.toHaveBeenCalled(); + }); + + it("retains a failed commit draft and allows retrying the same value", () => { + const { result } = setup(); + const error = new Error("Save failed"); + const onSuccess = vi.fn(); + act(() => { + result.current.begin("Candidate"); + result.current.confirm(() => { + throw error; + }, onSuccess); + }); + expect(result.current.state).toEqual({ + status: "failed", + draft: "Candidate", + error, + source: "commit", + }); + expect(onSuccess).not.toHaveBeenCalled(); + act(() => result.current.confirm(() => undefined, onSuccess)); + expect(result.current.state).toEqual({ status: "idle" }); + expect(onSuccess).toHaveBeenCalledTimes(1); + }); + + it("retains an asynchronously rejected commit draft instead of returning to idle", async () => { + const request = deferred(); + const { result } = setup(); + const onSuccess = vi.fn(); + act(() => { + result.current.change("Candidate"); + result.current.confirm(() => request.promise, onSuccess); + }); + expect(result.current.state).toEqual({ status: "confirming", draft: "Candidate" }); + await settle(request, { error: undefined }); + expect(result.current.state).toEqual({ + status: "failed", + draft: "Candidate", + error: undefined, + source: "commit", + }); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it.each(["resolve", "reject"] as const)( + "ignores an old commit %s after a newer draft replaces it", + async (outcome) => { + const request = deferred(); + const { result } = setup(); + const onSuccess = vi.fn(); + act(() => { + result.current.change("Older"); + result.current.confirm(() => request.promise, onSuccess); + result.current.change("Newer"); + }); + await settle(request, outcome === "reject" ? { error: new Error("Old failure") } : undefined); + expect(result.current.state).toEqual({ status: "editing", draft: "Newer" }); + expect(onSuccess).not.toHaveBeenCalled(); + }, + ); + + it("does not let an older change result replace the newer draft's failure", async () => { + const old = deferred(); + const currentError = new Error("Current failure"); + const { result } = setup(); + act(() => { + result.current.change("Older", () => old.promise); + result.current.change("Newer", () => { + throw currentError; + }); + }); + await settle(old, { error: new Error("Old failure") }); + expect(result.current.state).toEqual({ + status: "failed", + draft: "Newer", + error: currentError, + source: "change", + }); + }); + + it("keeps parse failures with their draft and invalidates pending change checks", async () => { + const request = deferred(); + const commit = vi.fn(); + const { result } = setup(); + act(() => { + result.current.change("-", () => request.promise); + result.current.fail("Not a number", "parse"); + result.current.confirm(commit); + }); + await settle(request, { error: new Error("Old check failure") }); + expect(result.current.state).toEqual({ + status: "failed", + draft: "-", + error: "Not a number", + source: "parse", + }); + expect(commit).not.toHaveBeenCalled(); + act(() => result.current.cancel()); + expect(result.current.state).toEqual({ status: "idle" }); + act(() => result.current.fail("No draft", "parse")); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("cancels pending confirmation and prevents the waiting commit from starting", async () => { + const request = deferred(); + const commit = vi.fn(); + const { result } = setup(); + act(() => { + result.current.change("Candidate", () => request.promise); + result.current.confirm(commit); + result.current.cancel(); + }); + await settle(request); + expect(result.current.state).toEqual({ status: "idle" }); + expect(commit).not.toHaveBeenCalled(); + }); + + it.each(["sync", "async"] as const)( + "does not recreate an orphan error from a %s restoration failure", + async (mode) => { + const request = deferred(); + const error = new Error("Restoration failed"); + const { result } = setup(); + const restore = vi.fn(() => { + expect(result.current.getState()).toEqual({ status: "idle" }); + if (mode === "sync") throw error; + return request.promise; + }); + act(() => { + result.current.change("Candidate"); + result.current.cancel(restore); + }); + if (mode === "async") await settle(request, { error }); + expect(restore).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ status: "idle" }); + }, + ); + + it("ignores a late save result after cancellation and unmount", async () => { + const request = deferred(); + const onSuccess = vi.fn(); + const view = setup(); + act(() => { + view.result.current.change("Candidate"); + view.result.current.confirm(() => request.promise, onSuccess); + view.result.current.cancel(); + }); + view.unmount(); + await settle(request); + expect(onSuccess).not.toHaveBeenCalled(); + }); + + it("does not start a waiting commit after unmount", async () => { + const request = deferred(); + const commit = vi.fn(); + const view = setup(); + act(() => { + view.result.current.change("Candidate", () => request.promise); + view.result.current.confirm(commit); + }); + view.unmount(); + await settle(request); + expect(commit).not.toHaveBeenCalled(); + }); + + it("supports thenables without making synchronous callbacks asynchronous", async () => { + const { result } = setup(); + await act(async () => { + // eslint-disable-next-line unicorn/no-thenable -- Intentionally tests a foreign thenable. + result.current.change("Candidate", () => ({ then: (resolve: () => void) => resolve() })); + // eslint-disable-next-line unicorn/no-thenable -- Intentionally tests a foreign thenable. + result.current.confirm(() => ({ then: (resolve: () => void) => resolve() })); + }); + expect(result.current.state).toEqual({ status: "idle" }); + }); + + it("preserves callback propagation and eager completion when interception is disabled", async () => { + const { result } = setup(false); + const error = new Error("Consumer-owned failure"); + const onSuccess = vi.fn(() => { + expect(result.current.getState()).toEqual({ status: "idle" }); + }); + act(() => { + expect(() => + result.current.change("Candidate", () => { + throw error; + }), + ).toThrow(error); + }); + expect(result.current.state).toEqual({ status: "editing", draft: "Candidate" }); + act(() => { + expect(() => + result.current.confirm(() => { + throw error; + }, onSuccess), + ).toThrow(error); + }); + expect(onSuccess).toHaveBeenCalledTimes(1); + expect(result.current.state).toEqual({ status: "idle" }); + + const request = deferred(); + const handled = request.promise.catch(() => undefined); + const readThen = vi.fn(() => request.promise.then.bind(request.promise)); + const unobserved = { + // eslint-disable-next-line unicorn/no-thenable -- Any access would violate disabled interception. + get then() { + return readThen(); + }, + }; + act(() => { + result.current.change("Next", () => unobserved); + result.current.confirm(() => unobserved); + }); + expect(readThen).not.toHaveBeenCalled(); + expect(result.current.state).toEqual({ status: "idle" }); + await act(async () => { + request.reject(error); + await handled; + }); + expect(result.current.state).toEqual({ status: "idle" }); + }); +}); diff --git a/packages/core/src/components/data-table/use-cell-edit-session.ts b/packages/core/src/components/data-table/use-cell-edit-session.ts new file mode 100644 index 00000000..0c199140 --- /dev/null +++ b/packages/core/src/components/data-table/use-cell-edit-session.ts @@ -0,0 +1,171 @@ +import { useEffect, useRef, useState } from "react"; + +/** @internal */ +export interface CellEditFailure { + error: unknown; + source: "parse" | "change" | "commit"; +} + +/** @internal */ +export type CellEditSessionState = + | { status: "idle" } + | { status: "editing"; draft: TDraft } + | { status: "confirming"; draft: TDraft } + | ({ status: "failed"; draft: TDraft } & CellEditFailure); + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof (value as PromiseLike | null | undefined)?.then === "function"; +} + +/** Owns the draft and its failure together; consumer data remains authoritative. @internal */ +export function useCellEditSession(enabled: boolean) { + const [state, setState] = useState>({ status: "idle" }); + const { current: session } = useRef({ + state, + revision: 0, + mounted: false, + pendingChange: null as Promise | null, + }); + const transition = (next: CellEditSessionState) => { + session.state = next; + setState(next); + }; + const isCurrent = (revision: number) => session.mounted && session.revision === revision; + + useEffect(() => { + session.mounted = true; + return () => { + session.mounted = false; + session.revision++; + session.pendingChange = null; + }; + }, [enabled, session]); + + const begin = (draft: TDraft) => { + if (session.state.status !== "idle") return; + session.revision++; + transition({ status: "editing", draft }); + }; + + const change = (draft: TDraft, callback?: () => unknown) => { + const revision = ++session.revision; + session.pendingChange = null; + transition({ status: "editing", draft }); + if (!callback) return; + if (!enabled) { + callback(); + return; + } + const reject = (error: unknown) => { + if (isCurrent(revision)) transition({ status: "failed", draft, error, source: "change" }); + }; + try { + const result = callback(); + if (isPromiseLike(result)) { + const pending = Promise.resolve(result).then( + () => { + if (isCurrent(revision) && session.pendingChange === pending) + session.pendingChange = null; + return true; + }, + (error: unknown) => { + if (isCurrent(revision) && session.pendingChange === pending) + session.pendingChange = null; + reject(error); + return false; + }, + ); + if (isCurrent(revision)) session.pendingChange = pending; + } + } catch (error) { + reject(error); + } + }; + + const discard = () => { + session.revision++; + session.pendingChange = null; + transition({ status: "idle" }); + }; + + const confirm = (callback: () => unknown, onSuccess?: () => void) => { + const current = session.state; + if ( + current.status === "idle" || + current.status === "confirming" || + (current.status === "failed" && current.source !== "commit") + ) + return; + if (!enabled) { + discard(); + onSuccess?.(); + callback(); + return; + } + const { draft } = current; + const revision = session.revision; + const pendingChange = session.pendingChange; + transition({ status: "confirming", draft }); + const isConfirming = () => isCurrent(revision) && session.state.status === "confirming"; + const complete = () => { + if (!isConfirming()) return; + transition({ status: "idle" }); + onSuccess?.(); + }; + const reject = (error: unknown) => { + if (isConfirming()) transition({ status: "failed", draft, error, source: "commit" }); + }; + const run = () => { + if (!isConfirming()) return; + try { + const result = callback(); + if (isPromiseLike(result)) { + void Promise.resolve(result).then(complete, reject); + return; + } + } catch (error) { + reject(error); + return; + } + complete(); + }; + if (pendingChange) { + void pendingChange.then((valid) => { + if (valid) run(); + }); + } else { + run(); + } + }; + + const fail = (error: unknown, source: "parse") => { + const current = session.state; + if (current.status === "idle") return; + session.revision++; + session.pendingChange = null; + transition({ status: "failed", draft: current.draft, error, source }); + }; + + const cancel = (callback?: () => unknown) => { + discard(); + if (!callback) return; + if (!enabled) { + callback(); + return; + } + // Restoration failures have no draft to attach to and must not recreate an orphan error. + try { + const result = callback(); + if (isPromiseLike(result)) void Promise.resolve(result).catch(() => {}); + } catch { + // The edit has already been cancelled. + } + }; + + const getFailure = () => { + const current = session.state; + return current.status === "failed" ? current : undefined; + }; + + return { state, getState: () => session.state, getFailure, begin, change, confirm, fail, cancel }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0caefba4..7abbb0f7 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -304,6 +304,7 @@ export { aggregations, type Aggregation } from "./lib/aggregations"; // DataTable export { + CellEditError, DataTable, useDataTable, useDataTableContext,