diff --git a/docs-manifest.json b/docs-manifest.json index 4cb82d450..cefa4cb8e 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -405,6 +405,7 @@ "sources": [ "packages/core/src/components/data-table/**", "packages/core/src/components/internals/table-core/column-types.ts", + "packages/core/src/components/internals/table-core/cell-edit-error.ts", "packages/core/src/hooks/use-collection-variables.ts", "packages/core/src/lib/collection-url-state.ts", "packages/core/src/contexts/collection-control-context.tsx", @@ -475,7 +476,7 @@ ], "hashes": { "typeSurface": "33eb2bdd6be1c774", - "outline": "3543772d5bd1ec9a", + "outline": "36328fa06e4b4612", "snapshot": "a97e067c66e46ef6", "outputMd": "7133811af89213ef", "examples": "e7100d7517f72495" @@ -549,10 +550,10 @@ "claims": [], "symbols": ["I18nLabels", "defineI18nLabels"], "hashes": { - "typeSurface": "38a1d271bf54751a", + "typeSurface": "cd254008bb9d09a5", "outline": "9a2fa5d9f1b2af3f", "snapshot": null, - "outputMd": "3035dea9ac31b7f5", + "outputMd": "d5e44472b7a0d2ff", "examples": "e477bcbfa866cfda" } }, @@ -1823,7 +1824,7 @@ "packages/core/skills/app-shell-patterns/references/api/aggregations.md": "daf6c93e7285ee88", "packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "ed0db6372d25e2f9", "packages/core/skills/app-shell-patterns/references/api/create-typed-paths.md": "4c3a858d3eb02a2a", - "packages/core/skills/app-shell-patterns/references/api/define-i18n-labels.md": "3035dea9ac31b7f5", + "packages/core/skills/app-shell-patterns/references/api/define-i18n-labels.md": "d5e44472b7a0d2ff", "packages/core/skills/app-shell-patterns/references/api/define-module.md": "75361d7687b43878", "packages/core/skills/app-shell-patterns/references/api/define-resource.md": "27e4d84b41cf40e0", "packages/core/skills/app-shell-patterns/references/api/guards/hidden.md": "dda25877f7e40b82", diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 2596beeb9..3bbfcd8e2 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -6,6 +6,7 @@ description: Compound data table component with sortable columns, filter chips, sources: - packages/core/src/components/data-table/** - packages/core/src/components/internals/table-core/column-types.ts + - packages/core/src/components/internals/table-core/cell-edit-error.ts - packages/core/src/hooks/use-collection-variables.ts - packages/core/src/lib/collection-url-state.ts - packages/core/src/contexts/collection-control-context.tsx diff --git a/docs/api/define-i18n-labels.md b/docs/api/define-i18n-labels.md index d6556f97a..7255bc280 100644 --- a/docs/api/define-i18n-labels.md +++ b/docs/api/define-i18n-labels.md @@ -165,7 +165,7 @@ These checks validate structure and call sites, not translation quality or langu ## Exports - `I18nLabels` — type -- `defineI18nLabels` — const +- `defineI18nLabels` — function ## Related diff --git a/packages/core/src/components/data-table/cell-edit-error.ts b/packages/core/src/components/data-table/cell-edit-error.ts deleted file mode 100644 index 780f403ed..000000000 --- a/packages/core/src/components/data-table/cell-edit-error.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * A cell-editing failure whose message is safe to display to the user. - * Translate the message before throwing; other errors use generic feedback. - */ -export class CellEditError extends globalThis.Error { - override name = "CellEditError"; -} diff --git a/packages/core/src/components/data-table/cell-renderers.tsx b/packages/core/src/components/data-table/cell-renderers.tsx index 472d603aa..9c0b1e142 100644 --- a/packages/core/src/components/data-table/cell-renderers.tsx +++ b/packages/core/src/components/data-table/cell-renderers.tsx @@ -1,76 +1,25 @@ import type { ReactNode } from "react"; -import { createValueRenderer } from "@/components/internals/table-core"; -import { optionLabel, resolveBadgeOptions } from "./cell-values"; +import { resolveDataTableColumn } from "./column-adapter"; +import { getCellValue } from "./cell-values"; import type { Column } from "./types"; -/** - * Read the raw cell value for a column. Used by the built-in `type` - * renderers and by `truncate`'s tooltip so they share one precedence - * rule: explicit `accessor` wins, otherwise fall back to `row[col.id]` - * when `id` is set. - * - * @internal - */ -export function getCellValue>( - row: TRow, - col: Column, -): unknown { - if (col.accessor) return col.accessor(row); - if (col.id) return row[col.id]; - return undefined; -} +export { getCellValue } from "./cell-values"; -/** - * Create a column renderer that reuses date formatters across rows. - * Callers should prefer `col.render` when it is defined. - * - * @internal - */ +/** Build once per column/timezone so the display adapter retains formatter caches. */ export function createTypedCellRenderer>( col: Column, timeZone: string, ): (row: TRow) => ReactNode { - if (col.type === "date") { - const renderDate = createValueRenderer(col, timeZone); - return (row) => renderDate(getCellValue(row, col), row); - } - return (row) => renderTypedValue(row, col, getCellValue(row, col)); + const adapter = resolveDataTableColumn(col, timeZone); + return (row) => adapter.renderValue(getCellValue(row, col), row); } -/** - * Render the consumer's current value with the column's built-in renderer. - * Editable cells pass `linkAsText` because an editable link is an input, - * not a navigation target. - * - * @internal - */ +/** Compatibility entrypoint; label and editing-display policies belong to the adapter. */ export function renderTypedValue>( row: TRow, col: Column, value: unknown, options?: { linkAsText?: boolean; timeZone?: string }, ): ReactNode { - const timeZone = - options?.timeZone ?? - (col.type === "date" ? Intl.DateTimeFormat().resolvedOptions().timeZone : "UTC"); - switch (col.type) { - case "badge": { - const choices = col.edit - ? resolveBadgeOptions(col.edit.options, col.typeOptions, col.filter) - : undefined; - return createValueRenderer(col, timeZone, { - resolveBadgeLabel: choices ? (item) => String(optionLabel(choices, item)) : undefined, - })(value, row); - } - case "link": { - // An editable link is an input, not navigation; its choice label is resolved here. - const label = optionLabel(col.edit?.options, value); - const descriptor = options?.linkAsText ? ({ type: "text" } as const) : col; - return createValueRenderer(descriptor, timeZone)(label, row); - } - case "text": - return createValueRenderer(col, timeZone)(optionLabel(col.edit?.options, value), row); - default: - return createValueRenderer(col, timeZone)(value, row); - } + return resolveDataTableColumn(col, options?.timeZone).renderValue(value, row, options); } diff --git a/packages/core/src/components/data-table/cell-values.test.ts b/packages/core/src/components/data-table/cell-values.test.ts index e6bf9eaa0..087769783 100644 --- a/packages/core/src/components/data-table/cell-values.test.ts +++ b/packages/core/src/components/data-table/cell-values.test.ts @@ -7,97 +7,8 @@ import { toCalendarDate, toIsoDateTime, toTimeText, - normalizeNumberText, - parseNumberText, - sameNumber, - toNumberEditText, - toNumberValue, - toTextValue, } from "./cell-values"; -describe("typed text that isn't a number", () => { - it("is reported as not a number, not silently accepted", () => { - for (const text of ["e", "1e5", "+5", "0x1f", "12a", "1,5"]) { - expect(parseNumberText(normalizeNumberText(text))).toBeUndefined(); - } - }); -}); - -describe("normalizeNumberText", () => { - it("drops strict thousands grouping", () => { - expect(normalizeNumberText("1,234.5")).toBe("1234.5"); - expect(normalizeNumberText("-1,234,567")).toBe("-1234567"); - }); - - it("leaves a decimal comma alone so it fails instead of becoming 15", () => { - expect(normalizeNumberText("1,5")).toBe("1,5"); - expect(parseNumberText(normalizeNumberText("1,5"))).toBeUndefined(); - }); - - it("converts full-width digits and signs", () => { - expect(normalizeNumberText("12.5")).toBe("12.5"); - expect(normalizeNumberText("-3")).toBe("-3"); - expect(normalizeNumberText("−3")).toBe("-3"); - }); - - it("strips whitespace and currency symbols", () => { - expect(normalizeNumberText(" $1,234.50 ")).toBe("1234.50"); - expect(normalizeNumberText("¥ 1 200")).toBe("1200"); - }); -}); - -describe("parseNumberText", () => { - it("treats an empty draft as null", () => { - expect(parseNumberText("")).toBeNull(); - expect(parseNumberText(" ")).toBeNull(); - }); - - it("parses partial but complete-enough numbers without business constraints", () => { - expect(parseNumberText("-3.5678")).toBe(-3.5678); - expect(parseNumberText("500.0001")).toBe(500.0001); - expect(parseNumberText("5.")).toBe(5); - expect(parseNumberText(".5")).toBe(0.5); - expect(parseNumberText("-0")).toBe(0); - expect(Object.is(parseNumberText("-0"), -0)).toBe(false); - }); - - it("rejects half-typed and non-numeric drafts", () => { - for (const text of ["-", ".", "-.", "abc", "1,5", String(Number.MAX_SAFE_INTEGER * 2)]) { - expect(parseNumberText(text)).toBeUndefined(); - } - }); -}); - -describe("value helpers", () => { - it("compares parsed numbers without silently rounding distinct values", () => { - expect(sameNumber(10, 10)).toBe(true); - expect(sameNumber(0.1 + 0.2, 0.3)).toBe(false); - expect(sameNumber(1234567890123456, 1234567890123457)).toBe(false); - expect(sameNumber(null, null)).toBe(true); - expect(sameNumber(0, null)).toBe(false); - expect(sameNumber(10, 10.01)).toBe(false); - }); - - it("reads raw values the way the renderers do", () => { - expect(toNumberValue("10.00")).toBe(10); - expect(toNumberValue("")).toBeNull(); - expect(toNumberValue("abc")).toBeNull(); - expect(toTextValue("")).toBeNull(); - expect(toTextValue(42)).toBe("42"); - }); - - it("formats numbers without grouping, exponents, or precision loss", () => { - expect(toNumberEditText(1234567.5)).toBe("1234567.5"); - expect(toNumberEditText(0.1 + 0.2)).toBe("0.30000000000000004"); - expect(toNumberEditText(1234567890123456)).toBe("1234567890123456"); - expect(toNumberEditText(1e21)).toBe("1000000000000000000000"); - expect(toNumberEditText(null)).toBe(""); - for (const value of [Number.MAX_SAFE_INTEGER, Number.MIN_VALUE, 1e-30, 1.2345678901234567]) { - expect(parseNumberText(toNumberEditText(value))).toBe(value); - } - }); -}); - describe("choices", () => { const choices = [ { value: "acme", label: "Acme Corp" }, diff --git a/packages/core/src/components/data-table/cell-values.ts b/packages/core/src/components/data-table/cell-values.ts index 086ef8975..e61fb43f3 100644 --- a/packages/core/src/components/data-table/cell-values.ts +++ b/packages/core/src/components/data-table/cell-values.ts @@ -1,7 +1,6 @@ /** - * Pure helpers behind DataTable's inline cell editing: how typed or pasted text - * becomes a value and whether a draft is a real change or a cosmetic one - * (`10` vs `10.00`). Business validation belongs to the consumer. + * Pure helpers for DataTable's choice labels and date/time conversion. + * Business validation belongs to the consumer. * * @internal */ @@ -15,62 +14,17 @@ import { } from "@internationalized/date"; import { localDateTimeParts, normalizeTemporalFilterValue } from "@/lib/temporal-filter-values"; import type { SelectOption } from "@/types/collection"; -import type { BadgeCellOptions, DataTableFilterConfig } from "./types"; - -// Thousands grouping with commas, e.g. "1,234,567.89". -const GROUPED_NUMBER = /^-?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/; - -/** - * Cleans number text before it's checked: full-width digits and signs become - * ASCII, whitespace and currency symbols are dropped, and commas are removed - * only when they form strict thousands groups — so "1,234.5" becomes "1234.5", - * while "1,5" is left alone to fail as "not a number" instead of silently - * turning into 15. - */ -export function normalizeNumberText(text: string): string { - const compact = text - .normalize("NFKC") - .replace(/[\s\p{Sc}]/gu, "") - .replace(/^−/, "-"); - return GROUPED_NUMBER.test(compact) ? compact.replace(/,/g, "") : compact; -} - -/** `""` → `null`, `"5."` → `5`; `"-"`, `"."` and anything non-numeric → `undefined`. */ -export function parseNumberText(text: string): number | null | undefined { - const trimmed = text.trim(); - if (trimmed === "") return null; - if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(trimmed)) return undefined; - const value = Number(trimmed); - if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) return undefined; - // `+ 0` folds -0 into 0 so "-0" doesn't read as a change from 0. - return value + 0; -} - -/** Reads a raw cell value as a number the way the `number` renderer does. */ -export function toNumberValue(raw: unknown): number | null { - if (raw == null || raw === "") return null; - const value = typeof raw === "number" ? raw : Number(raw); - return Number.isFinite(value) ? value : null; -} - -/** Reads a raw cell value as text; `null`, `undefined` and `""` are all empty. */ -export function toTextValue(raw: unknown): string | null { - if (raw == null || raw === "") return null; - return String(raw); -} - -/** Compare parsed values exactly; rounding belongs to the consumer. */ -export function sameNumber(a: number | null, b: number | null): boolean { - return a === b; -} - -/** Plain edit text preserves the value without grouping or exponent notation. */ -export function toNumberEditText(value: number | null): string { - if (value === null) return ""; - return value.toLocaleString("en-US", { - useGrouping: false, - maximumSignificantDigits: 21, - }); +import type { BadgeCellOptions } from "@/components/internals/table-core/column-types"; +import type { Column, DataTableFilterConfig } from "./types"; + +/** Raw lookup shared by rendering, editing and context menus; never reads render output. */ +export function getCellValue>( + row: TRow, + col: Column, +): unknown { + if (col.accessor) return col.accessor(row); + if (col.id) return row[col.id]; + return undefined; } // ── Choices (dropdown and badge editors) ───────────────────────────────────── diff --git a/packages/core/src/components/data-table/column-adapter.test.tsx b/packages/core/src/components/data-table/column-adapter.test.tsx new file mode 100644 index 000000000..4791d51b9 --- /dev/null +++ b/packages/core/src/components/data-table/column-adapter.test.tsx @@ -0,0 +1,146 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { isValidElement } from "react"; +import { MemoryRouter } from "react-router"; +import { resolveDataTableColumn } from "./column-adapter"; +import type { Column } from "./types"; +import type { DataTableEditableCellProps } from "./editing/cell/shared"; +import { TypingEditCell, type TypingEditPolicy } from "./editing/cell/typing"; +import { ChoiceEditCell } from "./editing/cell/choice"; +import { DateEditCell } from "./editing/cell/date"; + +afterEach(cleanup); + +type Row = { id: string; value: string | number | null }; +const row: Row = { id: "1", value: "open" }; +const edit = { onValueCommitted: () => {} }; +const choices = [{ value: "open", label: "Open label" }]; + +function editorElement(column: Column) { + const adapter = resolveDataTableColumn(column, "UTC"); + // Inspect the bound element without mounting its unrelated cell/navigation context. + const editor = adapter.editor; + const element = editor?.render({ col: column } as DataTableEditableCellProps); + if (!isValidElement>(element)) throw new Error("Missing editor"); + return { adapter, editor, element }; +} + +describe("DataTable column adapter", () => { + it("resolves typed-input policies from the column definition", () => { + for (const type of ["text", "number", "money"] as const) { + const { adapter, element } = editorElement({ type, edit }); + expect(element.type).toBe(TypingEditCell); + expect(element.props.numeric).toBe(type !== "text"); + expect(adapter.editor?.icon).toBeUndefined(); + } + const link = editorElement({ type: "link", typeOptions: { href: () => "/" }, edit }); + expect(link.element.type).toBe(TypingEditCell); + expect(link.element.props.numeric).toBe(false); + }); + + it("owns numeric conversion and equality without changing the raw draft contract", () => { + const { element } = editorElement({ type: "money", edit }); + const policy = element.props as TypingEditPolicy; + expect(policy.toDraft(12.5)).toBe("12.5"); + expect(policy.parse("1,234.50")).toBe(1234.5); + expect(policy.parse("1,2")).toBeUndefined(); + expect(policy.parse("")).toBeNull(); + expect(policy.unchanged(12.5, 12.5)).toBe(true); + expect(policy.unchanged(null, 0)).toBe(false); + }); + + it("resolves text choices even when empty, but rejects badges without choices", () => { + const text = editorElement({ type: "text", edit: { ...edit, options: [] } }); + expect(text.element.type).toBe(ChoiceEditCell); + expect(text.element.props.options).toEqual([]); + expect(text.adapter.editor?.icon).toBeDefined(); + expect(resolveDataTableColumn({ type: "badge", edit }).editor).toBeUndefined(); + expect(resolveDataTableColumn({ type: "text" }).editor).toBeUndefined(); + expect(resolveDataTableColumn({ render: () => "Custom" }).editor).toBeUndefined(); + }); + + it("shares badge fallback choices between display and editing and protects array values", () => { + const column: Column = { + type: "badge", + typeOptions: { badgeLabelMap: { open: "Open label" } }, + edit, + }; + const { adapter, element } = editorElement(column); + expect(element.type).toBe(ChoiceEditCell); + expect(element.props.options).toEqual(choices); + expect(adapter.skeletonClassName).toBe("astw:h-5.5 astw:rounded-full"); + expect(adapter.editor?.canEditValue("open")).toBe(true); + expect(adapter.editor?.canEditValue(["open"])).toBe(false); + render(<>{adapter.renderValue("open", row)}); + expect(screen.getByText("Open label")).toBeDefined(); + }); + + it("retains badge edit-option/filter fallback precedence", () => { + const filtered: Column = { + type: "badge", + filter: { type: "enum", field: "value", options: choices }, + edit, + }; + expect(editorElement(filtered).element.props.options).toEqual(choices); + expect( + resolveDataTableColumn({ ...filtered, edit: { ...edit, options: [] }, type: "badge" }).editor, + ).toBeUndefined(); + }); + + it("resolves date and datetime settings, with a calendar icon from the same binding", () => { + for (const dateFormat of ["short", "datetime"] as const) { + const { editor, element } = editorElement({ + type: "date", + typeOptions: { dateFormat }, + edit, + }); + expect(element.type).toBe(DateEditCell); + expect(element.props.withTime).toBe(dateFormat === "datetime"); + expect(element.props.icon).toBe(editor?.icon); + } + }); + + it("keeps readonly links navigable and editable links as their resolved labels", () => { + const href = vi.fn(() => "/rows/1"); + const adapter = resolveDataTableColumn( + { type: "link", typeOptions: { href }, edit: { ...edit, options: choices } }, + "UTC", + ); + expect(adapter.renderValue("open", row, { linkAsText: true })).toBe("Open label"); + expect(href).not.toHaveBeenCalled(); + render({adapter.renderValue("open", row)}); + expect(screen.getByRole("link", { name: "Open label" }).getAttribute("href")).toBe("/rows/1"); + }); + + it("binds stable component identities and resets sessions only when error mode changes", () => { + const first = editorElement({ type: "text", edit }).element; + const rebuilt = editorElement({ type: "text", edit: { ...edit } }).element; + const unhandled = editorElement({ type: "text", edit: { ...edit, error: false } }).element; + expect(rebuilt.type).toBe(first.type); + expect(rebuilt.key).toBe(first.key); + expect(unhandled.type).toBe(first.type); + expect(unhandled.key).not.toBe(first.key); + }); + + it("reads updated choice configuration without replacing cached display renderers", () => { + const column: Column = { type: "text", edit: { ...edit, options: choices } }; + const adapter = resolveDataTableColumn(column, "UTC"); + const renderValue = adapter.renderValue; + column.edit!.options = [{ value: "open", label: "Updated label" }]; + expect(adapter.renderValue).toBe(renderValue); + expect(adapter.renderValue("open", row)).toBe("Updated label"); + const element = adapter.editor?.render({ col: column } as DataTableEditableCellProps); + expect(isValidElement>(element) && element.props.options).toEqual( + column.edit!.options, + ); + column.edit!.options = undefined; + const input = adapter.editor?.render({ col: column } as DataTableEditableCellProps); + expect(isValidElement(input) && input.type).toBe(TypingEditCell); + }); + + it("does not resolve inherited registry names as editors", () => { + const adapter = resolveDataTableColumn({ type: "constructor", edit } as unknown as Column); + expect(adapter.editor).toBeUndefined(); + expect(adapter.renderValue("safe", row)).toBe("safe"); + }); +}); diff --git a/packages/core/src/components/data-table/column-adapter.tsx b/packages/core/src/components/data-table/column-adapter.tsx new file mode 100644 index 000000000..99ce575ab --- /dev/null +++ b/packages/core/src/components/data-table/column-adapter.tsx @@ -0,0 +1,208 @@ +import type { ComponentType, ReactNode } from "react"; +import { CalendarDays, ChevronDown } from "lucide-react"; +import { BadgeList } from "@/components/badge-list"; +import { + createValueRenderer, + type ValueRendererOptions, +} from "@/components/internals/table-core/cell-renderers"; +import type { ColumnCellType } from "@/components/internals/table-core/column-types"; +import { + normalizeNumberText, + parseNumberText, + sameNumber, + toNumberEditText, + toNumberValue, + toTextValue, +} from "@/components/internals/table-core/input-values"; +import type { SelectOption } from "@/types/collection"; +import type { Column } from "./types"; +import { optionLabel, resolveBadgeOptions } from "./cell-values"; +import { TypingEditCell, type TypingEditPolicy } from "./editing/cell/typing"; +import { ChoiceEditCell, type ChoiceEditPolicy } from "./editing/cell/choice"; +import { DateEditCell } from "./editing/cell/date"; +import type { DataTableEditableCellProps } from "./editing/cell/shared"; + +export type DataTableCellEditor> = { + readonly icon?: ReactNode; + readonly canEditValue: (value: unknown) => boolean; + readonly render: (props: DataTableEditableCellProps) => ReactNode; +}; + +export type DataTableColumnAdapter> = { + readonly skeletonClassName?: string; + readonly renderValue: ( + value: unknown, + row: TRow, + options?: { linkAsText?: boolean }, + ) => ReactNode; + readonly editor?: DataTableCellEditor; +}; + +function bindEditor, TOptions extends object>( + Render: ComponentType & TOptions>, + options: TOptions, + icon?: ReactNode, + canEditValue: (value: unknown) => boolean = () => true, +): DataTableCellEditor { + return { + icon, + canEditValue, + render: (props) => ( + + ), + }; +} + +const textPolicy: TypingEditPolicy = { + numeric: false, + toDraft: (value) => toTextValue(value) ?? "", + parse: toTextValue, + unchanged: (value, current) => value === toTextValue(current), +}; +const numberPolicy: TypingEditPolicy = { + numeric: true, + toDraft: (value) => toNumberEditText(toNumberValue(value)), + parse: (draft) => parseNumberText(normalizeNumberText(draft)), + unchanged: (value, current) => sameNumber(value as number | null, toNumberValue(current)), +}; + +function typingEditor>(policy: TypingEditPolicy) { + return bindEditor(TypingEditCell, policy); +} + +function choiceEditor>( + options: readonly SelectOption[], + renderOption: ChoiceEditPolicy["renderOption"] = (item) => item.label, + canEditValue?: (value: unknown) => boolean, +) { + return bindEditor( + ChoiceEditCell, + { options, renderOption }, + , + canEditValue, + ); +} + +function display>( + col: Column, + timeZone: string | undefined, + options?: ValueRendererOptions, +): DataTableColumnAdapter["renderValue"] { + return createValueRenderer(col, timeZone ?? "UTC", options); +} + +function textColumn>( + col: Extract, { type: "text" | "link" }>, + timeZone?: string, +): DataTableColumnAdapter { + const render = display(col, timeZone); + const plain = display(col, timeZone, { linkAsText: true }); + return { + renderValue: (value, row, options) => + (options?.linkAsText ? plain : render)(optionLabel(col.edit?.options, value), row), + get editor() { + if (!col.edit) return undefined; + return col.edit.options + ? choiceEditor(col.edit.options) + : typingEditor(textPolicy); + }, + }; +} + +function numericColumn>( + col: Column, + timeZone?: string, +): DataTableColumnAdapter { + return { + renderValue: display(col, timeZone), + get editor() { + return col.edit ? typingEditor(numberPolicy) : undefined; + }, + }; +} + +type ColumnFactories> = { + [K in ColumnCellType]: ( + column: Extract, { type: K }>, + timeZone?: string, + ) => DataTableColumnAdapter; +}; + +/** DataTable alone interprets its display types as editing and label policies. */ +function createColumnFactories>() { + return { + text: textColumn, + link: textColumn, + number: numericColumn, + money: numericColumn, + date(col, timeZone) { + return { + renderValue: display(col, timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone), + get editor() { + return col.edit + ? bindEditor( + DateEditCell, + { withTime: col.typeOptions?.dateFormat === "datetime" }, + , + ) + : undefined; + }, + }; + }, + badge(col, timeZone) { + const choices = () => + col.edit ? resolveBadgeOptions(col.edit.options, col.typeOptions, col.filter) : undefined; + return { + skeletonClassName: "astw:h-5.5 astw:rounded-full", + renderValue: display(col, timeZone, { + get resolveBadgeLabel() { + const options = choices(); + return options ? (value: string) => String(optionLabel(options, value)) : undefined; + }, + }), + get editor() { + const options = choices(); + return options?.length + ? choiceEditor( + options, + (item) => ( + item.label} + /> + ), + (value) => !Array.isArray(value), + ) + : undefined; + }, + }; + }, + } satisfies ColumnFactories; +} + +const columnFactories = createColumnFactories>() as Record< + ColumnCellType, + ( + col: Column>, + timeZone?: string, + ) => DataTableColumnAdapter> +>; + +export function resolveDataTableColumn>( + col: Column, + timeZone?: string, +): DataTableColumnAdapter { + if (col.type === undefined || !Object.hasOwn(columnFactories, col.type)) + return { renderValue: display(col, timeZone) }; + // Public Column already checked the kind-specific configuration; erase only at dispatch. + return columnFactories[col.type]( + col as Column>, + timeZone, + ) as DataTableColumnAdapter; +} diff --git a/packages/core/src/components/data-table/column-layout.ts b/packages/core/src/components/data-table/column-layout.ts index 06d9d7d2f..1bbf6b0a0 100644 --- a/packages/core/src/components/data-table/column-layout.ts +++ b/packages/core/src/components/data-table/column-layout.ts @@ -1,14 +1,13 @@ import type { CSSProperties } from "react"; import { cn } from "@/lib/utils"; +import { getDefaultAlignment } from "@/components/internals/table-core/cell-renderers"; import type { Column } from "./types"; // Resolve the effective horizontal alignment for a column. Numeric `type` // values default to `"right"` so digits line up along their decimal place; // everything else defaults to `"left"`. Explicit `col.align` always wins. function resolveAlign>(col: Column): "left" | "right" { - if (col.align) return col.align; - if (col.type === "number" || col.type === "money") return "right"; - return "left"; + return col.align ?? getDefaultAlignment(col.type); } // Fallback widths of the built-in selection / row-actions columns, used as the diff --git a/packages/core/src/components/data-table/date-cache.test.tsx b/packages/core/src/components/data-table/date-cache.test.tsx index dd05b9a61..5c2d98181 100644 --- a/packages/core/src/components/data-table/date-cache.test.tsx +++ b/packages/core/src/components/data-table/date-cache.test.tsx @@ -3,9 +3,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { AppShellConfigContext, buildConfigurations } from "@/contexts/appshell-context"; +import type { DateCellOptions } from "@/components/internals/table-core/column-types"; import { DataTable } from "./data-table"; import { useDataTable } from "./use-data-table"; -import type { Column, DateCellOptions } from "./types"; +import type { Column } from "./types"; afterEach(() => { cleanup(); diff --git a/packages/core/src/components/data-table/editing/cell/choice.tsx b/packages/core/src/components/data-table/editing/cell/choice.tsx index 99655f1f0..2d39c36dc 100644 --- a/packages/core/src/components/data-table/editing/cell/choice.tsx +++ b/packages/core/src/components/data-table/editing/cell/choice.tsx @@ -1,54 +1,23 @@ -import { - useMemo, - useRef, - useState, - type ComponentProps, - type FocusEvent, - type MouseEvent, - type TouchEvent, -} from "react"; -import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; -import { ChevronDown } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { isComposing } from "@/lib/is-composing"; -import { BadgeList } from "@/components/badge-list"; -import { ComboboxParts } from "@/components/combobox"; -import { toTextValue } from "../../cell-values"; +import { useMemo, type ReactNode } from "react"; +import type { SelectOption } from "@/types/collection"; +import { ChoiceEditor } from "@/components/internals/table-core/editors/choice"; +import { toTextValue } from "@/components/internals/table-core/input-values"; import { useDataTableT } from "../../i18n"; import { useCellEditSession } from "../use-session"; import { - CELL_ERROR_CLASS_NAME, - ICON_INPUT_CLASS_NAME, - INPUT_CLASS_NAME, EditableCellFrame, - columnChoices, useCellValue, useNavigationRef, - type EditableColumn, type DataTableEditableCellProps, } from "./shared"; -type ChoiceColumn> = Extract< - EditableColumn, - { type: "text" | "link" | "badge" } ->; - -/** One dropdown choice; `value: null` is "None". */ -interface Choice { - value: string | null; - 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(); - -const isChoiceEqual = (a: Choice, b: Choice) => a.value === b.value; - -const choiceLabel = (choice: Choice) => choice.label; +export type ChoiceEditPolicy = { + options: readonly SelectOption[]; + renderOption: (item: { value: string; label: string }) => ReactNode; +}; +type ChoiceDraft = { value: string | null; label: string; notified?: boolean }; +/** DataTable owns callback ordering/rollback; the core owns search and dropdown interaction. */ export function ChoiceEditCell>({ row, col, @@ -59,7 +28,11 @@ export function ChoiceEditCell>({ navigation, cellProps, tooltipLabel, -}: DataTableEditableCellProps>) { + display: displayValue, + icon, + options: choices, + renderOption, +}: DataTableEditableCellProps & ChoiceEditPolicy) { const t = useDataTableT(); const session = useCellEditSession(col.edit?.error !== false); const { state } = session; @@ -67,67 +40,27 @@ export function ChoiceEditCell>({ row, col, state.status === "failed" ? state : undefined, + displayValue, ); const register = useNavigationRef(navigation, rowKey, colKey); const value = toTextValue(current); const noneLabel = t("editNone"); - const choices = columnChoices(col); - // Keyed on content: rebuilding columns must not reset the highlight mid-search. const choicesKey = (choices ?? []).map((c) => `${c.value}\u0000${c.label}`).join("\u0001"); - const items = useMemo( - () => [ - { value: null, label: noneLabel }, - ...(choices ?? []).map((c) => ({ value: c.value, label: String(c.label) })), - ], - // eslint-disable-next-line react-hooks/exhaustive-deps -- `choicesKey` stands for `choices`. - [choicesKey, noneLabel], + // A consumer rebuilding definitions must not invalidate an active search/highlight. + const options = useMemo( + () => (choices ?? []).map((c) => ({ value: c.value, label: String(c.label) })), + // eslint-disable-next-line react-hooks/exhaustive-deps -- choicesKey captures choices by content. + [choicesKey], ); - - const selected = items.find((item) => item.value === value) ?? null; - const [open, setOpen] = useState(false); - const [focused, setFocused] = useState(false); + const selected = options.find((item) => item.value === value) ?? { + value, + label: value ?? noneLabel, + }; 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. - const [listQuery, setListQuery] = useState(null); - const highlightedRef = useRef(undefined); - const explicitHighlightRef = useRef(undefined); - const composingRef = useRef(false); - const selectOnMouseUpRef = useRef(false); - const from = { rowKey, colKey }; - - const filtered = useMemo(() => { - const search = searchable(listQuery ?? ""); - if (!search) return items; - const rank = (item: Choice) => { - const text = searchable(item.label); - if (text === search) return 0; - return text.startsWith(search) ? 1 : 2; - }; - return ( - items - .filter((item) => item.value !== null && searchable(item.label).includes(search)) - // Sorting is stable, preserving the original order within each rank. - .toSorted((left, right) => rank(left) - rank(right)) - ); - }, [items, listQuery]); - - const startEditing = () => { - session.begin(selected ?? { value, label: value ?? noneLabel }); - setQuery(selected?.label ?? ""); - setListQuery(null); - highlightedRef.current = undefined; - explicitHighlightRef.current = undefined; - }; - - const closeEditor = () => { - setQuery(null); - explicitHighlightRef.current = undefined; - setOpen(false); + const begin = () => { + if (session.getState().status === "idle") session.begin(selected); }; - const cancelEditing = () => { const active = session.getState(); session.cancel( @@ -135,251 +68,84 @@ export function ChoiceEditCell>({ ? () => change(value) : undefined, ); - closeEditor(); }; - const leaveEditor = () => { const active = session.getState(); - if (active.status === "failed" || active.status === "confirming") closeEditor(); - else cancelEditing(); + if (active.status !== "failed" && active.status !== "confirming") cancelEditing(); }; - - const pick = (item: Choice) => { + const pick = (next: string | number | null) => { + if (typeof next === "number") throw new Error("DataTable choices must be strings or null"); const active = session.getState(); - if (active.status === "confirming" && active.draft.value === item.value) return; + if (active.status === "confirming" && active.draft.value === next) + return session.confirmAsync(() => undefined); 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 = () => { - const item = explicitHighlightRef.current; - return item && filtered.includes(item) ? item : undefined; - }; - - const keyboardCandidate = () => { - const highlighted = highlightedRef.current; - return ( - explicitCandidate() ?? - (highlighted && filtered.includes(highlighted) ? highlighted : filtered[0]) + { + value: next, + label: options.find((item) => item.value === next)?.label ?? next ?? noneLabel, + notified: next !== value, + }, + next !== value ? () => change(next) : undefined, ); + return session.confirmAsync(() => (next !== value ? commit(next) : undefined)); }; - - const handleKeyDown: NonNullable["onKeyDown"]> = ( - event, - ) => { - if (isComposing(event.nativeEvent, composingRef.current)) { - event.preventBaseUIHandler(); - return; - } - switch (event.key) { - case "Enter": { - event.preventDefault(); - event.preventBaseUIHandler(); - const active = session.getState(); - if (active.status === "confirming") return; - if (!open) { - 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 leaveEditor(); - return; - } - case "Tab": { - event.preventBaseUIHandler(); - const item = - session.getState().status !== "idle" && open - ? (explicitCandidate() ?? - (searchable(listQuery ?? "") ? keyboardCandidate() : undefined)) - : undefined; - if (item) pick(item); - else leaveEditor(); - if (navigation.move(from, event.shiftKey ? "prev" : "next")) event.preventDefault(); - return; - } - case "Escape": - if (session.getState().status === "idle" && !open) return; - event.preventDefault(); - event.stopPropagation(); - event.preventBaseUIHandler(); - cancelEditing(); - return; - } - }; - - const handleFocus = (event: FocusEvent) => { - const withinCell = focused || containsErrorTarget(event.relatedTarget); - setFocused(true); - if (withinCell) return; - if (session.getState().status === "idle") startEditing(); - event.currentTarget.select(); - }; - - const handleBlur = () => { - setFocused(false); - leaveEditor(); - }; - - const handleMouseDown = (event: MouseEvent) => { - if (event.button === 2 && session.getState().status === "idle") { - event.preventDefault(); - return; - } - if (session.getState().status === "idle") { - startEditing(); - setOpen(true); - } - if (!focused) selectOnMouseUpRef.current = true; - }; - - const handleMouseUp = (event: MouseEvent) => { - if (!selectOnMouseUpRef.current) return; - selectOnMouseUpRef.current = false; - event.preventDefault(); - }; - - const stopWhileEditing = (event: MouseEvent | TouchEvent) => { - if (session.getState().status !== "idle") event.stopPropagation(); - }; - - const renderChoice = (item: Choice) => { - if (item.value === null) - return {item.label}; - if (col.type !== "badge") return item.label; - return ( - item.label} /> - ); - }; - return ( } + icon={icon} tooltipLabel={tooltipLabel} editing={editing} callbackError={callbackError} label={label} - onEditorLeave={handleBlur} + onEditorLeave={leaveEditor} onEditorCancel={cancelEditing} getFailure={session.getFailure} confirming={state.status === "confirming"} > - - items={items} - filteredItems={filtered} - value={activeChoice} - onValueChange={(item, details) => { - if (details.reason === "item-press" && item) pick(item); - }} - 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; - } + { + const active = session.getState(); + session.change(active.status === "idle" ? selected : active.draft); }} - open={open} - onOpenChange={(next, details) => { - if (next) { - 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; - } - if (details.reason === "escape-key") cancelEditing(); - else leaveEditor(); + confirmOnTab + onTab={(event) => { + if (navigation.move({ rowKey, colKey }, event.shiftKey ? "prev" : "next")) + event.preventDefault(); }} - onOpenChangeComplete={(isOpen) => { - if (!isOpen) setListQuery(null); - }} - onItemHighlighted={(item, details) => { - // Hover is visual feedback, not a keyboard confirmation candidate. - if (details.reason === "pointer") return; - highlightedRef.current = item; - if (item && details.reason === "keyboard") explicitHighlightRef.current = item; + retry={ + state.status === "failed" && state.source === "commit" + ? () => + session.confirmAsync(() => + state.draft.value !== value ? commit(state.draft.value) : undefined, + ) + : undefined + } + renderOption={(item) => { + if (item.value === null) + return {item.label}; + return renderOption({ value: String(item.value), label: item.label }); }} - autoHighlight - itemToStringLabel={choiceLabel} - isItemEqualToValue={isChoiceEqual} - > - { - composingRef.current = true; - }} - onCompositionEnd={() => { - composingRef.current = false; - }} - /> - {/* At least as wide as the cell, and wide enough that choices don't wrap. */} - - {t("editNoMatches")} - - {(item: Choice) => ( - - {renderChoice(item)} - - )} - - - + /> ); } diff --git a/packages/core/src/components/data-table/editing/cell/date.tsx b/packages/core/src/components/data-table/editing/cell/date.tsx index eacb88bc5..273f1a6c5 100644 --- a/packages/core/src/components/data-table/editing/cell/date.tsx +++ b/packages/core/src/components/data-table/editing/cell/date.tsx @@ -1,44 +1,21 @@ import { useState, type KeyboardEvent } from "react"; -import { Popover } from "@base-ui/react/popover"; +import type { Popover } from "@base-ui/react/popover"; import type { CalendarDate } from "@internationalized/date"; -import { CalendarDays } from "lucide-react"; -import { cn } from "@/lib/utils"; import { useTimeZone } from "@/contexts/appshell-context"; -import { Button } from "@/components/button"; -import { Calendar } from "@/components/calendar"; -import { Input } from "@/components/input"; +import { CalendarControl } from "@/components/internals/table-core/editors/calendar-control"; +import { CELL_ERROR_CLASS_NAME } from "@/components/internals/table-core/cell-styles"; import type { DateTimeDraft } from "../../types"; import { sameDate, toCalendarDate, toIsoDateTime, toTimeText } from "../../cell-values"; import { useDataTableT } from "../../i18n"; import type { CellEditNavigation } from "../use-navigation"; import { useCellEditSession } from "../use-session"; import { - OVERLAY_CLASS_NAME, - CELL_FOCUS_CLASS_NAME, - CELL_ERROR_CLASS_NAME, - POPUP_CLASS_NAME, EditableCellFrame, useCellValue, useNavigationRef, - type EditableColumn, type DataTableEditableCellProps, } from "./shared"; -type DateColumn> = Extract< - EditableColumn, - { type: "date" } ->; - -// Date cells: a transparent button over the cell's own display, with a pointer. -const TRIGGER_CLASS_NAME = cn( - OVERLAY_CLASS_NAME, - CELL_FOCUS_CLASS_NAME, - "astw:cursor-pointer", - "astw:data-popup-open:ring-2 astw:data-popup-open:ring-inset astw:data-popup-open:ring-primary", -); - -// Tab / Shift+Tab from a closed calendar moves between editable cells, the -// same way it does from a typing cell. function tabBetweenCells( event: KeyboardEvent, open: boolean, @@ -49,6 +26,7 @@ function tabBetweenCells( if (navigation.move(from, event.shiftKey ? "prev" : "next")) event.preventDefault(); } +/** DataTable owns datetime conversion, draft notifications and callback confirmation. */ export function DateEditCell>({ row, col, @@ -58,18 +36,19 @@ export function DateEditCell>({ navigation, cellProps, tooltipLabel, - dateRenderer, -}: DataTableEditableCellProps>) { + display: displayValue, + icon, + withTime, +}: DataTableEditableCellProps & { withTime: boolean }) { const t = useDataTableT(); const { value: timeZone } = useTimeZone(); - const withTime = col.typeOptions?.dateFormat === "datetime"; 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, + displayValue, ); const register = useNavigationRef(navigation, rowKey, colKey); const baseline: DateTimeDraft = { @@ -114,9 +93,7 @@ export function DateEditCell>({ ? `${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 visibleDisplay === "string" ? `${label}, ${visibleDisplay}` : label; - const handleOpenChange = (next: boolean, details?: Popover.Root.ChangeEventDetails) => { if ( !next && @@ -128,9 +105,8 @@ export function DateEditCell>({ details.cancel(); return; } - if (next) { - session.begin(baseline); - } else { + if (next) session.begin(baseline); + else { const active = session.getState(); if ( details?.reason === "escape-key" || @@ -142,19 +118,17 @@ export function DateEditCell>({ } setOpen(next); }; - const handlePick = (day: CalendarDate) => { const active = session.getState(); setDraft({ ...(active.status === "idle" ? baseline : active.draft), day: day.toString() }); if (!withTime) finish(day.toString()); }; - return ( } + icon={icon} tooltipLabel={tooltipLabel} editing={open} callbackError={callbackError} @@ -163,84 +137,56 @@ export function DateEditCell>({ getFailure={session.getFailure} confirming={state.status === "confirming"} > - - { + { + setDraft({ day: null, time: null }); + finish(null); + }} + triggerProps={{ + ref: register, + "aria-label": accessibleName, + "aria-invalid": callbackError ? true : undefined, + "aria-describedby": callbackError?.id, + "aria-keyshortcuts": callbackError ? "Alt+ArrowDown" : undefined, + className: callbackError ? CELL_ERROR_CLASS_NAME : undefined, + onKeyDown: (event) => { if (event.key === "Escape" && !open && session.getState().status !== "idle") { event.preventDefault(); event.stopPropagation(); cancelEditing(); return; } - if (event.key === "ArrowDown" && !open) { - event.preventDefault(); - handleOpenChange(true); - return; - } tabBetweenCells(event, open, navigation, { rowKey, colKey }); - }} - /> - - - event.stopPropagation()} - onTouchStart={(event) => event.stopPropagation()} - > - - {withTime && ( - - setDraft({ - ...activeDraft, - time: event.target.value || null, - }) - } - className="astw:h-8 astw:text-body-md" - /> - )} -
- - {withTime && ( - - )} -
-
-
-
-
+ }, + }} + time={ + withTime + ? { + label: `${label} (${t("chooseTime")})`, + value: activeDraft.time ?? "", + onChange: (text) => setDraft({ ...activeDraft, time: text || null }), + } + : undefined + } + confirmation={ + withTime + ? { + label: t("editDone"), + disabled: timestamp === undefined, + onClick: () => { + if (timestamp !== undefined) finish(timestamp); + }, + } + : undefined + } + />
); } diff --git a/packages/core/src/components/data-table/editing/cell/errors.test.tsx b/packages/core/src/components/data-table/editing/cell/errors.test.tsx index 81b958a1d..0e562ff4b 100644 --- a/packages/core/src/components/data-table/editing/cell/errors.test.tsx +++ b/packages/core/src/components/data-table/editing/cell/errors.test.tsx @@ -4,6 +4,7 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-librar import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { CellEditError, type TextCellEditOptions } from "@/index"; +import { CellEditError as SharedCellEditError } from "@/components/internals/table-core/cell-edit-error"; import { createAppShellWrapper } from "../../../../../tests/test-utils"; import { DataTable } from "../../data-table"; import { useDataTable } from "../../use-data-table"; @@ -107,6 +108,7 @@ const queryErrorTrigger = () => screen.queryByRole("button", { name: "Show error describe("DataTable callback error presentation", () => { it("exports CellEditError as a named native error", () => { + expect(CellEditError).toBe(SharedCellEditError); const error = new CellEditError("Enter a quantity"); expect(error).toBeInstanceOf(Error); expect(error.name).toBe("CellEditError"); diff --git a/packages/core/src/components/data-table/editing/cell/root.tsx b/packages/core/src/components/data-table/editing/cell/root.tsx index 0a4e13217..147a07808 100644 --- a/packages/core/src/components/data-table/editing/cell/root.tsx +++ b/packages/core/src/components/data-table/editing/cell/root.tsx @@ -1,58 +1,10 @@ -import type { Column } from "../../types"; -import { columnChoices, type EditableColumn, type DataTableEditableCellProps } from "./shared"; -import { TypingEditCell } from "./typing"; -import { ChoiceEditCell } from "./choice"; -import { DateEditCell } from "./date"; +import type { DataTableCellEditor } from "../../column-adapter"; +import type { DataTableEditableCellProps } from "./shared"; -/** - * Whether the column carries an `edit` config it can use: any typed column - * except a `badge` one with no choices to offer. - * - * @internal - */ -export function isEditableColumn>( - col: Column, -): col is EditableColumn { - if (col.edit === undefined || col.type === undefined) return false; - return col.type !== "badge" || (columnChoices(col)?.length ?? 0) > 0; -} - -/** - * Whether the column's editor shows an icon (a dropdown's chevron or a date's - * calendar). Typed-into cells show none: the cell's tint already says it's - * editable, and a pen in every cell reads as noise. - * - * @internal - */ -export function hasEditorIcon>(col: Column): boolean { - return ( - col.type === "date" || - col.type === "badge" || - ((col.type === "text" || col.type === "link") && !!col.edit?.options) - ); -} - -/** - * A body cell whose value can be edited in place. The value belongs to the - * consumer: the cell only holds a draft while it is being edited, and notifies - * `edit.onValueChange` for drafts and `edit.onValueCommitted` for changed, - * confirmed values. Validation and persistence belong to the consumer. - * - * - `number`, `money`, and `text` / `link` without choices are typed into. - * - `badge`, and `text` / `link` with `edit.options`, are dropdowns you can - * type into to search. - * - `date` opens a calendar. - * - * @internal - */ -export function DataTableEditableCell>( - props: DataTableEditableCellProps, -) { - const { col } = props; - 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 ; +/** The column adapter already selected and configured a stable editor component. */ +export function DataTableEditableCell>({ + editor, + ...props +}: DataTableEditableCellProps & { editor: DataTableCellEditor }) { + return editor.render(props); } diff --git a/packages/core/src/components/data-table/editing/cell/shared.tsx b/packages/core/src/components/data-table/editing/cell/shared.tsx index 751e52da1..3af96e378 100644 --- a/packages/core/src/components/data-table/editing/cell/shared.tsx +++ b/packages/core/src/components/data-table/editing/cell/shared.tsx @@ -1,159 +1,25 @@ +import { useCallback, useRef, type ComponentProps, type ReactNode } from "react"; +import type { Table } from "@/components/table"; +import { useCellError } from "@/components/internals/table-core/cell-error"; +import { CellEditError } from "@/components/internals/table-core/cell-edit-error"; +import type { CellEditFailure } from "@/components/internals/table-core/cell-session"; import { - useCallback, - useId, - useLayoutEffect, - useRef, - useState, - type ComponentProps, - type ReactNode, - type RefObject, -} from "react"; -import { Popover } from "@base-ui/react/popover"; -import { CircleAlert, X } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { useTimeZone } from "@/contexts/appshell-context"; -import { Button } from "@/components/button"; -import { Table } from "@/components/table"; -import { Tooltip } from "@/components/tooltip"; + EditableCellSurface, + type EditableCellSurfaceProps, +} from "@/components/internals/table-core/cell-surface"; import type { Column } from "../../types"; -import { getCellValue, renderTypedValue } from "../../cell-renderers"; -import { CellEditError } from "../../cell-edit-error"; -import { resolveBadgeOptions } from "../../cell-values"; +import { getCellValue } from "../../cell-values"; import type { CellEditNavigation } from "../use-navigation"; import { useDataTableT } from "../../i18n"; -import type { CellEditFailure } from "../use-session"; - -/** - * A column whose cells can be edited in place. - * - * @internal - */ -export type EditableColumn> = Extract< - Column, - { type: "text" | "number" | "money" | "date" | "badge" | "link" } ->; - -// The dropdown choices a column offers, or `undefined` when it's typed into. -export function columnChoices>(col: Column) { - if (col.type === "badge") - return resolveBadgeOptions(col.edit?.options, col.typeOptions, col.filter); - if (col.type === "text" || col.type === "link") return col.edit?.options; - return undefined; -} - -/** - * Room for the icon a dropdown or date cell shows at its right edge (a chevron - * or a calendar; 14px plus a gap), so a value ends — or truncates — before it - * instead of running underneath. Read-only cells of the same column keep it - * too, so the column's width doesn't change as rows become editable or not. - * - * @internal - */ -export const ICON_SPACE_CLASS_NAME = "astw:pr-5"; - -// Editors cover the whole cell, end to end, instead of taking part in layout, -// so a cell becoming editable — or being edited — never changes row height or -// column width. They inherit the cell's padding (they're its direct children), -// so their text lines up exactly with the cell's own display underneath. -export const OVERLAY_CLASS_NAME = - "astw:absolute astw:inset-0 astw:h-full astw:w-full astw:p-[inherit] astw:rounded-none astw:border-0 astw:shadow-none astw:bg-transparent astw:dark:bg-transparent"; - -// Spreadsheet-style: the cell being edited outlines its edges (an inset ring, -// so it never spills onto neighbouring cells). -export const CELL_FOCUS_CLASS_NAME = cn( - "astw:outline-none astw:focus:ring-2 astw:focus:ring-inset astw:focus:ring-primary", - "astw:focus-visible:ring-2 astw:focus-visible:ring-inset astw:focus-visible:ring-primary", -); - -// Parsing and callback errors share the cell's error outline. -export const CELL_ERROR_CLASS_NAME = - "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 -// row's hover / selected fade, a pinned cell's opaque card), so it never -// replaces them and stays a step darker than its read-only neighbours. The -// cell being edited drops it: an untinted cell with a ring reads as "active". -// (Written out in full: Tailwind only generates classes it finds as literals.) -const EDITABLE_CELL_CLASS_NAME = cn( - "astw:[background-image:linear-gradient(color-mix(in_srgb,var(--foreground)_4%,transparent),color-mix(in_srgb,var(--foreground)_4%,transparent))]", - "astw:hover:[background-image:linear-gradient(color-mix(in_srgb,var(--foreground)_7%,transparent),color-mix(in_srgb,var(--foreground)_7%,transparent))]", - "astw:focus-within:[background-image:none]", -); - -// Text, number and dropdown cells are typed into: the whole cell shows a text -// cursor. -export const INPUT_CLASS_NAME = cn( - OVERLAY_CLASS_NAME, - CELL_FOCUS_CLASS_NAME, - "astw:text-body-md astw:cursor-text", -); - -// A dropdown's text stops before its chevron. -export const ICON_INPUT_CLASS_NAME = "astw:pr-7 astw:[td:last-child>&]:pr-11"; - -// The icon that says how a cell is edited: shown while the cell is hovered or -// being edited (the cell's tint says it's editable at rest). -// Decoration only: a click anywhere in the cell goes to the editor underneath. -const CELL_ICON_CLASS_NAME = cn( - "astw:pointer-events-none astw:absolute astw:inset-y-0 astw:right-2 astw:flex astw:items-center astw:[td:last-child>&]:right-6", - "astw:text-muted-foreground astw:opacity-0 astw:transition-opacity", - "astw:group-hover/cell:opacity-30 astw:group-focus-within/cell:opacity-30", - "astw:[&_svg]:size-3.5", -); - -export const POPUP_CLASS_NAME = cn( - "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:flex astw:flex-col astw:gap-3 astw:rounded-md astw:border astw:border-border astw:p-3 astw:shadow-md", - "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:fade-out-0 astw:data-ending-style:zoom-out-95", -); - -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}

-
- ); -} export function useCellValue>( row: TRow, - col: EditableColumn, + col: Column, failure: CellEditFailure | undefined, - dateRenderer?: (row: TRow) => ReactNode, + display: ReactNode, ) { - const { value: timeZone } = useTimeZone(); const current = getCellValue(row, col); - const render = col.render ?? dateRenderer; - const display = render - ? render(row) - : renderTypedValue(row, col, current, { linkAsText: true, timeZone }); 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) => @@ -167,31 +33,16 @@ export function useCellValue>( 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; + const { error: callbackError, containsTarget: containsErrorTarget } = useCellError({ + failure: failure && failure.source !== "parse" ? failure : undefined, + message: renderError ? t("editCallbackError") : message, + renderContent: + failure && renderError ? () => renderError({ row, error: failure.error }) : undefined, + }); return { current, display, change, commit, callbackError, containsErrorTarget }; } -/** - * Registers the cell's focusable element with the table's Tab - * navigation. Unregisters on every detach instead of returning a ref cleanup: - * `Input` and `Combobox.Input` merge refs with a plain callback that calls ours - * with `null` and drops any cleanup we return. - */ +/** Unregister every detached editor even when an underlying control merges callback refs. */ export function useNavigationRef( navigation: CellEditNavigation, rowKey: string, @@ -207,223 +58,29 @@ export function useNavigationRef( ); } +/** DataTable keeps its labels and DOM slot contract outside the shared surface. */ export function EditableCellFrame({ - cellProps, - display, - truncate, - hideDisplay, - icon, - tooltipLabel, - editing, - callbackError, label, - onEditorLeave, - onEditorCancel, - getFailure, - confirming, - children, -}: { - cellProps: ComponentProps; - display: ReactNode; - 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; -}) { + ...props +}: Omit & { label: string }) { const t = useDataTableT(); - const [tooltipOpen, setTooltipOpen] = useState(false); - 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()} - > - - {children} - {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 && ( - - )} -
- ); return ( - - - {tooltipLabel} - + ); } -export interface DataTableEditableCellProps< - TRow extends Record, - TColumn extends EditableColumn = EditableColumn, -> { +export interface DataTableEditableCellProps> { row: TRow; - col: TColumn; + col: Column; rowKey: string; colKey: string; /** Accessible name for the editor — the column's label. */ @@ -431,8 +88,9 @@ export interface DataTableEditableCellProps< align: "left" | "right"; tooltipLabel?: string; navigation: CellEditNavigation; - /** Reuses the table's column-level date formatter cache. */ - dateRenderer?: (row: TRow) => ReactNode; - /** `data-slot`, style, class and context-menu handlers shared with static cells. */ + /** Display and icon policy are resolved once by the column adapter. */ + display: ReactNode; + icon?: ReactNode; + /** data-slot, style, class and context-menu handlers shared with static cells. */ cellProps: ComponentProps; } diff --git a/packages/core/src/components/data-table/editing/cell/typing.tsx b/packages/core/src/components/data-table/editing/cell/typing.tsx index 90e39553e..ce863b9fa 100644 --- a/packages/core/src/components/data-table/editing/cell/typing.tsx +++ b/packages/core/src/components/data-table/editing/cell/typing.tsx @@ -1,37 +1,22 @@ -import { - useId, - useRef, - useState, - type ChangeEvent, - type FocusEvent, - type KeyboardEvent, - type MouseEvent, - type TouchEvent, -} from "react"; -import { cn } from "@/lib/utils"; -import { isComposing } from "@/lib/is-composing"; -import { Input } from "@/components/input"; -import { Tooltip } from "@/components/tooltip"; -import { - normalizeNumberText, - parseNumberText, - sameNumber, - toNumberEditText, - toNumberValue, - toTextValue, -} from "../../cell-values"; +import { useId } from "react"; +import { TypingEditor } from "@/components/internals/table-core/editors/typing"; import { useDataTableT } from "../../i18n"; import { useCellEditSession } from "../use-session"; import { - CELL_ERROR_CLASS_NAME, - ICON_INPUT_CLASS_NAME, - INPUT_CLASS_NAME, EditableCellFrame, useCellValue, useNavigationRef, type DataTableEditableCellProps, } from "./shared"; +export type TypingEditPolicy = { + numeric: boolean; + toDraft: (value: unknown) => string; + parse: (draft: string) => unknown; + unchanged: (value: unknown, current: unknown) => boolean; +}; + +/** DataTable owns raw draft notifications, equality and callback confirmation. */ export function TypingEditCell>({ row, col, @@ -42,7 +27,12 @@ export function TypingEditCell>({ navigation, cellProps, tooltipLabel, -}: DataTableEditableCellProps) { + display: displayValue, + numeric, + toDraft, + parse, + unchanged: isUnchanged, +}: DataTableEditableCellProps & TypingEditPolicy) { const t = useDataTableT(); const errorId = useId(); const session = useCellEditSession(col.edit?.error !== false); @@ -52,21 +42,13 @@ export function TypingEditCell>({ row, col, failure, + displayValue, ); const register = useNavigationRef(navigation, rowKey, colKey); const editing = state.status !== "idle"; - const [focused, setFocused] = useState(false); - const [hovered, setHovered] = useState(false); - const composingRef = useRef(false); - const selectOnMouseUpRef = useRef(false); - - const numeric = col.type === "number" || col.type === "money"; - const baselineText = numeric - ? toNumberEditText(toNumberValue(current)) - : (toTextValue(current) ?? ""); + const baselineText = toDraft(current); const text = state.status === "idle" ? baselineText : state.draft; const message = failure?.source === "parse" ? t("editNotANumber") : undefined; - const revert = (notify = false) => { const previous = session.getState(); session.cancel( @@ -75,121 +57,28 @@ export function TypingEditCell>({ : undefined, ); }; - const commitDraft = (): boolean => { 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); + const value = parse(draftText); if (value === undefined) { session.fail(undefined, "parse"); return false; } - const unchanged = - draftText === baselineText || - (numeric - ? sameNumber(value as number | null, toNumberValue(current)) - : value === toTextValue(current)); + const unchanged = draftText === baselineText || isUnchanged(value, current); session.confirm(() => (unchanged ? undefined : commit(value))); return true; }; - - const handleChange = (event: ChangeEvent) => { - const next = event.target.value; - 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. - const handleCompositionEnd = () => { - composingRef.current = false; - }; - - const handleKeyDown = (event: KeyboardEvent) => { - // While an IME is composing, Enter confirms the conversion, not the cell. - if (isComposing(event.nativeEvent, composingRef.current)) return; - const from = { rowKey, colKey }; - switch (event.key) { - case "Enter": - event.preventDefault(); - if (session.getState().status === "idle") { - session.begin(baselineText); - event.currentTarget.select(); - } else { - commitDraft(); - } - return; - 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 (session.getState().status === "idle") return; - // Handled here, so an enclosing dialog doesn't also close on this Esc. - event.preventDefault(); - event.stopPropagation(); - revert(true); - return; - } - }; - - const handleFocus = (event: FocusEvent) => { - const withinCell = focused || containsErrorTarget(event.relatedTarget); - setFocused(true); - if (withinCell) return; - session.begin(baselineText); - event.currentTarget.select(); - }; - const handleBlur = () => { - setFocused(false); const active = session.getState(); if (active.status === "failed") { - // Preserve callback failures; malformed numeric blur still cancels the edit. + // Callback failures stay visible; malformed numeric blur cancels the edit. if (active.source === "parse") revert(true); return; } if (!commitDraft()) revert(true); }; - - const handleMouseDown = (event: MouseEvent) => { - if (editing) return; - // A right-click on a resting cell opens DataTable's cell menu instead of editing. - if (event.button === 2) { - event.preventDefault(); - return; - } - session.begin(baselineText); - if (focused) event.currentTarget.select(); - selectOnMouseUpRef.current = true; - }; - - // The click that focuses the cell keeps the select-all from `handleFocus`; - // otherwise mouseup would collapse it to a caret. - const handleMouseUp = (event: MouseEvent) => { - if (!selectOnMouseUpRef.current) return; - selectOnMouseUpRef.current = false; - event.preventDefault(); - }; - - // While editing, the browser's own menu and long-press (paste, select) win - // over DataTable's cell menu. - const stopWhileEditing = (event: MouseEvent | TouchEvent) => { - if (editing) event.stopPropagation(); - }; - - const hint = t("editRevertHint"); - return ( >({ getFailure={session.getFailure} confirming={state.status === "confirming"} > - {message !== undefined && ( - - {message}. {hint} - - )} - - { - composingRef.current = true; - }} - onCompositionEnd={handleCompositionEnd} - onMouseDown={handleMouseDown} - onMouseUp={handleMouseUp} - onPointerEnter={() => setHovered(true)} - onPointerLeave={() => setHovered(false)} - onContextMenu={stopWhileEditing} - onTouchStart={stopWhileEditing} - /> + session.begin(baselineText)} + onChange={(next) => { + const active = session.getState(); + const previous = active.status === "idle" ? baselineText : active.draft; + if (next !== previous) session.change(next, () => change(next)); + }} + onCommit={commitDraft} + onCancel={() => revert(true)} + onTab={(event) => { + const active = session.getState(); + const failedCommit = active.status === "failed" && active.source === "commit"; + if (!failedCommit && !commitDraft()) { + event.preventDefault(); + return; } - /> - - {message} - {hint} - - + if (navigation.move({ rowKey, colKey }, event.shiftKey ? "prev" : "next")) + event.preventDefault(); + }} + /> ); } diff --git a/packages/core/src/components/data-table/editing/use-session.test.tsx b/packages/core/src/components/data-table/editing/use-session.test.tsx index 34f385f58..a6b3c0295 100644 --- a/packages/core/src/components/data-table/editing/use-session.test.tsx +++ b/packages/core/src/components/data-table/editing/use-session.test.tsx @@ -54,7 +54,7 @@ describe("useCellEditSession", () => { }); act(() => { result.current.change("Changed", () => 42); - result.current.confirm(() => false, onSuccess); + expect(result.current.confirm(() => false, onSuccess)).toBeUndefined(); expect(result.current.getState()).toEqual({ status: "idle" }); }); expect(onSuccess).toHaveBeenCalledTimes(1); @@ -291,6 +291,62 @@ describe("useCellEditSession", () => { expect(commit).not.toHaveBeenCalled(); }); + it.each(["resolve", "reject"] as const)( + "ignores a late commit %s after unmount without requiring cancellation", + async (outcome) => { + const request = deferred(); + const onSuccess = vi.fn(); + const view = setup(); + act(() => { + view.result.current.change("Candidate"); + view.result.current.confirm(() => request.promise, onSuccess); + }); + const commands = view.result.current; + const retained = commands.getState(); + view.unmount(); + await settle(request, outcome === "reject" ? { error: new Error("Old failure") } : undefined); + commands.change("Detached"); + commands.cancel(); + expect(commands.getState()).toBe(retained); + expect(onSuccess).not.toHaveBeenCalled(); + }, + ); + + it.each(["check", "commit"] as const)( + "invalidates an outstanding %s on policy changes and updates retained commands", + async (phase) => { + const request = deferred(); + const view = renderHook((enabled: boolean) => useCellEditSession(enabled), { + initialProps: true, + wrapper: Wrapper, + }); + const commands = view.result.current; + const commit = vi.fn(() => (phase === "commit" ? request.promise : undefined)); + const onSuccess = vi.fn(); + act(() => { + commands.change("Candidate", phase === "check" ? () => request.promise : undefined); + commands.confirm(commit, onSuccess); + }); + view.rerender(false); + expect(view.result.current.state).toEqual({ status: "editing", draft: "Candidate" }); + expect(view.result.current.change).toBe(commands.change); + expect(view.result.current.confirm).toBe(commands.confirm); + await settle(request); + expect(commit).toHaveBeenCalledTimes(phase === "commit" ? 1 : 0); + expect(onSuccess).not.toHaveBeenCalled(); + expect(view.result.current.state).toEqual({ status: "editing", draft: "Candidate" }); + const error = new Error("Consumer-owned failure"); + act(() => { + expect(() => + commands.confirm(() => { + throw error; + }), + ).toThrow(error); + }); + expect(view.result.current.state).toEqual({ status: "idle" }); + }, + ); + it("supports thenables without making synchronous callbacks asynchronous", async () => { const { result } = setup(); await act(async () => { diff --git a/packages/core/src/components/data-table/editing/use-session.ts b/packages/core/src/components/data-table/editing/use-session.ts index 0c1991406..b3c4e62cd 100644 --- a/packages/core/src/components/data-table/editing/use-session.ts +++ b/packages/core/src/components/data-table/editing/use-session.ts @@ -1,171 +1,37 @@ -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 */ +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; +import { createCellEditSession } from "@/components/internals/table-core/cell-session"; + +/** + * Adapts one mounted DataTable cell to the React-independent edit session. + * Lazy initialization fixes its owner; useSyncExternalStore observes its cached state. + * Effect cleanup invalidates pending work on unmount or policy changes, while activation + * allows StrictMode effect replay to reuse the session without accepting stale callbacks. + * @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; - + const [session] = useState(() => createCellEditSession(enabled)); + const state = useSyncExternalStore(session.subscribe, session.getState, session.getState); useEffect(() => { - session.mounted = true; - return () => { - session.mounted = false; - session.revision++; - session.pendingChange = null; - }; + session.activate(enabled); + return session.deactivate; }, [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; + // Preserve the existing void callback contract; shared dropdowns await confirmation. + const confirm = useCallback( + (callback: () => unknown, onSuccess?: () => void) => { + void session.confirm(callback, onSuccess); + }, + [session], + ); + return { + state, + getState: session.getState, + getFailure: session.getFailure, + begin: session.begin, + change: session.change, + confirm, + confirmAsync: session.confirm, + getCancellationRevision: session.getCancellationRevision, + fail: session.fail, + cancel: session.cancel, }; - - return { state, getState: () => session.state, getFailure, begin, change, confirm, fail, cancel }; } diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index cd56a7d99..4a228883a 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -1,22 +1,29 @@ // DataTable compound component export { DataTable, type DataTablePaginationProps, type DataTableRootProps } from "./data-table"; -export { CellEditError } from "./cell-edit-error"; +export { CellEditError } from "@/components/internals/table-core/cell-edit-error"; export { useDataTable } from "./use-data-table"; export { useDataTableContext, type DataTableContextValue } from "./context"; // Field helpers export { createColumnHelper } from "./field-helpers"; +export type { + BadgeCellOptions, + ColumnCellType, + DateCellOptions, + LinkCellOptions, + MoneyCellOptions, + NumberCellOptions, +} from "@/components/internals/table-core/column-types"; + // Types — DataTable-specific export type { BadgeCellEditOptions, - BadgeCellOptions, BadgeVariant, CellEditState, Column, ColumnBase, - ColumnCellType, ColumnTypeBranch, HeaderRenderContext, FooterRenderContext, @@ -24,12 +31,8 @@ export type { DataTableFilterConfig, DateCellEditOptions, DateTimeDraft, - DateCellOptions, - LinkCellOptions, MetadataFieldOptions, - MoneyCellOptions, NumberCellEditOptions, - NumberCellOptions, DataTableAction, RowAction, RowSelectionOptions, diff --git a/packages/core/src/components/data-table/rows.tsx b/packages/core/src/components/data-table/rows.tsx index d1882954d..3747d0cf9 100644 --- a/packages/core/src/components/data-table/rows.tsx +++ b/packages/core/src/components/data-table/rows.tsx @@ -7,12 +7,13 @@ import { Button } from "@/components/button"; import { Checkbox } from "@/components/checkbox"; import { Menu } from "@/components/menu"; import { Tooltip } from "@/components/tooltip"; +import { ICON_SPACE_CLASS_NAME } from "@/components/internals/table-core/cell-styles"; import type { RowAction, RowExpansionOptions } from "./types"; -import { getCellValue, createTypedCellRenderer } from "./cell-renderers"; +import { getCellValue } from "./cell-values"; +import { resolveDataTableColumn } from "./column-adapter"; import { DataTableCellContextMenu, getCellContextValue } from "./context-menu"; import { useCellContextMenu } from "./use-cell-context-menu"; -import { DataTableEditableCell, hasEditorIcon, isEditableColumn } from "./editing/cell/root"; -import { ICON_SPACE_CLASS_NAME } from "./editing/cell/shared"; +import { DataTableEditableCell } from "./editing/cell/root"; import { useCellEditNavigation } from "./editing/use-navigation"; import { RowExpandToggle, DataTableExpandedRow } from "./expanded-row"; import { useDataTableT } from "./i18n"; @@ -62,13 +63,8 @@ function DataTableRows>({ const hasExpand = !!rowExpansion; const { ordered, keys, placements, selection, expand, actions } = pinLayout; // Intl formatter construction is costly; avoid repeating it for every cell render. - const cellRenderers = useMemo( - () => - new Map( - ordered - .filter((col) => !col.render) - .map((col) => [col, createTypedCellRenderer(col, timeZone)]), - ), + const columnAdapters = useMemo( + () => new Map(ordered.map((col) => [col, resolveDataTableColumn(col, timeZone)])), [ordered, timeZone], ); const { @@ -91,7 +87,8 @@ function DataTableRows>({ // In a table people edit, every data cell says what a click does: editors // show a text or pointer cursor, and the rest show "not allowed". Skipped when // rows are clickable, where the row click is what the cursor should promise. - const blockReadOnlyCells = !onClickRow && (ordered?.some(isEditableColumn) ?? false); + const blockReadOnlyCells = + !onClickRow && ordered.some((col) => !!columnAdapters.get(col)?.editor); const warnedMissingIdRef = useRef(false); return ( @@ -176,7 +173,9 @@ function DataTableRows>({ })()} {ordered?.map((col) => { const key = keys.get(col) as string; - const editableColumn = isEditableColumn(col); + const adapter = columnAdapters.get(col)!; + const editor = adapter.editor; + const editableColumn = !!editor; if (editableColumn && rowId == null && !warnedMissingIdRef.current) { warnedMissingIdRef.current = true; console.warn( @@ -186,13 +185,12 @@ function DataTableRows>({ const editable = editableColumn && rowId != null && - // A badge dropdown picks one value; a cell holding several stays - // read-only rather than have its list replaced by a single pick. - !(col.type === "badge" && Array.isArray(getCellValue(row, col))) && + editor.canEditValue(getCellValue(row, col)) && (col.edit?.canEdit?.(row, { selected }) ?? true); - // An editable cell renders its own display; skip rendering it twice. - let content: ReactNode; - if (!editable) content = col.render ? col.render(row) : cellRenderers.get(col)?.(row); + const display = col.render + ? col.render(row) + : adapter.renderValue(getCellValue(row, col), row, { linkAsText: editable }); + const content: ReactNode = editable ? undefined : display; const customCellProps = col.cellProps?.(row); const { style: cellStyle, className: cellClassName } = pinCellProps( @@ -216,7 +214,7 @@ function DataTableRows>({ // Truncate via an inner element so the cell's overflow stays visible. // A read-only cell of a dropdown / date column keeps the icon's // space like its editable neighbours, so the column's width never shifts. - const iconSpace = editableColumn && hasEditorIcon(col); + const iconSpace = !!editor?.icon; const cellBody = col.truncate || iconSpace ? ( >({ label={headerLabel} align={resolveAlign(col)} navigation={navigation} - dateRenderer={col.type === "date" ? cellRenderers.get(col) : undefined} + editor={editor!} + display={display} tooltipLabel={tooltipLabel} cellProps={cellProps} /> diff --git a/packages/core/src/components/data-table/status-rows.tsx b/packages/core/src/components/data-table/status-rows.tsx index f17b05996..89200a0bd 100644 --- a/packages/core/src/components/data-table/status-rows.tsx +++ b/packages/core/src/components/data-table/status-rows.tsx @@ -1,6 +1,7 @@ import type { ReactNode } from "react"; import { cn } from "@/lib/utils"; import { Table } from "@/components/table"; +import { resolveDataTableColumn } from "./column-adapter"; import { ACTIONS_WIDTH, EXPAND_WIDTH, @@ -37,6 +38,9 @@ function DataTableLoaderRows>({ hasRowActions, }: DataTableLoaderRowsProps) { const { ordered: columns, keys, placements, selection, expand, actions } = pinLayout; + const skeletonClasses = new Map( + columns.map((col) => [col, resolveDataTableColumn(col, "UTC").skeletonClassName]), + ); // No fixed row height: each cell's placeholder matches the height of the // real content it stands in for (text line, badge, icon button), so the // skeleton rows resolve to exactly the same row height as loaded rows and @@ -78,7 +82,6 @@ function DataTableLoaderRows>({ {columns?.map((col, colIndex) => { const key = keys.get(col) as string; const skeletonWidth = SKELETON_WIDTHS[(rowIndex + colIndex) % SKELETON_WIDTHS.length]; - const isBadge = col.type === "badge"; const { style, className } = pinCellProps( placements.get(col), { style: col.width ? { width: col.width } : undefined }, @@ -89,10 +92,7 @@ function DataTableLoaderRows>({
; + popupRef: RefObject; + containsTarget: (target: EventTarget | null) => boolean; +} + +function DefaultCellErrorContent({ message }: { message: string }) { + return ( +
+ + + {message} +
+ ); +} + +/** Error presentation and the portaled inspector form one logical focus group. */ +export function useCellError({ + failure, + message, + renderContent, +}: { + failure: CellSurfaceFailure | undefined; + message: string; + renderContent?: () => ReactNode; +}) { + const id = useId(); + const triggerRef = useRef(null); + const popupRef = useRef(null); + const containsTarget = (target: EventTarget | null) => + target instanceof Node && + !!(triggerRef.current?.contains(target) || popupRef.current?.contains(target)); + const error: CellErrorUI | undefined = failure + ? { + failure, + id, + message, + content: renderContent ? renderContent() : , + triggerRef, + popupRef, + containsTarget, + } + : undefined; + return { error, containsTarget }; +} diff --git a/packages/core/src/components/internals/table-core/cell-renderers.test.tsx b/packages/core/src/components/internals/table-core/cell-renderers.test.tsx index 3f71c41f3..98419c2eb 100644 --- a/packages/core/src/components/internals/table-core/cell-renderers.test.tsx +++ b/packages/core/src/components/internals/table-core/cell-renderers.test.tsx @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router"; -import { createValueRenderer } from "./cell-renderers"; +import { createDisplayFactories, createValueRenderer, getDefaultAlignment } from "./cell-renderers"; +import type { ColumnCellType, ValueDisplayDescriptor } from "./column-types"; afterEach(() => { cleanup(); @@ -9,6 +10,26 @@ afterEach(() => { }); describe("neutral value rendering", () => { + it("keeps default alignment with each display factory", () => { + const factories = createDisplayFactories>(); + for (const [type, factory] of Object.entries(factories)) { + expect(getDefaultAlignment(type as ColumnCellType)).toBe(factory.align); + } + expect(getDefaultAlignment("number")).toBe("right"); + expect(getDefaultAlignment("money")).toBe("right"); + expect(getDefaultAlignment("text")).toBe("left"); + expect(getDefaultAlignment(undefined)).toBe("left"); + }); + + it.each(["toString", "constructor", "__proto__", "hasOwnProperty", "unknown"])( + "retains the text fallback for unknown runtime kind %s", + (type) => { + const descriptor = { type } as ValueDisplayDescriptor>; + expect(createValueRenderer(descriptor, "UTC")("Value", {})).toBe("Value"); + expect(getDefaultAlignment(type as ColumnCellType)).toBe("left"); + }, + ); + it("renders the supplied value without looking up row properties or edit choices", () => { const descriptor = { type: "text" as const, @@ -70,6 +91,19 @@ describe("neutral value rendering", () => { expect(screen.getByText("Resolved label")).toBeDefined(); }); + it("suppresses only link interactivity without consulting its href", () => { + const href = vi.fn(() => "/rows/A"); + const renderer = createValueRenderer({ type: "link", typeOptions: { href } }, "UTC", { + linkAsText: true, + }); + render(<>{renderer("Resolved label", {})}); + expect(screen.queryByRole("link")).toBeNull(); + expect(screen.getByText("Resolved label")).toBeDefined(); + expect(renderer(false, {})).toBe("✗"); + expect(href).not.toHaveBeenCalled(); + expect(createValueRenderer({ type: "text" }, "UTC", { linkAsText: true })(false, {})).toBe("✗"); + }); + it("keeps independent date-only and zoned formatter caches across rows", () => { const DateTimeFormat = Intl.DateTimeFormat; const spy = vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locale, options) { diff --git a/packages/core/src/components/internals/table-core/cell-renderers.tsx b/packages/core/src/components/internals/table-core/cell-renderers.tsx index a90f76b85..9216b16af 100644 --- a/packages/core/src/components/internals/table-core/cell-renderers.tsx +++ b/packages/core/src/components/internals/table-core/cell-renderers.tsx @@ -5,6 +5,7 @@ import { BadgeList, toValueArray } from "@/components/badge-list"; import { normalizeTemporalFilterValue } from "@/lib/temporal-filter-values"; import type { BadgeCellOptions, + ColumnCellType, DateCellOptions, LinkCellOptions, MoneyCellOptions, @@ -15,6 +16,8 @@ import type { export interface ValueRendererOptions { /** Optional, already-resolved display label policy for individual badge values. */ resolveBadgeLabel?: (value: string) => string; + /** Keep an editable link's value visible without creating a navigation target. */ + linkAsText?: boolean; } const PLACEHOLDER = ( @@ -164,24 +167,86 @@ function renderLink>( ); } +function withAlignment unknown>( + align: "left" | "right", + factory: TFactory, +) { + return Object.assign(factory, { align }); +} + +/** Factory signatures define display options and accepted values; metadata owns defaults. */ +export function createDisplayFactories>() { + return { + text: withAlignment("left", (_config: { typeOptions?: never }) => { + return (value: string | number | boolean | bigint | null | undefined, _row: TRow) => + renderText(value); + }), + number: withAlignment("right", (config: { typeOptions?: NumberCellOptions }) => { + return (value: number | null | undefined, _row: TRow) => + renderNumber(value, config.typeOptions); + }), + money: withAlignment("right", (config: { typeOptions?: MoneyCellOptions }) => { + return (value: number | null | undefined, row: TRow) => + renderMoney(value, row, config.typeOptions); + }), + date: withAlignment("left", (config: { typeOptions?: DateCellOptions }, timeZone: string) => { + const renderDate = createDateRenderer(config.typeOptions, timeZone); + return (value: Date | string | number | null | undefined, _row: TRow) => renderDate(value); + }), + badge: withAlignment( + "left", + ( + config: { typeOptions?: BadgeCellOptions }, + _timeZone: string, + options: ValueRendererOptions, + ) => { + return (value: string | string[] | number | boolean | null | undefined, _row: TRow) => + renderBadge(value, config.typeOptions, options.resolveBadgeLabel); + }, + ), + link: withAlignment( + "left", + ( + config: { typeOptions: LinkCellOptions }, + _timeZone: string, + options: ValueRendererOptions, + ) => { + return (value: string | number | boolean | null | undefined, row: TRow) => + options.linkAsText ? renderText(value) : renderLink(value, row, config.typeOptions); + }, + ), + } satisfies Record unknown>; +} + +type RuntimeDisplayFactory = { + ( + config: Record, + timeZone: string, + options: ValueRendererOptions, + ): (value: unknown, row: Record) => ReactNode; + align: "left" | "right"; +}; + +// Only runtime dispatch erases the field-dependent factory signatures. +const displayFactories = createDisplayFactories>() as unknown as Record< + ColumnCellType, + RuntimeDisplayFactory +>; + +/** Read layout defaults from the same definitions used to construct display renderers. */ +export function getDefaultAlignment(type: ColumnCellType | undefined): "left" | "right" { + return type !== undefined && Object.hasOwn(displayFactories, type) + ? displayFactories[type].align + : "left"; +} + /** Build once per display/timezone definition so date formatters are shared across rows. */ export function createValueRenderer>( descriptor: ValueDisplayDescriptor, timeZone: string, options: ValueRendererOptions = {}, ): (value: unknown, row: TRow) => ReactNode { - switch (descriptor.type) { - case "number": - return (value) => renderNumber(value, descriptor.typeOptions); - case "money": - return (value, row) => renderMoney(value, row, descriptor.typeOptions); - case "date": - return createDateRenderer(descriptor.typeOptions, timeZone); - case "badge": - return (value) => renderBadge(value, descriptor.typeOptions, options.resolveBadgeLabel); - case "link": - return (value, row) => renderLink(value, row, descriptor.typeOptions); - default: - return renderText; - } + const type = descriptor.type; + if (type === undefined || !Object.hasOwn(displayFactories, type)) return renderText; + return displayFactories[type](descriptor, timeZone, options); } diff --git a/packages/core/src/components/internals/table-core/cell-session.test.ts b/packages/core/src/components/internals/table-core/cell-session.test.ts new file mode 100644 index 000000000..84dff2671 --- /dev/null +++ b/packages/core/src/components/internals/table-core/cell-session.test.ts @@ -0,0 +1,234 @@ +// @vitest-environment node +import { describe, expect, it, vi } from "vitest"; +import { createCellEditSession } from "./cell-session"; + +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((success, failure) => { + resolve = success; + reject = failure; + }); + return { promise, resolve, reject }; +} + +async function settle(request: ReturnType, failure?: { error: unknown }) { + if (failure) request.reject(failure.error); + else request.resolve(); + await request.promise.catch(() => undefined); +} + +describe("React-independent cell edit sessions", () => { + it("caches its state and publishes adopted snapshots, not reads or no-ops", async () => { + const session = createCellEditSession(); + const initial = session.getState(); + const listener = vi.fn(() => { + expect(session.getState()).toEqual({ status: "editing", draft: "Memo" }); + }); + const detach = session.subscribe(listener); + expect(session.getState()).toBe(initial); + expect(session.getFailure()).toBeUndefined(); + session.fail("No draft", "parse"); + const commit = vi.fn(); + await expect(session.confirm(commit)).resolves.toBe(true); + expect(commit).not.toHaveBeenCalled(); + expect(listener).not.toHaveBeenCalled(); + const begin = session.begin; + begin("Memo"); + const editing = session.getState(); + expect(editing).not.toBe(initial); + begin("Must not replace Memo"); + expect(session.getState()).toBe(editing); + expect(listener).toHaveBeenCalledTimes(1); + detach(); + detach(); + session.cancel(); + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("retains a draft and its failure when a view detaches and resubscribes", () => { + const session = createCellEditSession(); + const firstView = vi.fn(); + const detach = session.subscribe(firstView); + session.change("-"); + session.fail("Not a number", "parse"); + const failed = session.getState(); + detach(); + const secondView = vi.fn(); + session.subscribe(secondView); + expect(session.getState()).toBe(failed); + expect(session.getFailure()).toBe(failed); + const commit = vi.fn(); + session.confirm(commit); + expect(commit).not.toHaveBeenCalled(); + session.change("2"); + session.confirm(commit); + expect(session.getState()).toEqual({ status: "idle" }); + expect(commit).toHaveBeenCalledTimes(1); + expect(firstView).toHaveBeenCalledTimes(2); + expect(secondView).toHaveBeenCalledTimes(3); + }); + + it("uses the current listener set for each notification when subscriptions change", () => { + const session = createCellEditSession(); + const late = vi.fn(); + const second = vi.fn(); + let detachSecond!: () => void; + const first = vi.fn(() => { + detachSecond(); + session.subscribe(late); + }); + session.subscribe(first); + detachSecond = session.subscribe(second); + session.begin("Memo"); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + expect(late).not.toHaveBeenCalled(); + session.change("Changed"); + expect(second).toHaveBeenCalledTimes(1); + expect(late).toHaveBeenCalledTimes(1); + }); + + it.each(["resolve", "reject"] as const)( + "does not cancel a request when its view disappears before a %s", + async (outcome) => { + const session = createCellEditSession(); + const request = deferred(); + const listener = vi.fn(); + const detach = session.subscribe(listener); + const complete = vi.fn(); + session.begin("Memo"); + session.confirm(() => request.promise, complete); + detach(); + const error = new Error("Save failed"); + await settle(request, outcome === "reject" ? { error } : undefined); + expect(session.getState()).toEqual( + outcome === "resolve" + ? { status: "idle" } + : { status: "failed", draft: "Memo", error, source: "commit" }, + ); + expect(complete).toHaveBeenCalledTimes(outcome === "resolve" ? 1 : 0); + expect(listener).toHaveBeenCalledTimes(2); + }, + ); + + it("keeps synchronous confirmation synchronous while returning its awaitable result", async () => { + const session = createCellEditSession(); + session.begin("Memo"); + const complete = vi.fn(() => expect(session.getState()).toEqual({ status: "idle" })); + const result = session.confirm(() => false, complete); + expect(complete).toHaveBeenCalledTimes(1); + expect(session.getState()).toEqual({ status: "idle" }); + await expect(result).resolves.toBe(true); + }); + + it("waits for a change check before committing and deduplicates confirmation", async () => { + const session = createCellEditSession(); + const check = deferred(); + const save = deferred(); + const commit = vi.fn(() => save.promise); + const duplicate = vi.fn(); + session.change("Memo", () => check.promise); + session.confirm(commit); + session.confirm(duplicate); + expect(commit).not.toHaveBeenCalled(); + await settle(check); + expect(commit).toHaveBeenCalledTimes(1); + expect(session.getState()).toEqual({ status: "confirming", draft: "Memo" }); + session.confirm(duplicate); + await settle(save); + expect(session.getState()).toEqual({ status: "idle" }); + expect(duplicate).not.toHaveBeenCalled(); + }); + + it.each(["check", "commit"] as const)( + "retains a rejected %s draft and retries only a commit failure", + async (phase) => { + const session = createCellEditSession(); + const request = deferred(); + session.change("Memo", phase === "check" ? () => request.promise : undefined); + const commit = vi.fn(() => (phase === "commit" ? request.promise : undefined)); + session.confirm(commit); + const error = new Error("Rejected"); + await settle(request, { error }); + expect(session.getState()).toEqual({ + status: "failed", + draft: "Memo", + error, + source: phase === "check" ? "change" : "commit", + }); + const retry = vi.fn(); + session.confirm(retry); + expect(retry).toHaveBeenCalledTimes(phase === "commit" ? 1 : 0); + if (phase === "check") session.change("Corrected"); + else expect(session.getState()).toEqual({ status: "idle" }); + }, + ); + + it.each(["cancel", "deactivate"] as const)( + "does not start a waiting commit after %s", + async (action) => { + const session = createCellEditSession(); + const check = deferred(); + const commit = vi.fn(); + session.change("Memo", () => check.promise); + session.confirm(commit); + session[action](); + const retained = session.getState(); + await settle(check); + expect(commit).not.toHaveBeenCalled(); + expect(session.getState()).toBe(retained); + }, + ); + + it.each(["check", "commit"] as const)( + "reactivates an invalidated %s with its draft intact for explicit retry", + async (phase) => { + const session = createCellEditSession(); + const request = deferred(); + const complete = vi.fn(); + const commit = vi.fn(() => (phase === "commit" ? request.promise : undefined)); + session.change("Memo", phase === "check" ? () => request.promise : undefined); + session.confirm(commit, complete); + session.deactivate(); + session.activate(true); + expect(session.getState()).toEqual({ status: "editing", draft: "Memo" }); + const retry = deferred(); + session.confirm(() => retry.promise); + await settle(request); + expect(session.getState()).toEqual({ status: "confirming", draft: "Memo" }); + expect(commit).toHaveBeenCalledTimes(phase === "commit" ? 1 : 0); + expect(complete).not.toHaveBeenCalled(); + await settle(retry); + expect(session.getState()).toEqual({ status: "idle" }); + }, + ); + + it("does not reset a live confirmation when an active owner activates again", async () => { + const session = createCellEditSession(); + const request = deferred(); + session.begin("Memo"); + session.confirm(() => request.promise); + const confirming = session.getState(); + session.activate(true); + expect(session.getState()).toBe(confirming); + await settle(request); + expect(session.getState()).toEqual({ status: "idle" }); + }); + + it("ignores retained commands after deactivation without notifying", () => { + const session = createCellEditSession(); + session.begin("Memo"); + const retained = session.getState(); + const listener = vi.fn(); + session.subscribe(listener); + session.deactivate(); + session.begin("Detached"); + session.change("Detached", listener); + session.fail("Detached", "parse"); + session.cancel(listener); + session.confirm(listener); + expect(session.getState()).toBe(retained); + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/internals/table-core/cell-session.ts b/packages/core/src/components/internals/table-core/cell-session.ts new file mode 100644 index 000000000..60f04d485 --- /dev/null +++ b/packages/core/src/components/internals/table-core/cell-session.ts @@ -0,0 +1,216 @@ +/** Callback/parse failure belongs to the logical edit, not its mounted control. */ +export interface CellEditFailure { + error: unknown; + source: "parse" | "change" | "commit"; +} + +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 one logical edit without React or committed row state. getState is a cached + * useSyncExternalStore snapshot, adopted before synchronous listener notification. + * Detaching a view does not cancel its draft or callbacks; the owner explicitly + * activates/deactivates the session at its own lifetime boundary. + * + * confirm preserves synchronous callback timing while returning an awaitable result. + * False means failure or invalidation, not callback return values. Private revisions + * reject stale completions; cancellation tokens also dismiss portaled editor UI. + * @internal + */ +export function createCellEditSession(interceptErrors = true) { + let state: CellEditSessionState = { status: "idle" }; + let revision = 0; + let cancellationRevision = 0; + let active = true; + let enabled = interceptErrors; + let pendingChange: Promise | null = null; + let confirmation: { promise: Promise; resolve: (success: boolean) => void } | null = + null; + const listeners = new Set<() => void>(); + + function transition(next: CellEditSessionState) { + state = next; + // oxlint-disable-next-line unicorn/no-useless-spread -- Subscribers can detach or resubscribe during notification. + for (const listener of [...listeners]) listener(); + } + function invalidate() { + revision++; + pendingChange = null; + confirmation?.resolve(false); + confirmation = null; + } + const isCurrent = (candidate: number) => active && revision === candidate; + + function begin(draft: TDraft) { + if (!active || state.status !== "idle") return; + invalidate(); + transition({ status: "editing", draft }); + } + + function change(draft: TDraft, callback?: () => unknown) { + if (!active) return; + invalidate(); + const candidate = revision; + transition({ status: "editing", draft }); + if (!callback) return; + if (!enabled) { + callback(); + return; + } + function reject(error: unknown) { + if (isCurrent(candidate)) transition({ status: "failed", draft, error, source: "change" }); + } + try { + const result = callback(); + if (isPromiseLike(result)) { + const pending = Promise.resolve(result).then( + () => { + if (isCurrent(candidate) && pendingChange === pending) pendingChange = null; + return true; + }, + (error: unknown) => { + if (isCurrent(candidate) && pendingChange === pending) pendingChange = null; + reject(error); + return false; + }, + ); + if (isCurrent(candidate)) pendingChange = pending; + } + } catch (error) { + reject(error); + } + } + + function discard() { + cancellationRevision++; + invalidate(); + transition({ status: "idle" }); + } + + function confirm(callback: () => unknown, onSuccess?: () => void): Promise { + if (!active) return Promise.resolve(false); + const current = state; + if (current.status === "idle") return Promise.resolve(true); + if (current.status === "confirming") return confirmation?.promise ?? Promise.resolve(false); + if (current.status === "failed" && current.source !== "commit") return Promise.resolve(false); + if (!enabled) { + discard(); + onSuccess?.(); + callback(); + return Promise.resolve(true); + } + + const { draft } = current; + const candidate = revision; + const waiting = pendingChange; + let resolve!: (success: boolean) => void; + const promise = new Promise((done) => { + resolve = done; + }); + const attempt = { promise, resolve }; + confirmation = attempt; + transition({ status: "confirming", draft }); + const isConfirming = () => + isCurrent(candidate) && state.status === "confirming" && confirmation === attempt; + function finish(success: boolean) { + if (confirmation === attempt) confirmation = null; + resolve(success); + } + function complete() { + if (!isConfirming()) return; + transition({ status: "idle" }); + finish(true); + onSuccess?.(); + } + function reject(error: unknown) { + if (!isConfirming()) return; + transition({ status: "failed", draft, error, source: "commit" }); + finish(false); + } + function 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 (waiting) { + void waiting.then((valid) => { + if (valid) run(); + else finish(false); + }); + } else run(); + return promise; + } + + function fail(error: unknown, source: "parse") { + if (!active || state.status === "idle") return; + const { draft } = state; + invalidate(); + transition({ status: "failed", draft, error, source }); + } + + function cancel(callback?: () => unknown) { + if (!active) return; + discard(); + if (!callback) return; + if (!enabled) { + callback(); + return; + } + // Restoration cannot create a failure for the draft that was just discarded. + try { + const result = callback(); + if (isPromiseLike(result)) void Promise.resolve(result).catch(() => {}); + } catch { + // There is no longer a draft to attach a restoration error to. + } + } + + return { + getState: () => state, + getFailure: () => (state.status === "failed" ? state : undefined), + /** Token for checking whether an asynchronous adoption still belongs to this edit. */ + getRevision: () => revision, + /** Changes only on discard, so views can close stale popups without losing newer edits. */ + getCancellationRevision: () => cancellationRevision, + subscribe: (listener: () => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + begin, + change, + confirm, + fail, + cancel, + activate: (intercept: boolean) => { + enabled = intercept; + active = true; + if (state.status === "confirming" && confirmation === null) + transition({ status: "editing", draft: state.draft }); + }, + deactivate: () => { + active = false; + invalidate(); + }, + }; +} + +export type CellEditSession = ReturnType>; diff --git a/packages/core/src/components/internals/table-core/cell-styles.ts b/packages/core/src/components/internals/table-core/cell-styles.ts new file mode 100644 index 000000000..b95109709 --- /dev/null +++ b/packages/core/src/components/internals/table-core/cell-styles.ts @@ -0,0 +1,42 @@ +import { cn } from "@/lib/utils"; + +/** Space reserved by dropdown/date/error icons, including read-only cells. */ +export const ICON_SPACE_CLASS_NAME = "astw:pr-5"; + +// Overlay editors inherit td padding without changing the table's geometry. +export const OVERLAY_CLASS_NAME = + "astw:absolute astw:inset-0 astw:h-full astw:w-full astw:p-[inherit] astw:rounded-none astw:border-0 astw:shadow-none astw:bg-transparent astw:dark:bg-transparent"; + +export const CELL_FOCUS_CLASS_NAME = cn( + "astw:outline-none astw:focus:ring-2 astw:focus:ring-inset astw:focus:ring-primary", + "astw:focus-visible:ring-2 astw:focus-visible:ring-inset astw:focus-visible:ring-primary", +); + +export const CELL_ERROR_CLASS_NAME = + "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 image tint preserves pinned/selected/consumer backgrounds underneath it. +export const EDITABLE_CELL_CLASS_NAME = cn( + "astw:[background-image:linear-gradient(color-mix(in_srgb,var(--foreground)_4%,transparent),color-mix(in_srgb,var(--foreground)_4%,transparent))]", + "astw:hover:[background-image:linear-gradient(color-mix(in_srgb,var(--foreground)_7%,transparent),color-mix(in_srgb,var(--foreground)_7%,transparent))]", +); + +export const INPUT_CLASS_NAME = cn( + OVERLAY_CLASS_NAME, + CELL_FOCUS_CLASS_NAME, + "astw:text-body-md astw:cursor-text", +); + +export const ICON_INPUT_CLASS_NAME = "astw:pr-7 astw:[td:last-child>&]:pr-11"; + +export const CELL_ICON_CLASS_NAME = cn( + "astw:pointer-events-none astw:absolute astw:inset-y-0 astw:right-2 astw:flex astw:items-center astw:[td:last-child>&]:right-6", + "astw:text-muted-foreground astw:opacity-0 astw:transition-opacity", + "astw:group-hover/cell:opacity-30 astw:group-focus-within/cell:opacity-30", + "astw:[&_svg]:size-3.5", +); + +export const POPUP_CLASS_NAME = cn( + "astw:bg-popover astw:text-popover-foreground astw:z-(--z-popup) astw:flex astw:flex-col astw:gap-3 astw:rounded-md astw:border astw:border-border astw:p-3 astw:shadow-md", + "astw:animate-in astw:fade-in-0 astw:zoom-in-95 astw:data-ending-style:animate-out astw:data-ending-style:zoom-out-95", +); diff --git a/packages/core/src/components/internals/table-core/cell-surface.test.tsx b/packages/core/src/components/internals/table-core/cell-surface.test.tsx new file mode 100644 index 000000000..1d8783dcd --- /dev/null +++ b/packages/core/src/components/internals/table-core/cell-surface.test.tsx @@ -0,0 +1,157 @@ +import { type ComponentProps } from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Table } from "@/components/table"; +import { useCellError, type CellSurfaceFailure } from "./cell-error"; +import { EditableCellSurface } from "./cell-surface"; + +const labels = { + errorHint: "Inspect the error", + showError: "Show quantity error", + errorDialog: "Quantity error details", + closeError: "Close details", +}; + +afterEach(cleanup); + +function Fixture({ + failure, + cellProps = {}, + onLeave, + onCancel = () => {}, + onRowClick, + renderError, + clearTintOnFocus, +}: { + clearTintOnFocus?: boolean; + failure?: CellSurfaceFailure; + cellProps?: ComponentProps; + onLeave?: () => void; + onCancel?: () => void; + onRowClick?: () => void; + renderError?: () => React.ReactNode; +}) { + const { error } = useCellError({ + failure, + message: "Quantity was rejected", + renderContent: renderError, + }); + return ( + + + + failure} + onEditorLeave={onLeave} + onEditorCancel={onCancel} + > + + + + +
+ ); +} + +describe("EditableCellSurface without a table adapter or AppShell provider", () => { + it("owns exactly one td and composes adapter cell handlers without firing row clicks", () => { + const click = vi.fn(); + const keyDown = vi.fn(); + const keyCapture = vi.fn(); + const blur = vi.fn(); + const leave = vi.fn(); + const rowClick = vi.fn(); + render( + , + ); + const input = screen.getByRole("textbox", { name: "Quantity" }); + expect(screen.getAllByRole("cell")).toHaveLength(1); + expect(input.closest("td")?.hasAttribute("data-editable")).toBe(true); + fireEvent.click(input); + expect(click).toHaveBeenCalledTimes(1); + expect(rowClick).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "ArrowRight" }); + expect(keyCapture).toHaveBeenCalledTimes(1); + expect(keyDown).toHaveBeenCalledTimes(1); + fireEvent.blur(input, { relatedTarget: document.body }); + expect(blur).toHaveBeenCalledTimes(1); + expect(leave).toHaveBeenCalledTimes(1); + }); + + it.each([undefined, false])( + "keeps the tint while focused only when the adapter opts in (%s)", + (clearTintOnFocus) => { + render(); + const input = screen.getByRole("textbox", { name: "Quantity" }); + input.focus(); + const cell = input.closest("td")!; + expect(cell.className).toContain("background-image:linear-gradient"); + expect(cell.className.includes("astw:focus-within:[background-image:none]")).toBe( + clearTintOnFocus !== false, + ); + expect(document.activeElement).toBe(input); + }, + ); + + it("keeps localized error content and portaled controls in the same logical focus group", async () => { + const leave = vi.fn(); + const cancel = vi.fn(); + render(); + const input = screen.getByRole("textbox", { name: "Quantity" }); + const popup = await screen.findByRole("dialog", { name: labels.errorDialog }); + expect(popup.getAttribute("data-slot")).toBe("fixture-cell-error"); + expect(within(popup).getByText("Quantity was rejected")).toBeDefined(); + expect(screen.getByRole("button", { name: labels.showError })).toBeDefined(); + const description = document.getElementById(input.getAttribute("aria-describedby")!); + expect(description?.textContent).toBe("Quantity was rejected. Inspect the error"); + const close = within(popup).getByRole("button", { name: labels.closeError }); + fireEvent.blur(input, { relatedTarget: close }); + expect(leave).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Escape" }); + await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull()); + expect(cancel).not.toHaveBeenCalled(); + fireEvent.keyDown(input, { key: "Escape" }); + expect(cancel).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: "ArrowDown", altKey: true }); + expect(await screen.findByRole("dialog", { name: labels.errorDialog })).toBeDefined(); + }); + + it("preserves an intentionally empty custom error body rather than replacing it", async () => { + const renderError = vi.fn(() => null); + render(); + const popup = await screen.findByRole("dialog", { name: labels.errorDialog }); + expect(within(popup).queryByText("Quantity was rejected")).toBeNull(); + expect(renderError).toHaveBeenCalled(); + expect(within(popup).getAllByRole("button")).toEqual([ + within(popup).getByRole("button", { name: labels.closeError }), + ]); + }); + + it("honors a prevented adapter leave instead of confirming the cell", () => { + const leave = vi.fn(); + const onBlur = vi.fn((event: React.FocusEvent) => event.preventDefault()); + render(); + fireEvent.blur(screen.getByRole("textbox", { name: "Quantity" }), { + relatedTarget: document.body, + }); + expect(onBlur).toHaveBeenCalledTimes(1); + expect(leave).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/internals/table-core/cell-surface.tsx b/packages/core/src/components/internals/table-core/cell-surface.tsx new file mode 100644 index 000000000..999930ad6 --- /dev/null +++ b/packages/core/src/components/internals/table-core/cell-surface.tsx @@ -0,0 +1,239 @@ +import { useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from "react"; +import { Popover } from "@base-ui/react/popover"; +import { CircleAlert, X } from "lucide-react"; +import { Button } from "@/components/button"; +import { Table } from "@/components/table"; +import { Tooltip } from "@/components/tooltip"; +import { cn } from "@/lib/utils"; +import type { CellErrorUI, CellSurfaceFailure } from "./cell-error"; +import { + CELL_ERROR_CLASS_NAME, + CELL_ICON_CLASS_NAME, + EDITABLE_CELL_CLASS_NAME, + ICON_SPACE_CLASS_NAME, + POPUP_CLASS_NAME, +} from "./cell-styles"; + +export interface CellSurfaceLabels { + errorHint: string; + showError: string; + errorDialog: string; + closeError: string; +} + +export interface EditableCellSurfaceProps { + cellProps: ComponentProps; + display: ReactNode; + truncate?: boolean; + tooltipLabel?: ReactNode; + editing: boolean; + /** Immediate checkbox actions retain the tint while their control stays focused. */ + clearTintOnFocus?: boolean; + callbackError?: CellErrorUI; + labels: CellSurfaceLabels; + errorSlot?: string; + onEditorLeave?: () => void; + onEditorCancel: () => void; + getFailure: () => CellSurfaceFailure | undefined; + confirming: boolean; + hideDisplay?: "focused" | "forced-colors"; + icon?: ReactNode; + children: ReactNode; +} + +/** Owns exactly one td; adapters supply resolved content, labels and editing policy. */ +export function EditableCellSurface({ + cellProps, + display, + truncate, + hideDisplay, + icon, + tooltipLabel, + editing, + clearTintOnFocus = true, + callbackError, + labels, + errorSlot = "table-cell-error", + onEditorLeave, + onEditorCancel, + getFailure, + confirming, + children, +}: EditableCellSurfaceProps) { + const [tooltipOpen, setTooltipOpen] = useState(false); + 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 != null && !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 = ( + { + cellProps.onBlur?.(event); + if (event.defaultPrevented) return; + if ( + event.currentTarget.contains(event.relatedTarget) || + callbackError?.containsTarget(event.relatedTarget) + ) + return; + if (tabNavigationRef.current) dismissForNavigation(); + onEditorLeave?.(); + }} + onKeyDown={(event) => { + cellProps.onKeyDown?.(event); + if ( + event.key === "Tab" && + !event.defaultPrevented && + event.currentTarget.contains(event.target as Node) + ) + dismissForNavigation(); + tabNavigationRef.current = false; + }} + onKeyDownCapture={(event) => { + cellProps.onKeyDownCapture?.(event); + tabNavigationRef.current = + event.key === "Tab" && event.currentTarget.contains(event.target as Node); + if (event.defaultPrevented) return; + 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 }); + } + }} + onClick={(event) => { + cellProps.onClick?.(event); + event.stopPropagation(); + }} + > + + {children} + {callbackError && ( + <> + + {callbackError.message}. {labels.errorHint} + + { + setDismissedFailure(open ? null : callbackError.failure); + if (open) { + setNavigationDismissedFailure(null); + setManuallyOpenedFailure(callbackError.failure); + } + }} + > + event.stopPropagation()} + > + + + 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 && ( + + )} +
+ ); + return ( + + + {tooltipLabel} + + ); +} diff --git a/packages/core/src/components/internals/table-core/column-types.test-d.ts b/packages/core/src/components/internals/table-core/column-types.test-d.ts index 542f0c0c0..c9750d1e5 100644 --- a/packages/core/src/components/internals/table-core/column-types.test-d.ts +++ b/packages/core/src/components/internals/table-core/column-types.test-d.ts @@ -1,6 +1,14 @@ import { expectTypeOf, test } from "vitest"; import type { Column, ColumnBase, TextCellEditOptions } from "@/components/data-table/types"; -import type { ValueDisplayDescriptor } from "./column-types"; +import type { createDisplayFactories } from "./cell-renderers"; +import type { + ColumnCellType, + ColumnDisplay, + ColumnDisplayValues, + MoneyCellOptions, + TypedColumnDisplay, + ValueDisplayDescriptor, +} from "./column-types"; type Row = { id: string; amount: number; status: string }; @@ -13,6 +21,32 @@ test("DataTable columns remain compatible with the neutral display descriptor", >(); }); +test("display kinds, options and values follow specialized factory signatures", () => { + type Factories = ReturnType>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf[0]["typeOptions"]>().toEqualTypeOf< + MoneyCellOptions | undefined + >(); + expectTypeOf>().toEqualTypeOf< + { type: "money" } & Parameters[0] + >(); + expectTypeOf().toEqualTypeOf<{ + text: string | number | boolean | bigint | null | undefined; + number: number | null | undefined; + money: number | null | undefined; + date: Date | string | number | null | undefined; + badge: string | string[] | number | boolean | null | undefined; + link: string | number | boolean | null | undefined; + }>(); + expectTypeOf().toEqualTypeOf< + Parameters>[0] + >(); + expectTypeOf, { type: "number" }>>().toBeNever(); + expectTypeOf, { type: "money" }>>().toEqualTypeOf< + TypedColumnDisplay + >(); +}); + test("display descriptors retain the existing type/options contracts", () => { const link: ValueDisplayDescriptor = { type: "link", @@ -25,7 +59,12 @@ test("display descriptors retain the existing type/options contracts", () => { }; const money: ValueDisplayDescriptor = { type: "money", - typeOptions: { currency: (row) => row.status }, + typeOptions: { + currency: (row) => { + expectTypeOf(row).toEqualTypeOf(); + return row.status; + }, + }, }; // @ts-expect-error Links still require an href. const missingHref: ValueDisplayDescriptor = { type: "link" }; diff --git a/packages/core/src/components/internals/table-core/column-types.ts b/packages/core/src/components/internals/table-core/column-types.ts index 3d59e76a5..adc09daa3 100644 --- a/packages/core/src/components/internals/table-core/column-types.ts +++ b/packages/core/src/components/internals/table-core/column-types.ts @@ -1,7 +1,12 @@ import type { BadgeOptions } from "@/components/badge-list"; +import type { createDisplayFactories } from "./cell-renderers"; -/** Built-in display renderers, independent of how a component obtains or edits a value. */ -export type ColumnCellType = "text" | "number" | "money" | "date" | "badge" | "link"; +type DisplayFactories> = ReturnType< + typeof createDisplayFactories +>; + +/** Built-in display kinds come from their factory definitions. */ +export type ColumnCellType = keyof DisplayFactories>; /** Options for `type: "number"` cells. */ export interface NumberCellOptions { @@ -59,22 +64,14 @@ export interface LinkCellOptions> { /** Resolved presentation only: no accessor, edit choices, filters, or table state. */ export type ValueDisplayDescriptor> = | { type?: undefined; typeOptions?: never } - | { type: "text"; typeOptions?: never } - | { type: "number"; typeOptions?: NumberCellOptions } - | { type: "money"; typeOptions?: MoneyCellOptions } - | { type: "date"; typeOptions?: DateCellOptions } - | { type: "badge"; typeOptions?: BadgeCellOptions } - | { type: "link"; typeOptions: LinkCellOptions }; + | { + [K in ColumnCellType]: { type: K } & Parameters[K]>[0]; + }[ColumnCellType]; -/** Values accepted by each built-in display renderer. */ -export interface ColumnDisplayValues { - text: string | number | boolean | bigint | null | undefined; - number: number | null | undefined; - money: number | null | undefined; - date: Date | string | number | null | undefined; - badge: string | string[] | number | boolean | null | undefined; - link: string | number | boolean | null | undefined; -} +/** Accepted values are derived from each specialized factory's renderer signature. */ +export type ColumnDisplayValues = { + [K in ColumnCellType]: Parameters>[K]>>[0]; +}; export type TypedColumnDisplay< TRow extends Record, diff --git a/packages/core/src/components/internals/table-core/date-values.test.ts b/packages/core/src/components/internals/table-core/date-values.test.ts new file mode 100644 index 000000000..788876357 --- /dev/null +++ b/packages/core/src/components/internals/table-core/date-values.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { parseDateValue } from "./date-values"; + +describe("date-only editor values", () => { + it("preserves calendar days without converting through a timezone", () => { + expect(parseDateValue("2026-12-10")).toBe("2026-12-10"); + expect(parseDateValue("2024-02-29")).toBe("2024-02-29"); + expect(parseDateValue(null)).toBeNull(); + }); + it.each([undefined, "", "2026-02-29", "2026-12-32", "2026-1-2", "2026-12-10T00:00:00Z", 123])( + "rejects malformed dates %j", + (value) => { + expect(parseDateValue(value)).toBeUndefined(); + }, + ); + it("checks inclusive bounds and rejects invalid bound configuration", () => { + const bounds = { min: "2026-12-01", max: "2026-12-31" }; + expect(parseDateValue("2026-12-01", bounds)).toBe("2026-12-01"); + expect(parseDateValue("2026-12-31", bounds)).toBe("2026-12-31"); + expect(parseDateValue("2026-11-30", bounds)).toBeUndefined(); + expect(parseDateValue("2027-01-01", bounds)).toBeUndefined(); + expect(() => parseDateValue(null, { min: "bad" })).toThrow(); + expect(() => parseDateValue(null, { min: "2026-12-31", max: "2026-12-01" })).toThrow(); + }); +}); diff --git a/packages/core/src/components/internals/table-core/date-values.ts b/packages/core/src/components/internals/table-core/date-values.ts new file mode 100644 index 000000000..e1fdf92a0 --- /dev/null +++ b/packages/core/src/components/internals/table-core/date-values.ts @@ -0,0 +1,20 @@ +import { parseDate } from "@internationalized/date"; + +/** Date-only editor output: null clears, undefined is malformed or out of bounds. */ +export function parseDateValue( + value: unknown, + bounds: { min?: string; max?: string } = {}, +): string | null | undefined { + const min = bounds.min ? parseDate(bounds.min) : undefined; + const max = bounds.max ? parseDate(bounds.max) : undefined; + if (min && max && min.compare(max) > 0) throw new Error("Date minimum must not exceed maximum"); + if (value === null) return null; + if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return undefined; + try { + const date = parseDate(value); + if ((min && date.compare(min) < 0) || (max && date.compare(max) > 0)) return undefined; + return date.toString(); + } catch { + return undefined; + } +} diff --git a/packages/core/src/components/internals/table-core/editors/boolean.test.tsx b/packages/core/src/components/internals/table-core/editors/boolean.test.tsx new file mode 100644 index 000000000..d9c5b9d74 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/boolean.test.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Table } from "@/components/table"; +import { createAppShellWrapper } from "../../../../../tests/test-utils"; +import { BooleanEditor } from "./boolean"; + +afterEach(cleanup); + +function Harness({ onCommit }: { onCommit: (value: boolean) => void }) { + const [value, setValue] = useState(false); + return ( + + + + + + { + onCommit(next); + setValue(next); + }} + /> + + + + + ); +} + +describe("BooleanEditor", () => { + it("supports keyboard toggling and commits only boolean values", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(, { wrapper: createAppShellWrapper("en") }); + const checkbox = screen.getByRole("checkbox", { name: "Expedite, line 1" }); + expect(checkbox.getAttribute("aria-checked")).toBe("false"); + await user.tab(); + expect(document.activeElement).toBe(checkbox); + await user.keyboard(" "); + expect(onCommit).toHaveBeenLastCalledWith(true); + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + await user.keyboard(" "); + expect(onCommit.mock.calls).toEqual([[true], [false]]); + expect(checkbox.getAttribute("aria-checked")).toBe("false"); + }); + + it("uses the supplied error description and an out-of-flow overlay", () => { + const { container } = render( + + + + + Requires approval + {}} + /> + + + + , + { wrapper: createAppShellWrapper("en") }, + ); + const checkbox = screen.getByRole("checkbox", { name: "Expedite" }); + expect(checkbox.getAttribute("aria-invalid")).toBe("true"); + expect(checkbox.getAttribute("aria-describedby")).toBe("boolean-error"); + const overlay = container.querySelector("td > div")!; + expect(overlay.className).toContain("astw:absolute"); + expect(overlay.className).toContain("astw:inset-0"); + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + }); +}); diff --git a/packages/core/src/components/internals/table-core/editors/boolean.tsx b/packages/core/src/components/internals/table-core/editors/boolean.tsx new file mode 100644 index 000000000..be5ebbd73 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/boolean.tsx @@ -0,0 +1,32 @@ +import { Checkbox } from "@/components/checkbox"; +import { cn } from "@/lib/utils"; +import { OVERLAY_CLASS_NAME } from "../cell-styles"; + +export interface BooleanEditorProps { + value: boolean; + ariaLabel: string; + invalid?: boolean; + describedBy?: string; + onCommit: (value: boolean) => void; +} + +/** A checkbox overlay leaves the parent cell's display in charge of row geometry. */ +export function BooleanEditor({ + value, + ariaLabel, + invalid, + describedBy, + onCommit, +}: BooleanEditorProps) { + return ( +
+ onCommit(checked)} + aria-label={ariaLabel} + aria-invalid={invalid || undefined} + aria-describedby={describedBy} + /> +
+ ); +} diff --git a/packages/core/src/components/internals/table-core/editors/calendar-control.tsx b/packages/core/src/components/internals/table-core/editors/calendar-control.tsx new file mode 100644 index 000000000..8161591df --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/calendar-control.tsx @@ -0,0 +1,105 @@ +import type { ComponentProps } from "react"; +import { Popover } from "@base-ui/react/popover"; +import type { CalendarDate } from "@internationalized/date"; +import { Button } from "@/components/button"; +import { Calendar } from "@/components/calendar"; +import { Input } from "@/components/input"; +import { cn } from "@/lib/utils"; +import { CELL_FOCUS_CLASS_NAME, OVERLAY_CLASS_NAME, POPUP_CLASS_NAME } from "../cell-styles"; + +export interface CalendarControlProps { + open: boolean; + onOpenChange: (open: boolean, details?: Popover.Root.ChangeEventDetails) => void; + value: CalendarDate | null | undefined; + minValue?: CalendarDate; + maxValue?: CalendarDate; + ariaLabel: string; + onChange: (day: CalendarDate) => void; + triggerProps?: ComponentProps; + popupSlot: string; + clearLabel: string; + onClear: () => void; + footerClassName?: string; + time?: { label: string; value: string; onChange: (text: string) => void }; + confirmation?: { label: string; disabled: boolean; onClick: () => void }; +} + +/** Calendar UI only; owners decide date/datetime conversion and adoption. */ +export function CalendarControl({ + open, + onOpenChange, + value, + minValue, + maxValue, + ariaLabel, + onChange, + triggerProps, + popupSlot, + clearLabel, + onClear, + footerClassName, + time, + confirmation, +}: CalendarControlProps) { + return ( + + { + triggerProps?.onKeyDown?.(event); + if (!event.defaultPrevented && event.key === "ArrowDown" && !open) { + event.preventDefault(); + onOpenChange(true); + } + }} + /> + + + event.stopPropagation()} + onTouchStart={(event) => event.stopPropagation()} + onKeyDown={(event) => { + if (event.key === "Escape") event.stopPropagation(); + }} + > + + {time && ( + time.onChange(event.target.value)} + className="astw:h-8 astw:text-body-md" + /> + )} +
+ + {confirmation && ( + + )} +
+
+
+
+
+ ); +} diff --git a/packages/core/src/components/internals/table-core/editors/choice.test.tsx b/packages/core/src/components/internals/table-core/editors/choice.test.tsx new file mode 100644 index 000000000..8ecc76bd2 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/choice.test.tsx @@ -0,0 +1,390 @@ +import { useState } from "react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAppShellWrapper } from "../../../../../tests/test-utils"; +import { ChoiceEditor, type ChoiceEditorConfig, type ChoiceOption } from "./choice"; + +afterEach(cleanup); +type Row = { name: string }; +const row: Row = { name: "Current label" }; +const options: ChoiceOption[] = [ + { value: 10, label: "Ten" }, + { value: 20, label: "Twenty" }, +]; + +function deferred() { + let resolve!: (options: ChoiceOption[]) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + return { promise, resolve, reject }; +} +function Harness({ + config = { options }, + initial = 10, + onCommit = () => {}, + onCancel = () => {}, + currentRow = row, + invalid, +}: { + config?: ChoiceEditorConfig; + initial?: string | number | null; + onCommit?: (value: string | number | null) => void; + onCancel?: () => void; + currentRow?: Row; + invalid?: boolean; +}) { + const [value, setValue] = useState(initial); + return ( + + + + + + +
+ { + onCommit(next); + setValue(next); + }} + onCancel={onCancel} + /> +
+ ); +} +function setup(props: Parameters[0] = {}) { + const user = userEvent.setup(); + const view = render(, { wrapper: createAppShellWrapper("en") }); + const input = screen.getByRole("combobox", { name: "Choice" }) as HTMLInputElement; + return { user, input, ...view }; +} + +describe("ChoiceEditor fixed options", () => { + it("preserves numeric versus string values instead of stringifying selection", async () => { + const onCommit = vi.fn(); + const { user, input } = setup({ + config: { + options: [ + { value: 1, label: "Numeric one" }, + { value: "1", label: "Text one" }, + ], + }, + initial: null, + onCommit, + }); + await user.click(input); + await user.click(screen.getByRole("option", { name: "Numeric one" })); + expect(onCommit).toHaveBeenLastCalledWith(1); + await user.click(input); + await user.click(screen.getByRole("option", { name: "Text one" })); + expect(onCommit).toHaveBeenLastCalledWith("1"); + }); + + it("searches case/full-width insensitively without committing query text or highlights", async () => { + const onCommit = vi.fn(); + const { user, input } = setup({ onCommit }); + await user.click(input); + fireEvent.change(input, { target: { value: "TW" } }); + expect(screen.getByRole("option", { name: "Twenty" })).toBeDefined(); + expect(screen.queryByRole("option", { name: "Ten" })).toBeNull(); + await user.keyboard("{ArrowDown}"); + expect(onCommit).not.toHaveBeenCalled(); + await user.keyboard("{Enter}"); + expect(onCommit).toHaveBeenCalledExactlyOnceWith(20); + }); + + it("clears search without clearing the adopted value, and adopts null only through Clear", async () => { + const onCommit = vi.fn(); + const { user, input } = setup({ onCommit }); + await user.click(input); + await user.clear(input); + expect(onCommit).not.toHaveBeenCalled(); + expect(screen.getAllByRole("option")).toHaveLength(2); + await user.click(screen.getByRole("button", { name: "Clear value" })); + expect(onCommit).toHaveBeenCalledExactlyOnceWith(null); + }); + + it("Escape cancels rather than confirming or clearing, including after a query", async () => { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + const { user, input } = setup({ onCommit, onCancel }); + await user.click(input); + fireEvent.change(input, { target: { value: "Twenty" } }); + await user.keyboard("{Escape}"); + expect(onCommit).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(input.value).toBe("Ten"); + }); + + it("Escape from the popup cancels even when the value is invalid", async () => { + const onCancel = vi.fn(); + const onCommit = vi.fn(); + const { user, input } = setup({ invalid: true, onCancel, onCommit }); + await user.click(input); + screen.getByRole("button", { name: "Clear value" }).focus(); + await user.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("keeps an unknown current value without injecting a selectable fallback option", async () => { + const onCommit = vi.fn(); + const { user, input } = setup({ initial: 999, onCommit }); + expect(input.value).toBe("999"); + await user.click(input); + expect(screen.getAllByRole("option").map((item) => item.textContent)).toEqual([ + "Ten", + "Twenty", + ]); + await user.keyboard("{Escape}"); + expect(input.value).toBe("999"); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("does not confirm a choice while an IME composition is being confirmed", async () => { + const onCommit = vi.fn(); + const { user, input } = setup({ onCommit }); + await user.click(input); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + fireEvent.compositionEnd(input); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); + +describe("ChoiceEditor owner policies", () => { + it.each([false, true])( + "confirms a searched candidate on Tab only when enabled: %s", + async (confirmOnTab) => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + const onCancel = vi.fn(); + render( + , + { wrapper: createAppShellWrapper("en") }, + ); + const input = screen.getByRole("combobox", { name: "Choice" }); + await user.click(input); + fireEvent.change(input, { target: { value: "Twenty" } }); + await user.keyboard("{Tab}"); + if (confirmOnTab) expect(onCommit).toHaveBeenCalledExactlyOnceWith(20); + else { + expect(onCommit).not.toHaveBeenCalled(); + expect(onCancel).toHaveBeenCalledTimes(1); + } + }, + ); + it("does not let an old confirmation close a reopened search", async () => { + const user = userEvent.setup(); + let resolve!: (ok: boolean) => void; + const pending = new Promise((done) => { + resolve = done; + }); + const onCommit = vi.fn(() => pending); + render( + {}} + />, + { wrapper: createAppShellWrapper("en") }, + ); + const input = screen.getByRole("combobox", { name: "Choice" }); + await user.click(input); + await user.click(screen.getByRole("option", { name: "Twenty" })); + expect(onCommit).toHaveBeenCalledTimes(1); + await user.keyboard("{Escape}"); + await user.click(input); + fireEvent.change(input, { target: { value: "Ten" } }); + await act(async () => { + resolve(true); + await pending; + }); + expect(input.getAttribute("aria-expanded")).toBe("true"); + expect(screen.getByRole("option", { name: "Ten" })).toBeDefined(); + }); +}); + +describe("ChoiceEditor remote options", () => { + it("does not fetch for initial or synchronized labels and uses getOptionLabel only in its input", async () => { + const request = deferred(); + const fn = vi.fn(() => request.promise); + const config: ChoiceEditorConfig = { + fetcher: fn, + getOptionLabel: (_value, current) => current.name, + }; + const view = setup({ config, initial: 999 }); + expect(view.input.value).toBe("Current label"); + expect(fn).not.toHaveBeenCalled(); + view.rerender(); + expect(view.input.value).toBe("Updated label"); + expect(fn).not.toHaveBeenCalled(); + await view.user.click(view.input); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn.mock.calls[0]).toMatchObject([null, { signal: expect.any(AbortSignal) }]); + expect(screen.getByText("Loading options...")).toBeDefined(); + await act(async () => request.resolve(options)); + expect(screen.getAllByRole("option")).toHaveLength(2); + expect(screen.queryByRole("option", { name: "Updated label" })).toBeNull(); + }); + + it("shows error/retry while retaining a current fallback label and only retries explicitly", async () => { + const first = deferred(); + const second = deferred(); + const fn = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const { user, input } = setup({ + config: { fetcher: { fn, debounceMs: 0 }, getOptionLabel: () => "Existing item" }, + initial: 999, + }); + await user.click(input); + expect(screen.getByText("Loading options...")).toBeDefined(); + await act(async () => first.reject(new Error("Offline"))); + expect(input.value).toBe("Existing item"); + expect(screen.getByText("Could not load options.")).toBeDefined(); + expect(fn).toHaveBeenCalledTimes(1); + await user.click(screen.getByRole("button", { name: "Retry" })); + expect(fn).toHaveBeenCalledTimes(2); + await act(async () => second.resolve(options)); + expect(screen.getByRole("option", { name: "Twenty" })).toBeDefined(); + expect(screen.queryByText("Could not load options.")).toBeNull(); + }); + + it("does not filter or reorder server results using the local query", async () => { + const fn = vi.fn(async () => [ + { value: "b", label: "Unrelated result" }, + { value: "a", label: "Alpha result" }, + ]); + const { user, input } = setup({ + config: { fetcher: { fn, debounceMs: 0 }, getOptionLabel: () => "Existing" }, + initial: "saved", + }); + await user.click(input); + await screen.findByRole("option", { name: "Unrelated result" }); + fireEvent.change(input, { target: { value: "server query" } }); + await waitFor(() => + expect(fn).toHaveBeenLastCalledWith("server query", { signal: expect.any(AbortSignal) }), + ); + await screen.findByRole("option", { name: "Unrelated result" }); + expect(screen.getAllByRole("option").map((item) => item.textContent)).toEqual([ + "Unrelated result", + "Alpha result", + ]); + }); + + it("blocks stale results even when an older request settles during the next debounce window", async () => { + const first = deferred(); + const next = deferred(); + const onCommit = vi.fn(); + const fn = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(next.promise); + const { user, input } = setup({ + config: { fetcher: { fn, debounceMs: 200 }, getOptionLabel: () => "Existing" }, + initial: "saved", + onCommit, + }); + await user.click(input); + fireEvent.change(input, { target: { value: "new" } }); + await act(async () => first.resolve([{ value: "old", label: "Old result" }])); + expect(screen.queryByRole("option", { name: "Old result" })).toBeNull(); + expect(screen.getByText("Loading options...")).toBeDefined(); + await user.keyboard("{Enter}"); + expect(onCommit).not.toHaveBeenCalled(); + await waitFor(() => expect(fn).toHaveBeenCalledTimes(2)); + await act(async () => next.resolve([{ value: "new", label: "New result" }])); + await user.click(screen.getByRole("option", { name: "New result" })); + expect(onCommit).toHaveBeenCalledExactlyOnceWith("new"); + }); + + it("invalidates an old pending source and reloads only when reopened", async () => { + const oldRequest = deferred(); + const newRequest = deferred(); + const oldSource = vi.fn(() => oldRequest.promise); + const newSource = vi.fn(() => newRequest.promise); + const onCommit = vi.fn(); + const view = setup({ + config: { fetcher: oldSource, getOptionLabel: () => "Current" }, + initial: 999, + onCommit, + }); + await view.user.click(view.input); + view.rerender( + "Current" }} + initial={999} + onCommit={onCommit} + />, + ); + await waitFor(() => expect(view.input.getAttribute("aria-expanded")).toBe("false")); + expect(newSource).not.toHaveBeenCalled(); + await act(async () => oldRequest.resolve([{ value: "OLD", label: "Old source option" }])); + await view.user.click(view.input); + expect(newSource).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("option", { name: "Old source option" })).toBeNull(); + await act(async () => newRequest.resolve([{ value: "NEW", label: "New source option" }])); + await view.user.click(screen.getByRole("option", { name: "New source option" })); + expect(onCommit).toHaveBeenCalledExactlyOnceWith("NEW"); + }); + + it("drops loaded choices and labels when the source changes", async () => { + const oldSource = vi.fn(async () => [{ value: 999, label: "Old current label" }]); + const newRequest = deferred(); + const newSource = vi.fn(() => newRequest.promise); + const view = setup({ + config: { fetcher: oldSource, getOptionLabel: () => "Old fallback" }, + initial: 999, + }); + await view.user.click(view.input); + await screen.findByRole("option", { name: "Old current label" }); + view.rerender( + "New fallback" }} + initial={999} + />, + ); + await waitFor(() => expect(view.input.getAttribute("aria-expanded")).toBe("false")); + expect(view.input.value).toBe("New fallback"); + await view.user.click(view.input); + expect(screen.queryByRole("option", { name: "Old current label" })).toBeNull(); + await act(async () => newRequest.resolve([{ value: 10, label: "New option" }])); + expect(await screen.findByRole("option", { name: "New option" })).toBeDefined(); + }); + + it("aborts loading on unmount and never confirms a late result", async () => { + const request = deferred(); + const onCommit = vi.fn(); + let signal!: AbortSignal; + const fn = vi.fn((_query: string | null, requestOptions: { signal: AbortSignal }) => { + signal = requestOptions.signal; + return request.promise; + }); + const { user, input, unmount } = setup({ + config: { fetcher: fn, getOptionLabel: () => "Existing" }, + onCommit, + }); + await user.click(input); + expect(signal.aborted).toBe(false); + unmount(); + expect(signal.aborted).toBe(true); + await act(async () => request.resolve(options)); + expect(onCommit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/internals/table-core/editors/choice.tsx b/packages/core/src/components/internals/table-core/editors/choice.tsx new file mode 100644 index 000000000..52eaaa918 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/choice.tsx @@ -0,0 +1,536 @@ +import { + useLayoutEffect, + useMemo, + useRef, + useState, + type ComponentProps, + type KeyboardEvent, + type ReactNode, + type Ref, +} from "react"; +import { Combobox as BaseCombobox } from "@base-ui/react/combobox"; +import { ComboboxParts } from "@/components/combobox"; +import { Button } from "@/components/button"; +import { Spinner } from "@/components/spinner"; +import { defineI18nLabels } from "@/hooks/i18n"; +import { useAsyncItems, type AsyncFetcher } from "@/hooks/use-async-items"; +import { isComposing } from "@/lib/is-composing"; +import { cn } from "@/lib/utils"; +import { CELL_ERROR_CLASS_NAME, ICON_INPUT_CLASS_NAME, INPUT_CLASS_NAME } from "../cell-styles"; + +export type ChoiceOption = { + value: TValue; + label: string; + description?: string; +}; + +export type ChoiceEditorConfig = + | { + options: readonly ChoiceOption[]; + fetcher?: never; + getOptionLabel?: never; + placeholder?: string; + } + | { + options?: never; + fetcher: AsyncFetcher; + getOptionLabel: (value: string | number, row: TRow) => string; + placeholder?: string; + }; + +export type ChoiceEditorProps = { + value: string | number | null; + row: TRow; + config: ChoiceEditorConfig; + ariaLabel: string; + invalid?: boolean; + describedBy?: string; + onCommit: (value: string | number | null) => void | Promise; + onCancel: () => void; + onBegin?: () => void; + onActiveChange?: (active: boolean) => void; + cancelToken?: number; + /** Keep a retained failed/confirming value visible after focus leaves. */ + showValue?: boolean; + containsErrorTarget?: (target: EventTarget | null) => boolean; + inputRef?: Ref; + inputClassName?: string; + /** Optional owner-controlled editing, for resting read-only inputs. */ + editing?: boolean; + onFocus?: () => void; + onLeave?: () => void; + onQueryChange?: () => void; + confirmOnTab?: boolean; + onTab?: (event: KeyboardEvent) => void; + retry?: () => Promise; + nullOptionLabel?: string; + emptyLabel?: string; + renderOption?: (option: ChoiceCandidate) => ReactNode; +}; + +type ChoiceCandidate = { value: string | number | null; label: string; description?: string }; + +type Source = { + options: readonly ChoiceOption[]; + remote: boolean; + revision?: unknown; + loading?: boolean; + failed?: boolean; + onSearch?: (query: string) => void; + onOpen?: () => void; + retry?: () => void; +}; + +const labels = defineI18nLabels({ + en: { + clear: "Clear value", + loading: "Loading options...", + failed: "Could not load options.", + retry: "Retry", + empty: "No matching options.", + }, + ja: { + clear: "値をクリア", + loading: "候補を読み込み中...", + failed: "候補を読み込めませんでした。", + retry: "再試行", + empty: "一致する候補がありません。", + }, +}); + +const searchable = (text: string) => text.normalize("NFKC").toLocaleLowerCase().trim(); +const equalOption = (left: ChoiceCandidate, right: ChoiceCandidate) => + Object.is(left.value, right.value); +const optionLabel = (option: ChoiceCandidate) => option.label; + +/** Values are separate from search text, highlight, and the current result window. */ +export function ChoiceEditor(props: ChoiceEditorProps) { + if (props.config.fetcher) return ; + return ( + + ); +} + +function RemoteChoiceEditor({ + config, + ...props +}: ChoiceEditorProps & { + config: Extract, { fetcher: AsyncFetcher }>; +}) { + const desired = useRef({ query: null as string | null, revision: 0 }); + const committedSource = useRef(config.fetcher); + useLayoutEffect(() => { + if (committedSource.current === config.fetcher) return; + committedSource.current = config.fetcher; + desired.current = { ...desired.current, revision: desired.current.revision + 1 }; + setRequested(desired.current.revision); + setSettled(null); + }, [config.fetcher]); + const [requested, setRequested] = useState(0); + const [settled, setSettled] = useState<{ + revision: number; + failed: boolean; + source: typeof config.fetcher; + } | null>(null); + const fetcher = useMemo>(() => { + const fn = typeof config.fetcher === "function" ? config.fetcher : config.fetcher.fn; + const load = async (query: string | null, options: { signal: AbortSignal }) => { + const revision = desired.current.revision; + try { + const result = await fn(query, options); + if ( + options.signal.aborted || + committedSource.current !== config.fetcher || + revision !== desired.current.revision || + query !== desired.current.query + ) + throw new DOMException("Superseded search", "AbortError"); + setSettled({ revision, failed: false, source: config.fetcher }); + return result; + } catch (error) { + if ( + !options.signal.aborted && + committedSource.current === config.fetcher && + revision === desired.current.revision && + query === desired.current.query + ) + setSettled({ revision, failed: true, source: config.fetcher }); + throw error; + } + }; + return typeof config.fetcher === "function" + ? load + : { fn: load, debounceMs: config.fetcher.debounceMs }; + }, [config.fetcher]); + const asyncItems = useAsyncItems({ fetcher }); + const currentSource = settled?.source === config.fetcher; + const pending = !currentSource || settled?.revision !== requested || asyncItems.loading; + const failed = currentSource && settled?.revision === requested && settled.failed; + const search = (query: string) => { + desired.current = { query: query.trim() || null, revision: desired.current.revision + 1 }; + setRequested(desired.current.revision); + asyncItems.onInputValueChange(query); + }; + const retry = () => { + desired.current = { ...desired.current, revision: desired.current.revision + 1 }; + setRequested(desired.current.revision); + asyncItems.retry(); + }; + return ( + search(""), + retry, + }} + /> + ); +} + +function ChoiceControl({ + value, + row, + config, + ariaLabel, + invalid, + describedBy, + onCommit, + onCancel, + onBegin, + onActiveChange, + cancelToken, + showValue, + containsErrorTarget, + inputRef, + inputClassName, + editing, + onFocus, + onLeave, + onQueryChange, + confirmOnTab, + onTab, + retry, + nullOptionLabel, + emptyLabel, + renderOption, + source, +}: ChoiceEditorProps & { source: Source }) { + const t = labels.useT(); + const [open, setOpen] = useState(false); + const openRef = useRef(false); + const [focused, setFocused] = useState(false); + const [query, setQuery] = useState(null); + const [listQuery, setListQuery] = useState(null); + const previousCancel = useRef(cancelToken); + const previousSource = useRef(source.revision); + const operation = useRef(0); + const mounted = useRef(false); + useLayoutEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + onActiveChange?.(false); + }; + }, [onActiveChange]); + const composing = useRef(false); + const highlighted = useRef(undefined); + const explicitHighlight = useRef(undefined); + const selectOnMouseUp = useRef(false); + useLayoutEffect(() => { + if (previousCancel.current === cancelToken && previousSource.current === source.revision) + return; + previousCancel.current = cancelToken; + previousSource.current = source.revision; + operation.current++; + openRef.current = false; + setOpen(false); + setQuery(null); + highlighted.current = undefined; + explicitHighlight.current = undefined; + onActiveChange?.(focused); + }, [cancelToken, source.revision, focused, onActiveChange]); + const blocked = !!source.loading || !!source.failed; + const items = useMemo( + () => + nullOptionLabel !== undefined + ? [{ value: null, label: nullOptionLabel }, ...source.options] + : source.options, + [nullOptionLabel, source.options], + ); + const selected = + value === null + ? (items.find((item) => item.value === null) ?? null) + : (items.find((item) => Object.is(item.value, value)) ?? { + value, + label: config.getOptionLabel?.(value, row) ?? String(value), + }); + const filtered = useMemo(() => { + if (source.loading || source.failed) return []; + if (source.remote) return source.options; + const search = searchable(listQuery ?? ""); + if (!search) return items; + const rank = (option: ChoiceCandidate) => { + const label = searchable(option.label); + if (label === search) return 0; + return label.startsWith(search) ? 1 : 2; + }; + return items + .filter((option) => option.value !== null && searchable(option.label).includes(search)) + .toSorted((left, right) => rank(left) - rank(right)); + }, [items, source.options, source.remote, source.loading, source.failed, listQuery]); + + function start(fromInput = false) { + if (openRef.current) return; + operation.current++; + openRef.current = true; + setOpen(true); + if (!fromInput) { + setQuery(null); + setListQuery(null); + } + highlighted.current = undefined; + explicitHighlight.current = undefined; + onBegin?.(); + onActiveChange?.(true); + if (!fromInput) source.onOpen?.(); + } + function close(cancel: boolean) { + operation.current++; + openRef.current = false; + setOpen(false); + setQuery(null); + highlighted.current = undefined; + explicitHighlight.current = undefined; + onActiveChange?.(focused); + if (cancel) onCancel(); + } + function leave() { + close(false); + if (onLeave) onLeave(); + else if (!invalid) onCancel(); + } + function finish(result: void | Promise) { + if (!result) { + close(false); + return; + } + const attempt = operation.current; + void result.then((ok) => { + if (ok && mounted.current && attempt === operation.current) close(false); + }); + } + function pick(option: ChoiceCandidate) { + if (blocked || !filtered.includes(option)) return; + finish(onCommit(option.value)); + } + const keyDown: NonNullable["onKeyDown"]> = (event) => { + if (isComposing(event.nativeEvent, composing.current)) { + event.preventBaseUIHandler(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + event.preventBaseUIHandler(); + close(true); + } else if (event.key === "Enter") { + event.preventDefault(); + event.preventBaseUIHandler(); + if (!openRef.current) { + if (retry) finish(retry()); + else start(); + return; + } + if (blocked) return; + const candidate = + [explicitHighlight.current, highlighted.current].find( + (item) => item && filtered.includes(item), + ) ?? filtered[0]; + if (candidate) pick(candidate); + } else if (event.key === "Tab") { + event.preventBaseUIHandler(); + const explicit = explicitHighlight.current; + let candidate = explicit && filtered.includes(explicit) ? explicit : undefined; + if (!candidate && searchable(listQuery ?? "")) + candidate = + [highlighted.current].find((item) => item && filtered.includes(item)) ?? filtered[0]; + if (confirmOnTab && openRef.current && candidate && !blocked) pick(candidate); + else leave(); + onTab?.(event); + } + }; + return ( + + items={items} + filteredItems={filtered} + filter={null} + value={selected} + inputValue={query ?? selected?.label ?? ""} + open={open} + autoHighlight + itemToStringLabel={optionLabel} + isItemEqualToValue={equalOption} + onValueChange={(item, details) => { + if (details.reason === "item-press" && item) pick(item); + }} + onInputValueChange={(next, details) => { + if (details.reason !== "input-change" && details.reason !== "input-clear") return; + operation.current++; + setQuery(next); + setListQuery(next); + highlighted.current = undefined; + explicitHighlight.current = undefined; + onQueryChange?.(); + source.onSearch?.(next); + }} + onOpenChange={(next, details) => { + if (next) { + start(details.reason === "input-change"); + return; + } + if ( + containsErrorTarget?.(details.event.target) || + ("relatedTarget" in details.event && + containsErrorTarget?.(details.event.relatedTarget as EventTarget | null)) + ) { + details.cancel(); + return; + } + if (details.reason === "input-clear") return; + if (details.reason === "escape-key") close(true); + else if (details.reason === "item-press") close(false); + else leave(); + }} + onOpenChangeComplete={(isOpen) => { + if (!isOpen) setListQuery(null); + }} + onItemHighlighted={(item, details) => { + if (details.reason === "pointer") return; + highlighted.current = item ?? undefined; + if (details.reason === "keyboard") explicitHighlight.current = item ?? undefined; + }} + > + { + const withinCell = focused || containsErrorTarget?.(event.relatedTarget); + setFocused(true); + onActiveChange?.(true); + if (!withinCell) { + onFocus?.(); + event.currentTarget.select(); + } + }} + onBlur={() => { + setFocused(false); + onActiveChange?.(openRef.current); + if (!openRef.current) onLeave?.(); + }} + onMouseDown={(event) => { + if (event.button === 2 && editing === false) { + event.preventDefault(); + return; + } + if (editing === false) start(); + if (!focused && editing !== undefined) selectOnMouseUp.current = true; + }} + onMouseUp={(event) => { + if (selectOnMouseUp.current) { + selectOnMouseUp.current = false; + event.preventDefault(); + } + }} + onContextMenu={(event) => { + if (editing) event.stopPropagation(); + }} + onTouchStart={(event) => { + if (editing) event.stopPropagation(); + }} + onKeyDown={keyDown} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + /> + { + if (event.key === "Escape") event.stopPropagation(); + }} + > + {source.loading && ( + + + {t("loading")} + + )} + {source.failed && ( +
+ {t("failed")} + +
+ )} + {!blocked && {emptyLabel ?? t("empty")}} + + {(item: ChoiceCandidate) => ( + + {renderOption ? ( + renderOption(item) + ) : ( + <> + {item.label} + {item.description && ( + {item.description} + )} + + )} + + )} + + {nullOptionLabel === undefined && ( +
+ +
+ )} +
+ + ); +} diff --git a/packages/core/src/components/internals/table-core/editors/date.test.tsx b/packages/core/src/components/internals/table-core/editors/date.test.tsx new file mode 100644 index 000000000..3e1646f23 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/date.test.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Table } from "@/components/table"; +import { createAppShellWrapper } from "../../../../../tests/test-utils"; +import { DateEditor, type DateEditorProps } from "./date"; + +afterEach(cleanup); + +function Harness({ + value: initial = "2026-10-02", + onCommit, + ...props +}: Omit) { + const [value, setValue] = useState(initial); + return ( + <> + + + + + + + + { + setValue(next); + onCommit(next); + }} + /> + + + + + + ); +} + +const trigger = () => screen.getByRole("button", { name: /^Expected date/ }); +function dayButton(day: string) { + const popup = document.querySelector('[data-slot="table-cell-calendar"]')!; + return within(popup) + .getAllByRole("button") + .find((button) => button.textContent === day && !button.hasAttribute("data-outside-month"))!; +} +function setup(props: Partial> = {}, locale = "en") { + const onCommit = vi.fn(); + const onCancel = vi.fn(); + const onBegin = vi.fn(); + const view = render( + , + { wrapper: createAppShellWrapper(locale) }, + ); + return { ...view, user: userEvent.setup(), onCommit, onCancel, onBegin }; +} + +describe("DateEditor", () => { + it("opens a named calendar without committing and confirms a date-only value", async () => { + const { user, onCommit, onCancel, onBegin } = setup(); + await user.click(trigger()); + expect(screen.getByRole("grid", { name: "Expected date" })).toBeDefined(); + expect(onBegin).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + await user.click(dayButton("20")); + expect(onCommit).toHaveBeenCalledExactlyOnceWith("2026-10-20"); + expect(onCancel).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("grid")).toBeNull()); + expect(trigger().getAttribute("aria-label")).toBe("Expected date, 2026-10-20"); + }); + + it("commits null only for explicit Clear", async () => { + const { user, onCommit, onCancel } = setup(); + await user.click(trigger()); + await user.click(screen.getByRole("button", { name: "Clear date" })); + expect(onCommit).toHaveBeenCalledExactlyOnceWith(null); + expect(onCancel).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("grid")).toBeNull()); + expect(trigger().getAttribute("aria-label")).toBe("Expected date"); + }); + + it("Escape cancels rather than clearing or committing the current value", async () => { + const { user, onCommit, onCancel } = setup(); + await user.click(trigger()); + await user.keyboard("{ArrowRight}{Escape}"); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("grid")).toBeNull()); + expect(trigger().getAttribute("aria-label")).toBe("Expected date, 2026-10-02"); + }); + + it("does not bubble popup Escape into the enclosing table", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + const onCancel = vi.fn(); + const onTableKeyDown = vi.fn(); + render( + + + + + + + + + , + { wrapper: createAppShellWrapper("en") }, + ); + await user.click(trigger()); + await user.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + expect(onTableKeyDown).not.toHaveBeenCalled(); + }); + + it("outside dismissal cancels once and preserves the current value", async () => { + const { user, onCommit, onCancel } = setup(); + await user.click(trigger()); + await user.click(screen.getByRole("button", { name: "Outside the cell" })); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.queryByRole("grid")).toBeNull()); + expect(trigger().getAttribute("aria-label")).toBe("Expected date, 2026-10-02"); + }); + + it("keeps the calendar open when focus/pointer moves into the supplied error group", async () => { + const { user, onCancel, onCommit } = setup({ + containsErrorTarget: (target) => + target instanceof Element && target.closest('[data-testid="error-target"]') !== null, + }); + await user.click(trigger()); + await user.click(screen.getByTestId("error-target")); + expect(screen.getByRole("grid", { name: "Expected date" })).toBeDefined(); + expect(onCancel).not.toHaveBeenCalled(); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("does not select days outside min/max bounds and permits a boundary day", async () => { + const { user, onCommit } = setup({ value: "2026-10-15", min: "2026-10-10", max: "2026-10-20" }); + await user.click(trigger()); + const before = dayButton("9"); + const after = dayButton("21"); + expect(before.getAttribute("aria-disabled")).toBe("true"); + expect(after.getAttribute("aria-disabled")).toBe("true"); + fireEvent.click(before); + fireEvent.click(after); + expect(onCommit).not.toHaveBeenCalled(); + await user.click(dayButton("20")); + expect(onCommit).toHaveBeenCalledExactlyOnceWith("2026-10-20"); + }); + + it("opens with ArrowDown and forwards invalid state and description", async () => { + const { user, onBegin } = setup({ invalid: true, describedBy: "date-error" }); + const button = trigger(); + expect(button.getAttribute("aria-invalid")).toBe("true"); + expect(button.getAttribute("aria-describedby")).toBe("date-error"); + button.focus(); + await user.keyboard("{ArrowDown}"); + expect(screen.getByRole("grid", { name: "Expected date" })).toBeDefined(); + expect(onBegin).toHaveBeenCalledTimes(1); + }); + + it("keeps an invalid external value on cancellation instead of guessing a replacement", async () => { + const { user, onCommit, onCancel } = setup({ value: "not-a-date" }); + await user.click(trigger()); + await user.keyboard("{Escape}"); + expect(onCancel).toHaveBeenCalledTimes(1); + expect(onCommit).not.toHaveBeenCalled(); + expect(trigger().getAttribute("aria-label")).toBe("Expected date, not-a-date"); + }); + + it("localizes the explicit Clear action in Japanese", async () => { + const { user, onCommit } = setup({}, "ja"); + await user.click(trigger()); + await user.click(screen.getByRole("button", { name: "日付をクリア" })); + expect(onCommit).toHaveBeenCalledExactlyOnceWith(null); + }); +}); diff --git a/packages/core/src/components/internals/table-core/editors/date.tsx b/packages/core/src/components/internals/table-core/editors/date.tsx new file mode 100644 index 000000000..e415a7abe --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/date.tsx @@ -0,0 +1,120 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import type { Popover } from "@base-ui/react/popover"; +import { parseDate, type CalendarDate } from "@internationalized/date"; +import { defineI18nLabels } from "@/hooks/i18n"; +import { CELL_ERROR_CLASS_NAME } from "../cell-styles"; +import { CalendarControl } from "./calendar-control"; + +const labels = defineI18nLabels({ en: { clear: "Clear date" }, ja: { clear: "日付をクリア" } }); + +export interface DateEditorProps { + value: string | null | undefined; + ariaLabel: string; + invalid?: boolean; + describedBy?: string; + min?: string; + max?: string; + onCommit: (value: string | null) => void; + onCancel: () => void; + onBegin?: () => void; + cancelToken?: number; + containsErrorTarget?: (target: EventTarget | null) => boolean; +} + +function selectedDay(value: string | null | undefined): CalendarDate | undefined { + if (!value) return undefined; + try { + return parseDate(value); + } catch { + // Invalid consumer data remains unchanged until an explicit selection or Clear. + return undefined; + } +} + +/** Date-only adoption; the shared control never converts dates through an instant. */ +export function DateEditor({ + value, + ariaLabel, + invalid, + describedBy, + min, + max, + onCommit, + onCancel, + onBegin, + cancelToken, + containsErrorTarget, +}: DateEditorProps) { + const t = labels.useT(); + const [open, setOpen] = useState(false); + const openRef = useRef(false); + const previousCancel = useRef(cancelToken); + useLayoutEffect(() => { + if (previousCancel.current === cancelToken) return; + previousCancel.current = cancelToken; + openRef.current = false; + setOpen(false); + }, [cancelToken]); + const minValue = min ? parseDate(min) : undefined; + const maxValue = max ? parseDate(max) : undefined; + function cancel() { + if (!openRef.current) return; + openRef.current = false; + setOpen(false); + onCancel(); + } + function confirm(next: CalendarDate | null) { + if ( + next && + ((minValue && next.compare(minValue) < 0) || (maxValue && next.compare(maxValue) > 0)) + ) + return; + // Close before adoption so a resulting focus-out cannot cancel a confirmed edit. + openRef.current = false; + setOpen(false); + onCommit(next?.toString() ?? null); + } + function 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) { + cancel(); + return; + } + if (!openRef.current) { + openRef.current = true; + setOpen(true); + onBegin?.(); + } + } + return ( + confirm(null)} + footerClassName="astw:justify-end" + triggerProps={{ + "aria-label": value ? `${ariaLabel}, ${value}` : ariaLabel, + "aria-invalid": invalid || undefined, + "aria-describedby": describedBy, + "aria-keyshortcuts": invalid ? "Alt+ArrowDown" : undefined, + className: invalid ? CELL_ERROR_CLASS_NAME : undefined, + }} + /> + ); +} diff --git a/packages/core/src/components/internals/table-core/editors/typing.test.tsx b/packages/core/src/components/internals/table-core/editors/typing.test.tsx new file mode 100644 index 000000000..24e481851 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/typing.test.tsx @@ -0,0 +1,57 @@ +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createAppShellWrapper } from "../../../../../tests/test-utils"; +import { TypingEditor, type TypingEditorProps } from "./typing"; + +afterEach(cleanup); +const wrapper = createAppShellWrapper("en"); +const props = (): TypingEditorProps => ({ + value: "--", + editing: true, + ariaLabel: "Amount", + numeric: true, + onBegin: vi.fn(), + onChange: vi.fn(), + onCommit: vi.fn(), + onCancel: vi.fn(), + onTab: vi.fn(), +}); + +describe("TypingEditor", () => { + it("keeps the focused input through parse error and recovery transitions", () => { + const value = props(); + const view = render(, { wrapper }); + const input = screen.getByRole("textbox", { name: "Amount" }); + act(() => input.focus()); + view.rerender( + , + ); + expect(screen.getByRole("textbox", { name: "Amount" })).toBe(input); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(input.getAttribute("aria-describedby")).toBe("parse-error"); + expect(document.getElementById("parse-error")?.textContent).toContain("Enter a number"); + view.rerender(); + expect(screen.getByRole("textbox", { name: "Amount" })).toBe(input); + expect(input.getAttribute("aria-invalid")).toBeNull(); + expect(document.activeElement).toBe(input); + }); + it("routes Tab to the owner's policy and ignores IME confirmation Enter", () => { + const value = props(); + render(, { wrapper }); + const input = screen.getByRole("textbox", { name: "Amount" }); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect(value.onCommit).not.toHaveBeenCalled(); + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: "Enter" }); + expect(value.onCommit).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: "Tab", shiftKey: true }); + expect(value.onTab).toHaveBeenCalledTimes(1); + expect(value.onCommit).toHaveBeenCalledTimes(1); + fireEvent.keyDown(input, { key: "Escape" }); + expect(value.onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/core/src/components/internals/table-core/editors/typing.tsx b/packages/core/src/components/internals/table-core/editors/typing.tsx new file mode 100644 index 000000000..6e5d725b8 --- /dev/null +++ b/packages/core/src/components/internals/table-core/editors/typing.tsx @@ -0,0 +1,149 @@ +import { useRef, useState, type ComponentProps, type KeyboardEvent, type Ref } from "react"; +import { Input } from "@/components/input"; +import { Tooltip } from "@/components/tooltip"; +import { cn } from "@/lib/utils"; +import { isComposing } from "@/lib/is-composing"; +import { CELL_ERROR_CLASS_NAME, ICON_INPUT_CLASS_NAME, INPUT_CLASS_NAME } from "../cell-styles"; + +export interface TypingEditorProps { + value: string; + editing: boolean; + ariaLabel: string; + numeric?: boolean; + align?: "left" | "center" | "right"; + inputRef?: Ref; + inputMode?: ComponentProps["inputMode"]; + readOnly?: boolean; + invalid?: boolean; + describedBy?: string; + iconSpace?: boolean; + parseError?: { id: string; message: string; hint: string }; + containsErrorTarget?: (target: EventTarget | null) => boolean; + onBegin: () => void; + onChange: (text: string) => void; + onCommit: () => void; + onCancel: () => void; + onTab: (event: KeyboardEvent) => void; +} + +/** Shared input/IME/focus mechanics; parsing and confirmation policy belong to the owner. */ +export function TypingEditor({ + value, + editing, + ariaLabel, + numeric, + align, + inputRef, + inputMode, + readOnly, + invalid, + describedBy, + iconSpace, + parseError, + containsErrorTarget, + onBegin, + onChange, + onCommit, + onCancel, + onTab, +}: TypingEditorProps) { + const [focused, setFocused] = useState(false); + const [hovered, setHovered] = useState(false); + const composing = useRef(false); + const selectOnMouseUp = useRef(false); + const input = ( + onChange(event.target.value)} + onKeyDown={(event) => { + if (isComposing(event.nativeEvent, composing.current)) return; + if (event.key === "Enter") { + event.preventDefault(); + if (!editing) { + onBegin(); + event.currentTarget.select(); + } else onCommit(); + } else if (event.key === "Tab") onTab(event); + else if (event.key === "Escape" && editing) { + event.preventDefault(); + event.stopPropagation(); + onCancel(); + } + }} + onFocus={(event) => { + const withinCell = focused || containsErrorTarget?.(event.relatedTarget); + setFocused(true); + if (withinCell) return; + onBegin(); + event.currentTarget.select(); + }} + onBlur={() => setFocused(false)} + onCompositionStart={() => { + composing.current = true; + }} + onCompositionEnd={() => { + composing.current = false; + }} + onMouseDown={(event) => { + if (editing || !readOnly) return; + if (event.button === 2) { + event.preventDefault(); + return; + } + onBegin(); + if (focused) event.currentTarget.select(); + selectOnMouseUp.current = true; + }} + onMouseUp={(event) => { + if (!selectOnMouseUp.current) return; + selectOnMouseUp.current = false; + event.preventDefault(); + }} + onPointerEnter={() => setHovered(true)} + onPointerLeave={() => setHovered(false)} + onContextMenu={(event) => { + if (editing) event.stopPropagation(); + }} + onTouchStart={(event) => { + if (editing) event.stopPropagation(); + }} + /> + ); + return ( + <> + {parseError && ( + + {parseError.message}. {parseError.hint} + + )} + + + + {parseError?.message} + {parseError?.hint} + + + + ); +} diff --git a/packages/core/src/components/internals/table-core/index.ts b/packages/core/src/components/internals/table-core/index.ts index 0b4bf8bbe..3d4489828 100644 --- a/packages/core/src/components/internals/table-core/index.ts +++ b/packages/core/src/components/internals/table-core/index.ts @@ -1,4 +1,13 @@ -export { createValueRenderer, type ValueRendererOptions } from "./cell-renderers"; +export { + createValueRenderer, + getDefaultAlignment, + type ValueRendererOptions, +} from "./cell-renderers"; +export { + createCellEditSession, + type CellEditFailure, + type CellEditSessionState, +} from "./cell-session"; export type { BadgeCellOptions, ColumnCellType, diff --git a/packages/core/src/components/internals/table-core/input-values.test.ts b/packages/core/src/components/internals/table-core/input-values.test.ts new file mode 100644 index 000000000..c6fb95523 --- /dev/null +++ b/packages/core/src/components/internals/table-core/input-values.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { + normalizeNumberText, + parseNumberText, + sameNumber, + toNumberEditText, + toNumberValue, + toTextValue, +} from "./input-values"; + +describe("typed text that isn't a number", () => { + it("is reported as not a number, not silently accepted", () => { + for (const text of ["e", "1e5", "+5", "0x1f", "12a", "1,5"]) { + expect(parseNumberText(normalizeNumberText(text))).toBeUndefined(); + } + }); +}); + +describe("normalizeNumberText", () => { + it("drops strict thousands grouping", () => { + expect(normalizeNumberText("1,234.5")).toBe("1234.5"); + expect(normalizeNumberText("-1,234,567")).toBe("-1234567"); + }); + + it("leaves a decimal comma alone so it fails instead of becoming 15", () => { + expect(normalizeNumberText("1,5")).toBe("1,5"); + expect(parseNumberText(normalizeNumberText("1,5"))).toBeUndefined(); + }); + + it("converts full-width digits and signs", () => { + expect(normalizeNumberText("12.5")).toBe("12.5"); + expect(normalizeNumberText("-3")).toBe("-3"); + expect(normalizeNumberText("−3")).toBe("-3"); + }); + + it("strips whitespace and currency symbols", () => { + expect(normalizeNumberText(" $1,234.50 ")).toBe("1234.50"); + expect(normalizeNumberText("¥ 1 200")).toBe("1200"); + }); +}); + +describe("parseNumberText", () => { + it("treats an empty draft as null", () => { + expect(parseNumberText("")).toBeNull(); + expect(parseNumberText(" ")).toBeNull(); + }); + + it("parses partial but complete-enough numbers without business constraints", () => { + expect(parseNumberText("-3.5678")).toBe(-3.5678); + expect(parseNumberText("500.0001")).toBe(500.0001); + expect(parseNumberText("5.")).toBe(5); + expect(parseNumberText(".5")).toBe(0.5); + expect(parseNumberText("-0")).toBe(0); + expect(Object.is(parseNumberText("-0"), -0)).toBe(false); + }); + + it("rejects half-typed and non-numeric drafts", () => { + for (const text of ["-", ".", "-.", "abc", "1,5", String(Number.MAX_SAFE_INTEGER * 2)]) { + expect(parseNumberText(text)).toBeUndefined(); + } + }); +}); + +describe("value helpers", () => { + it("compares parsed numbers without silently rounding distinct values", () => { + expect(sameNumber(10, 10)).toBe(true); + expect(sameNumber(0.1 + 0.2, 0.3)).toBe(false); + expect(sameNumber(1234567890123456, 1234567890123457)).toBe(false); + expect(sameNumber(null, null)).toBe(true); + expect(sameNumber(0, null)).toBe(false); + expect(sameNumber(10, 10.01)).toBe(false); + }); + + it("reads raw values the way the renderers do", () => { + expect(toNumberValue("10.00")).toBe(10); + expect(toNumberValue("")).toBeNull(); + expect(toNumberValue("abc")).toBeNull(); + expect(toTextValue("")).toBeNull(); + expect(toTextValue(42)).toBe("42"); + }); + + it("formats numbers without grouping, exponents, or precision loss", () => { + expect(toNumberEditText(1234567.5)).toBe("1234567.5"); + expect(toNumberEditText(0.1 + 0.2)).toBe("0.30000000000000004"); + expect(toNumberEditText(1234567890123456)).toBe("1234567890123456"); + expect(toNumberEditText(1e21)).toBe("1000000000000000000000"); + expect(toNumberEditText(null)).toBe(""); + for (const value of [Number.MAX_SAFE_INTEGER, Number.MIN_VALUE, 1e-30, 1.2345678901234567]) { + expect(parseNumberText(toNumberEditText(value))).toBe(value); + } + }); +}); diff --git a/packages/core/src/components/internals/table-core/input-values.ts b/packages/core/src/components/internals/table-core/input-values.ts new file mode 100644 index 000000000..b8dcc757f --- /dev/null +++ b/packages/core/src/components/internals/table-core/input-values.ts @@ -0,0 +1,56 @@ +/** Pure input parsing; business validation, normalization and adoption belong to the adapter. */ + +// Thousands grouping with commas, e.g. "1,234,567.89". +const GROUPED_NUMBER = /^-?\d{1,3}(?:,\d{3})+(?:\.\d*)?$/; + +/** + * Cleans number text before it's checked: full-width digits and signs become + * ASCII, whitespace and currency symbols are dropped, and commas are removed + * only when they form strict thousands groups — so "1,234.5" becomes "1234.5", + * while "1,5" is left alone to fail instead of silently turning into 15. + */ +export function normalizeNumberText(text: string): string { + const compact = text + .normalize("NFKC") + .replace(/[\s\p{Sc}]/gu, "") + .replace(/^−/, "-"); + return GROUPED_NUMBER.test(compact) ? compact.replace(/,/g, "") : compact; +} + +/** `""` → `null`, `"5."` → `5`; `"-"`, `"."` and anything non-numeric → `undefined`. */ +export function parseNumberText(text: string): number | null | undefined { + const trimmed = text.trim(); + if (trimmed === "") return null; + if (!/^-?(?:\d+\.?\d*|\.\d+)$/.test(trimmed)) return undefined; + const value = Number(trimmed); + if (!Number.isFinite(value) || Math.abs(value) > Number.MAX_SAFE_INTEGER) return undefined; + // `+ 0` folds -0 into 0 so "-0" doesn't read as a change from 0. + return value + 0; +} + +/** Reads a raw numeric value; invalid and empty values are represented by null. */ +export function toNumberValue(raw: unknown): number | null { + if (raw == null || raw === "") return null; + const value = typeof raw === "number" ? raw : Number(raw); + return Number.isFinite(value) ? value : null; +} + +/** Reads a raw cell value as text; `null`, `undefined` and `""` are all empty. */ +export function toTextValue(raw: unknown): string | null { + if (raw == null || raw === "") return null; + return String(raw); +} + +/** Compare parsed values exactly; rounding belongs to the consumer. */ +export function sameNumber(a: number | null, b: number | null): boolean { + return a === b; +} + +/** Plain edit text preserves the value without grouping or exponent notation. */ +export function toNumberEditText(value: number | null): string { + if (value === null) return ""; + return value.toLocaleString("en-US", { + useGrouping: false, + maximumSignificantDigits: 21, + }); +} diff --git a/packages/core/src/components/line-items/column-adapter.test.tsx b/packages/core/src/components/line-items/column-adapter.test.tsx new file mode 100644 index 000000000..b71c77493 --- /dev/null +++ b/packages/core/src/components/line-items/column-adapter.test.tsx @@ -0,0 +1,111 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router"; +import { resolveLineItemsColumn } from "./column-adapter"; +import { defineLineItemsEditor } from "./editor-adapter"; +import type { LineItemsColumn } from "./types"; + +afterEach(cleanup); + +type Row = { name: string | null; price: number | null; day: string | null }; +const row: Row = { name: "Widget", price: 12.5, day: "2026-10-09" }; +const t = Object.assign((key: string, ..._args: unknown[]) => key, { + dynamic: (_key: string, fallback: string) => fallback, +}); + +describe("LineItems column adapter", () => { + it("keeps money presentation independent from the number editor", () => { + const adapter = resolveLineItemsColumn( + { + accessorKey: "price", + type: "money", + typeOptions: { currency: "USD", locale: "en-US" }, + edit: { editor: { type: "number" } }, + }, + "UTC", + ); + render(<>{adapter.renderDisplay(row)}); + expect(screen.getByText("$12.50")).toBeDefined(); + expect(adapter.align).toBe("right"); + expect(adapter.editor?.toDraft(row.price)).toBe("12.5"); + expect(adapter.editor?.parse("24.5", t)).toEqual({ ok: true, value: 24.5 }); + }); + + it("preserves explicit alignment and never infers a value from a source-less ID", () => { + const adapter = resolveLineItemsColumn( + { id: "price", type: "money", align: "center" }, + "UTC", + ); + render(<>{adapter.renderDisplay(row)}); + expect(screen.getByText("—")).toBeDefined(); + expect(adapter.align).toBe("center"); + expect(adapter.editor).toBeUndefined(); + }); + + it("uses computed accessors only when custom rendering does not override them", () => { + const accessor = vi.fn((value: Row) => value.price); + const custom = resolveLineItemsColumn( + { + id: "computed", + type: "date", + typeOptions: { locale: "not a locale" }, + accessor: () => accessor(row), + render: () => null, + }, + "UTC", + ); + expect(custom.renderDisplay(row)).toBeNull(); + expect(accessor).not.toHaveBeenCalled(); + const computed = resolveLineItemsColumn( + { id: "computed", type: "number", accessor, typeOptions: { locale: "en-US" } }, + "UTC", + ); + render(<>{computed.renderDisplay(row)}); + expect(accessor).toHaveBeenCalledWith(row); + expect(screen.getByText("13")).toBeDefined(); + }); + + it("keeps links with edit configuration as text even when canEdit rejects the row", () => { + const href = vi.fn(() => "/widgets"); + const column: LineItemsColumn = { + accessorKey: "name", + type: "link", + typeOptions: { href }, + edit: { editor: { type: "text" }, canEdit: () => false }, + }; + const adapter = resolveLineItemsColumn(column, "UTC"); + expect(adapter.renderDisplay(row)).toBe("Widget"); + expect(href).not.toHaveBeenCalled(); + column.edit = undefined; + render({adapter.renderDisplay(row)}); + expect(screen.getByRole("link", { name: "Widget" }).getAttribute("href")).toBe("/widgets"); + }); + + it("resolves current editor options without replacing the display renderer", () => { + const editor = { type: "date" as const, options: { max: "2026-12-31" } }; + const adapter = resolveLineItemsColumn( + { accessorKey: "day", type: "text", edit: { editor } }, + "UTC", + ); + const display = adapter.renderDisplay; + expect(adapter.editor?.parse(row.day, t)).toEqual({ ok: true, value: row.day }); + editor.options.max = "2026-01-01"; + expect(adapter.editor?.parse(row.day, t)).toEqual({ ok: false, message: "invalidDate" }); + expect(adapter.renderDisplay).toBe(display); + }); + + it("passes a custom editor through without taking over its draft or display", () => { + const editor = defineLineItemsEditor({ + toDraft: (value) => ({ text: String(value ?? "") }), + parse: () => ({ ok: true, value: null }), + render: () => null, + }); + const adapter = resolveLineItemsColumn( + { accessorKey: "price", render: () => "Custom display", edit: { editor } }, + "UTC", + ); + expect(adapter.renderDisplay(row)).toBe("Custom display"); + expect(adapter.editor?.toDraft(row.price)).toEqual({ text: "12.5" }); + expect(adapter.editor).toBe(adapter.editor); + }); +}); diff --git a/packages/core/src/components/line-items/column-adapter.ts b/packages/core/src/components/line-items/column-adapter.ts new file mode 100644 index 000000000..a72515dbf --- /dev/null +++ b/packages/core/src/components/line-items/column-adapter.ts @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { + createValueRenderer, + getDefaultAlignment, +} from "@/components/internals/table-core/cell-renderers"; +import type { LineItemsEditorAdapter } from "./editor-adapter"; +import { resolveLineItemsEditor } from "./editors"; +import type { LineItemsColumn } from "./types"; + +export type LineItemsColumnAdapter> = { + readonly align: "left" | "center" | "right"; + readonly renderDisplay: (row: TRow) => ReactNode; + readonly editor?: LineItemsEditorAdapter; +}; + +/** Resolve presentation independently of the explicitly selected property editor. */ +export function resolveLineItemsColumn>( + column: LineItemsColumn, + timeZone: string, +): LineItemsColumnAdapter { + const renderValue = column.render + ? undefined + : createValueRenderer(column, timeZone, { + get linkAsText() { + return !!column.edit; + }, + }); + return { + align: column.align ?? getDefaultAlignment(column.type), + renderDisplay: (row) => + column.render + ? column.render(row) + : renderValue!( + column.accessorKey !== undefined ? row[column.accessorKey] : column.accessor?.(row), + row, + ), + // Formatter caching must not freeze a replaced editor or in-place option changes. + get editor() { + return column.edit && resolveLineItemsEditor(column.edit.editor); + }, + }; +} diff --git a/packages/core/src/components/line-items/columns.test.ts b/packages/core/src/components/line-items/columns.test.ts new file mode 100644 index 000000000..c93898a3e --- /dev/null +++ b/packages/core/src/components/line-items/columns.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { compileLineItemsColumns } from "./columns"; +import type { LineItemsColumn } from "./types"; + +type Row = { name: string | null }; + +describe("LineItems editor ownership", () => { + it("allows duplicate displays but only one edit owner per property", () => { + const edit: LineItemsColumn = { accessorKey: "name", edit: { editor: { type: "text" } } }; + expect([...compileLineItemsColumns([edit, { accessorKey: "name", id: "display" }])]).toEqual([ + "name", + ]); + expect(() => compileLineItemsColumns([edit, { ...edit, id: "second-editor" }])).toThrow( + "multiple edit owners", + ); + }); + it.each([null, false])("rejects an invalid edit configuration %j", (edit) => { + const column: LineItemsColumn = { accessorKey: "name" }; + Object.assign(column, { edit }); + expect(() => compileLineItemsColumns([column])).toThrow(); + }); +}); diff --git a/packages/core/src/components/line-items/columns.ts b/packages/core/src/components/line-items/columns.ts index 6187ce75e..3375a5ceb 100644 --- a/packages/core/src/components/line-items/columns.ts +++ b/packages/core/src/components/line-items/columns.ts @@ -1,19 +1,26 @@ import type { LineItemsColumn } from "./types"; -/** Resolve document property ownership independently of display column identities. */ +/** Validate display identities and one editor owner per property, independently of row state. */ export function compileLineItemsColumns>( columns: readonly LineItemsColumn[], ): ReadonlySet { const ids = new Set(); const properties = new Set(); + const editors = new Set(); for (const column of columns) { const id = column.id ?? column.accessorKey; if (typeof id !== "string" || id === "" || ids.has(id)) throw new Error("LineItems column IDs must be non-empty and unique"); - if ("edit" in column && column.edit !== undefined) - throw new Error("This LineItems slice is readonly; cell editors are not supported yet"); ids.add(id); if (column.accessorKey !== undefined) properties.add(column.accessorKey); + const edit = column.edit; + if (edit === undefined) continue; + if (!edit || typeof edit !== "object") throw new Error("Invalid LineItems edit configuration"); + if (column.accessorKey === undefined) + throw new Error("Only accessorKey properties may have cell editors"); + if (editors.has(column.accessorKey)) + throw new Error(`Property "${column.accessorKey}" has multiple edit owners`); + editors.add(column.accessorKey); } return properties; } diff --git a/packages/core/src/components/line-items/editing.test.ts b/packages/core/src/components/line-items/editing.test.ts new file mode 100644 index 000000000..e4d9c2d45 --- /dev/null +++ b/packages/core/src/components/line-items/editing.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import { createLineItemsDocumentStore } from "./document-store"; +import { createLineItemsEditing } from "./editing"; +import type { LineItemsColumn } from "./types"; + +type Row = { id?: string; name: string | null; quantity: number | null }; +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((success, failure) => { + resolve = success; + reject = failure; + }); + return { promise, resolve, reject }; +} +function setup( + check?: (row: Readonly, value: string | null) => void | Promise, + error?: false, +) { + const columns: LineItemsColumn[] = [ + { + accessorKey: "name", + label: "Name", + edit: { editor: { type: "text" }, onValueCommitted: check, error }, + }, + { accessorKey: "quantity" }, + ]; + const store = createLineItemsDocumentStore({ + columns, + lines: [{ id: "A", name: "First", quantity: 1 }], + }); + const editing = createLineItemsEditing(store, columns); + const session = editing.getSession(store.getSnapshot()[0], columns[0]); + const confirm = (value: string) => { + session.change(value); + return session.confirm(() => editing.commitField("A", "name", value)); + }; + return { columns, store, editing, session, confirm }; +} + +describe("document-owned cell editing", () => { + it("adopts a successful check into the current row without overwriting unrelated changes", async () => { + const request = deferred(); + const check = vi.fn(() => request.promise); + const { editing, store, confirm } = setup(check); + const listener = vi.fn(); + store.subscribe(listener); + const result = confirm("Confirmed"); + expect(store.getSnapshot()[0].name).toBe("First"); + editing.updateField("A", "quantity", 2); + request.resolve(); + await expect(result).resolves.toBe(true); + expect(store.getSnapshot()[0]).toMatchObject({ name: "Confirmed", quantity: 2 }); + expect(check).toHaveBeenCalledWith( + expect.objectContaining({ name: "First", quantity: 1 }), + "Confirmed", + ); + expect(listener).toHaveBeenCalledTimes(2); + }); + + it.each([ + "update", + "noop-update", + "remove", + "revert", + "dispose", + "callback", + "permission", + ] as const)("prevents late adoption after %s invalidates the logical edit", async (action) => { + const request = deferred(); + const { columns, editing, store, session, confirm } = setup(() => request.promise); + const pending = confirm("Stale"); + if (action === "update") editing.updateField("A", "name", "Command"); + if (action === "noop-update") editing.updateField("A", "name", "First"); + if (action === "remove") editing.removeLine("A"); + if (action === "revert") editing.revert(); + if (action === "dispose") editing.deactivate(); + if (action === "callback" || action === "permission") { + editing.setColumns([ + { + accessorKey: "name", + edit: { + editor: { type: "text" }, + onValueCommitted: action === "callback" ? () => {} : () => request.promise, + canEdit: action === "permission" ? () => false : undefined, + }, + }, + columns[1], + ]); + } + const snapshot = store.getSnapshot(); + await expect(pending).resolves.toBe(false); + request.resolve(); + await request.promise; + await Promise.resolve(); + expect(store.getSnapshot()).toBe(snapshot); + if (action !== "dispose" && action !== "remove") + expect(session.getState()).toEqual({ status: "idle" }); + const expectedName = action === "update" ? "Command" : "First"; + expect(store.getSnapshot().map((row) => row.name)).toEqual( + action === "remove" ? [] : [expectedName], + ); + }); + + it("retains a rejected callback draft without adopting data or publishing a row snapshot", async () => { + const request = deferred(); + const { confirm, store, session } = setup(() => request.promise); + const initial = store.getSnapshot(); + const listener = vi.fn(); + store.subscribe(listener); + const result = confirm("Rejected"); + const error = new Error("Rejected"); + request.reject(error); + await expect(result).resolves.toBe(false); + expect(session.getFailure()).toMatchObject({ error, source: "commit", draft: "Rejected" }); + expect(store.getSnapshot()).toBe(initial); + expect(listener).not.toHaveBeenCalled(); + }); + + it("does not discard a draft for an invalid programmatic write", () => { + const { editing, session } = setup(); + session.begin("Retained"); + const draft = session.getState(); + expect(() => editing.updateField("A", "id", "Changed")).toThrow("save acknowledgment"); + expect(session.getState()).toBe(draft); + }); + + it("preserves a draft on display-only configuration changes", () => { + const { columns, editing, session } = setup(); + session.begin("Retained"); + const draft = session.getState(); + editing.setColumns([ + { ...columns[0], label: "Renamed", render: (row) => row.name }, + columns[1], + ]); + expect(session.getState()).toBe(draft); + }); + + it("preserves DataTable's eager opt-out and callback propagation", () => { + const error = new Error("Caller-owned"); + const { confirm, store, session } = setup(() => { + throw error; + }, false); + expect(() => confirm("Eager")).toThrow(error); + expect(store.getSnapshot()[0].name).toBe("Eager"); + expect(session.getState()).toEqual({ status: "idle" }); + }); + + it("rejects editors on the server identity property", () => { + const columns: LineItemsColumn<{ id: string | null }>[] = [ + { accessorKey: "id", edit: { editor: { type: "text" } } }, + ]; + const store = createLineItemsDocumentStore({ columns }); + expect(() => createLineItemsEditing(store, columns)).toThrow( + "Line IDs cannot have cell editors", + ); + }); +}); diff --git a/packages/core/src/components/line-items/editing.ts b/packages/core/src/components/line-items/editing.ts new file mode 100644 index 000000000..a43e27c84 --- /dev/null +++ b/packages/core/src/components/line-items/editing.ts @@ -0,0 +1,301 @@ +import { createCellEditSession } from "@/components/internals/table-core/cell-session"; +import type { createLineItemsDocumentStore } from "./document-store"; +import { + sameEditorContract, + type LineItemsEditorAdapter, + type LineItemsParseResult, +} from "./editor-adapter"; +import { resolveLineItemsEditor } from "./editors"; +import type { useLineItemsT } from "./i18n"; +import type { LineItemsColumn } from "./types"; + +type Store> = ReturnType>; +type Session = ReturnType>; +type Cell = { + lineKey: string; + columnId: string; + property: string; + session: Session; + changed: boolean; +}; + +function sameEditor>( + before: LineItemsColumn | undefined, + after: LineItemsColumn | undefined, + leftAdapter: LineItemsEditorAdapter | undefined, + rightAdapter: LineItemsEditorAdapter | undefined, +) { + const left = before?.edit; + const right = after?.edit; + return ( + !!left && + !!right && + !!leftAdapter && + !!rightAdapter && + before?.accessorKey === after?.accessorKey && + sameEditorContract(leftAdapter, rightAdapter) && + left.onValueCommitted === right.onValueCommitted && + left.canEdit === right.canEdit && + left.error === right.error + ); +} + +/** + * Binds cell sessions to one observable document. Sessions outlive their rendered controls, + * but never own adopted rows or baseline state. Programmatic writes discard conflicting + * drafts; removal, revert, configuration changes, and disposal invalidate pending adoption. + * Only committed column configuration is used to check callbacks and permissions. + * @internal + */ +export function createLineItemsEditing>( + store: Store, + initialColumns: readonly LineItemsColumn[], +) { + let columns = initialColumns; + let active = true; + const cells = new Map>(); + const records = () => [...cells.values()].flatMap((row) => [...row.values()]); + const columnFor = (id: string) => + columns.find((column) => (column.id ?? column.accessorKey) === id); + const getCurrentRow = (lineKey: string) => + store.getSnapshot().find((row) => store.getLineKey(row) === lineKey); + + function cancelProperty(lineKey: string, property: string, source?: Session) { + for (const cell of cells.get(lineKey)?.values() ?? []) { + if (cell.property === property && cell.session !== source) cell.session.cancel(); + } + } + function purgeRemoved() { + for (const [key, rowCells] of cells) { + if (getCurrentRow(key)) continue; + for (const cell of rowCells.values()) cell.session.deactivate(); + cells.delete(key); + } + } + function assertColumns(next: readonly LineItemsColumn[]) { + if (next.some((column) => column.edit && column.accessorKey === store.idKey)) + throw new Error("Line IDs cannot have cell editors"); + const resolved = new Map(); + for (const column of next) { + if (column.edit) + resolved.set( + (column.id ?? column.accessorKey)!, + resolveLineItemsEditor(column.edit.editor), + ); + } + return resolved; + } + let adapters = assertColumns(initialColumns); + + function isCurrentEditor(column: LineItemsColumn, adapter?: LineItemsEditorAdapter) { + const id = (column.id ?? column.accessorKey)!; + return sameEditor( + columnFor(id), + column, + adapters.get(id), + adapter ?? (column.edit && resolveLineItemsEditor(column.edit.editor)), + ); + } + function getCell(row: T, column: LineItemsColumn): Cell { + const lineKey = store.getLineKey(row); + const columnId = (column.id ?? column.accessorKey)!; + let rowCells = cells.get(lineKey); + if (!rowCells) { + rowCells = new Map(); + cells.set(lineKey, rowCells); + } + let cell = rowCells.get(columnId); + if (!cell) { + const session = createCellEditSession(column.edit?.error !== false); + if (!active) session.deactivate(); + cell = { lineKey, columnId, property: column.accessorKey!, session, changed: false }; + const created = cell; + session.subscribe(() => { + if (session.getState().status === "idle") created.changed = false; + }); + rowCells.set(columnId, cell); + } + return cell; + } + function begin(row: T, column: LineItemsColumn, adapter: LineItemsEditorAdapter) { + const current = getCurrentRow(store.getLineKey(row)); + if ( + !active || + !current || + !isCurrentEditor(column, adapter) || + !(column.edit?.canEdit?.(current) ?? true) + ) + return; + const cell = getCell(row, column); + if (cell.session.getState().status !== "idle") return; + cell.changed = false; + cell.session.begin(adapter.toDraft(current[cell.property])); + } + + const editing = { + assertColumns, + getLineKey: store.getLineKey, + getCurrentRow, + isCurrentEditor, + /** Creating/reading a session does not adopt rows; views subscribe directly to its state. */ + getSession: (row: T, column: LineItemsColumn): Session => getCell(row, column).session, + begin, + change(row: T, column: LineItemsColumn, adapter: LineItemsEditorAdapter, draft: unknown) { + const current = getCurrentRow(store.getLineKey(row)); + if ( + !active || + !current || + !isCurrentEditor(column, adapter) || + !(column.edit?.canEdit?.(current) ?? true) + ) + return; + begin(row, column, adapter); + const cell = getCell(row, column); + cell.changed = true; + cell.session.change(draft); + }, + commit( + row: T, + column: LineItemsColumn, + adapter: LineItemsEditorAdapter, + t: ReturnType, + ): Promise { + if (!active || !getCurrentRow(store.getLineKey(row)) || !isCurrentEditor(column, adapter)) + return Promise.resolve(false); + const cell = getCell(row, column); + const current = cell.session.getState(); + if (current.status === "idle") return Promise.resolve(true); + if (current.status === "confirming" || !cell.changed) + return cell.session.confirm(() => undefined); + let parsed: LineItemsParseResult; + try { + parsed = adapter.parse(current.draft, t); + if ( + !parsed || + typeof parsed !== "object" || + (parsed.ok !== true && parsed.ok !== false) || + (parsed.ok === true && !("value" in parsed)) || + (parsed.ok === false && typeof parsed.message !== "string") + ) + throw new Error("Invalid editor parse result"); + } catch (error) { + cell.session.fail(new Error("Editor parsing failed", { cause: error }), "parse"); + return Promise.resolve(false); + } + if (!parsed.ok) { + cell.session.fail(parsed.message, "parse"); + return Promise.resolve(false); + } + return cell.session.confirm(() => { + if (!Object.is(parsed.value, getCurrentRow(cell.lineKey)?.[cell.property])) + return editing.commitField(cell.lineKey, cell.columnId, parsed.value); + }); + }, + /** The session confirms first; only its still-current successful check may adopt a value. */ + commitField(lineKey: string, columnId: string, value: unknown) { + const row = getCurrentRow(lineKey); + const column = columnFor(columnId); + const source = cells.get(lineKey)?.get(columnId)?.session; + if (!active || !row || !column?.edit || column.accessorKey === undefined || !source) + throw new Error("The edited cell no longer exists"); + if (!(column.edit.canEdit?.(row) ?? true)) throw new Error("The cell is not editable"); + const revision = source.getRevision(); + const check = column.edit.onValueCommitted as + | ((row: Readonly, value: unknown) => void | Promise) + | undefined; + const adopt = () => { + const current = getCurrentRow(lineKey); + const currentColumn = columnFor(columnId); + if ( + !active || + !current || + source.getRevision() !== revision || + currentColumn?.edit?.onValueCommitted !== check || + !(currentColumn?.edit?.canEdit?.(current) ?? true) + ) + return; + store.updateField(lineKey, column.accessorKey!, value as T[keyof T & string]); + cancelProperty(lineKey, column.accessorKey!, source); + }; + if (column.edit.error === false) { + // DataTable-compatible opt-out: adoption is eager and callback errors remain caller-owned. + adopt(); + return check?.(row, value); + } + const result = check?.(row, value); + if (result && typeof result.then === "function") return Promise.resolve(result).then(adopt); + adopt(); + }, + updateField(lineKey: string, property: K, value: T[K]) { + store.updateField(lineKey, property, value); + cancelProperty(lineKey, property); + }, + removeLine(lineKey: string) { + store.removeLine(lineKey); + purgeRemoved(); + }, + revert() { + store.revert(); + for (const cell of records()) cell.session.cancel(); + purgeRemoved(); + }, + /** Called only after React commits; display-only changes do not discard a draft. */ + setColumns(next: readonly LineItemsColumn[]) { + const nextAdapters = assertColumns(next); + const previous = columns; + const previousAdapters = adapters; + columns = next; + adapters = nextAdapters; + for (const [key, rowCells] of cells) { + for (const [id, cell] of rowCells) { + const before = previous.find((column) => (column.id ?? column.accessorKey) === id); + const after = columnFor(id); + const right = after?.edit; + if (!right || after?.accessorKey !== cell.property) { + rowCells.delete(id); + cell.session.cancel(); + cell.session.deactivate(); + continue; + } + if (!sameEditor(before, after, previousAdapters.get(id), adapters.get(id))) + cell.session.cancel(); + const row = getCurrentRow(key); + if (row && !(right.canEdit?.(row) ?? true)) cell.session.cancel(); + if (active) cell.session.activate(right.error !== false); + } + if (rowCells.size === 0) cells.delete(key); + } + }, + /** React owner cleanup, not view detachment, ends pending cell work. */ + deactivate() { + active = false; + for (const cell of records()) cell.session.deactivate(); + }, + activate() { + active = true; + for (const cell of records()) + cell.session.activate(columnFor(cell.columnId)?.edit?.error !== false); + }, + }; + return editing; +} + +export type LineItemsEditing> = ReturnType< + typeof createLineItemsEditing +>; +const owners = new WeakMap(); + +/** Bind the exact hook record without adding an internal controller to its headless API. */ +export function bindLineItemsEditing>( + value: object, + editing: LineItemsEditing, +) { + owners.set(value, editing); +} +export function getLineItemsEditing>( + value: object, +): LineItemsEditing { + const editing = owners.get(value); + if (!editing) throw new Error("Editable LineItems requires the value returned by useLineItems"); + return editing as LineItemsEditing; +} diff --git a/packages/core/src/components/line-items/editor-adapter.test-d.ts b/packages/core/src/components/line-items/editor-adapter.test-d.ts new file mode 100644 index 000000000..9f7a9edf6 --- /dev/null +++ b/packages/core/src/components/line-items/editor-adapter.test-d.ts @@ -0,0 +1,251 @@ +import { createElement, forwardRef, memo, useState } from "react"; +import { expectTypeOf, test } from "vitest"; +import { + defineLineItemsEditor, + type LineItemsColumn, + type LineItemsCustomEditorContext, + type LineItemsEditor, +} from "./index"; +import { editorFactories, type createEditorFactories, type EditorFactory } from "./editors"; +import type { LineItemsEditorType } from "./types"; + +type Row = { + id: string; + quantity: number | null; + required: number; + name: string | null; + broad: string | number | null; + packed: { amount: number; unit: string } | null; + priority: 1 | 2 | null; +}; +type QuantityContext = LineItemsCustomEditorContext; +function QuantityInput(context: QuantityContext) { + const [local] = useState(0); + return createElement("input", { value: context.draft, "data-local": local }); +} + +test("custom definitions keep adopted values, drafts and component context precisely typed", () => { + const editor = defineLineItemsEditor({ + toDraft: (value) => { + expectTypeOf(value).toEqualTypeOf(); + return value === null ? "" : String(value); + }, + parse: (draft) => { + expectTypeOf(draft).toEqualTypeOf(); + return draft === "" ? { ok: true, value: null } : { ok: true, value: Number(draft) }; + }, + render: (context) => { + expectTypeOf(context).toEqualTypeOf(); + expectTypeOf(context.row).toEqualTypeOf>(); + expectTypeOf(context.value).toEqualTypeOf(); + expectTypeOf(context.draft).toEqualTypeOf(); + expectTypeOf(context.onCommit()).toEqualTypeOf>(); + expectTypeOf(context.status).toEqualTypeOf<"idle" | "editing" | "confirming" | "failed">(); + expectTypeOf(context.error).toEqualTypeOf< + Readonly<{ source: "parse" | "change" | "commit"; error: unknown }> | undefined + >(); + expectTypeOf(context.invalid).toEqualTypeOf(); + expectTypeOf(context.describedBy).toEqualTypeOf(); + context.onChange("-"); + // @ts-expect-error The draft is text even though the adopted value is numeric. + context.onChange(2); + // @ts-expect-error Confirmation must parse the session draft, not accept a bypass value. + void context.onCommit(2); + // @ts-expect-error The public row snapshot is readonly. + context.row.quantity = 2; + // @ts-expect-error Session plumbing is private to the adapter. + void context.ui; + return createElement(QuantityInput, context); + }, + }); + const columns: LineItemsColumn[] = [ + { + accessorKey: "quantity", + edit: { + editor, + onValueCommitted: (row, value) => { + expectTypeOf(row).toEqualTypeOf>(); + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }, + { + accessorKey: "broad", + edit: { + editor: { type: "number" }, + onValueCommitted: (_row, value) => { + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }, + ]; + void columns; + // @ts-expect-error A numeric custom editor cannot attach to a text property. + const wrongField: LineItemsColumn = { accessorKey: "name", edit: { editor } }; + // @ts-expect-error A nullable output cannot attach to a required numeric property. + const wrongOutput: LineItemsColumn = { accessorKey: "required", edit: { editor } }; + const requiredEditor = defineLineItemsEditor({ + toDraft: String, + parse: (draft) => ({ ok: true, value: Number(draft) }), + render: () => null, + }); + // @ts-expect-error This editor cannot initialize a draft from an existing null. + const wrongInput: LineItemsColumn = { + accessorKey: "quantity", + edit: { editor: requiredEditor }, + }; + type OtherRow = Omit & { id: number }; + const otherEditor = defineLineItemsEditor({ + toDraft: String, + parse: (draft) => ({ ok: true, value: Number(draft) }), + render: () => null, + }); + // @ts-expect-error The renderer's row contract must fit the consuming row type. + const wrongRow: LineItemsColumn = { accessorKey: "quantity", edit: { editor: otherEditor } }; + void wrongField; + void wrongOutput; + void wrongInput; + void wrongRow; +}); + +test("render accepts components including memo and forwardRef, but not elements or incompatible props", () => { + const definition = { + toDraft: (value: number | null) => String(value), + parse: (draft: string) => ({ ok: true as const, value: Number(draft) }), + }; + defineLineItemsEditor({ ...definition, render: QuantityInput }); + defineLineItemsEditor({ ...definition, render: memo(QuantityInput) }); + const ForwardedInput = forwardRef((context, ref) => + createElement("input", { ref, value: context.draft }), + ); + defineLineItemsEditor({ ...definition, render: ForwardedInput }); + defineLineItemsEditor({ + ...definition, + // @ts-expect-error render is a component definition rather than a rendered element instance. + render: createElement("input"), + }); + defineLineItemsEditor({ + ...definition, + // @ts-expect-error The component cannot require a numeric draft from this text-draft editor. + render: (_context: { draft: number }) => null, + }); + defineLineItemsEditor({ + ...definition, + // @ts-expect-error Parsing must produce the declared adopted value type. + parse: (draft) => ({ ok: true, value: draft }), + render: QuantityInput, + }); + // @ts-expect-error Custom handles cannot be forged from a type discriminator alone. + const forged: LineItemsEditor = { type: "custom" }; + const irrelevant: LineItemsEditor = { + type: "text", + // @ts-expect-error A text editor has no date-range setting. + options: { min: "2026-01-01" }, + }; + const wrongChoice: LineItemsEditor = { + type: "combobox", + options: { + // @ts-expect-error Choice output must fit the property's literal values. + options: [{ value: 3, label: "Invalid" }], + }, + }; + void forged; + void irrelevant; + void wrongChoice; +}); + +test("editor type determines the nested options shape", () => { + const date: LineItemsEditor = { + type: "date", + options: { min: "2026-01-01" }, + }; + const defaultDate: LineItemsEditor = { type: "date" }; + const remote: LineItemsColumn = { + accessorKey: "priority", + edit: { + editor: { + type: "combobox", + options: { + fetcher: async () => [], + getOptionLabel: (value, row) => { + expectTypeOf(value).toEqualTypeOf<1 | 2>(); + expectTypeOf(row).toEqualTypeOf(); + return String(value); + }, + }, + }, + }, + }; + const flatDate: LineItemsEditor = { + type: "date", + // @ts-expect-error Date settings belong inside editor.options. + min: "2026-01-01", + }; + const flatChoices: LineItemsEditor = { + type: "combobox", + // @ts-expect-error options is a configuration object, not the choice array itself. + options: [{ value: 1, label: "One" }], + }; + const wrongDate: LineItemsEditor = { + type: "date", + // @ts-expect-error Date settings do not accept choice candidates. + options: { options: [] }, + }; + void date; + void defaultDate; + void remote; + void flatDate; + void flatChoices; + void wrongDate; +}); + +test("factory signatures determine editor kinds, options and field compatibility", () => { + type Factories = ReturnType>; + type ChoiceConfig = Parameters[0]; + expectTypeOf< + Extract, { type: "combobox" }> + >().toEqualTypeOf<{ type: "combobox" } & ChoiceConfig>(); + expectTypeOf< + Parameters>["number"]>[0] + >().toEqualTypeOf(); + expectTypeOf< + Parameters>["date"]>[0] + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf["type"]>().toEqualTypeOf< + "number" | "combobox" | "custom" + >(); + expectTypeOf["type"]>().toEqualTypeOf<"custom">(); + + const { custom, ...incomplete } = editorFactories; + // @ts-expect-error Every editor kind requires an implementation. + const missing: Record = incomplete; + const extra: Record = { + ...editorFactories, + // @ts-expect-error A runtime registry cannot include an unregistered kind. + unsupported: editorFactories.text, + }; + void custom; + void missing; + void extra; +}); + +test("structured custom drafts can differ completely from their adopted values", () => { + type Draft = { amount: string; unit: string }; + const editor = defineLineItemsEditor({ + toDraft: (value) => ({ amount: String(value?.amount ?? ""), unit: value?.unit ?? "pcs" }), + parse: (draft) => ({ ok: true, value: { amount: Number(draft.amount), unit: draft.unit } }), + render: (context) => { + expectTypeOf(context.draft).toEqualTypeOf(); + expectTypeOf(context.value).toEqualTypeOf(); + context.onChange({ amount: "-", unit: "box" }); + // @ts-expect-error Every draft snapshot must contain all structured input fields. + context.onChange({ amount: "2" }); + // @ts-expect-error The amount input remains text until parsing succeeds. + context.onChange({ amount: 2, unit: "box" }); + return null; + }, + }); + const column: LineItemsColumn = { accessorKey: "packed", edit: { editor } }; + void column; +}); diff --git a/packages/core/src/components/line-items/editor-adapter.test.tsx b/packages/core/src/components/line-items/editor-adapter.test.tsx new file mode 100644 index 000000000..efdec9920 --- /dev/null +++ b/packages/core/src/components/line-items/editor-adapter.test.tsx @@ -0,0 +1,444 @@ +import { useState } from "react"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Input } from "@/components/input"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { + defineLineItemsEditor, + LineItems, + useLineItems, + type LineItemsColumn, + type LineItemsCustomEditorContext, + type UseLineItemsReturn, +} from "./index"; +import { resolveLineItemsEditor } from "./editors"; + +afterEach(cleanup); + +type Row = { + id: string; + quantity: number | null; + day: string | null; + packed: { amount: number; unit: string } | null; +}; +type QuantityContext = LineItemsCustomEditorContext; +type QuantityEdit = NonNullable, { accessorKey: "quantity" }>["edit"]>; +const initial: Row[] = [ + { id: "A", quantity: 1, day: "2026-12-10", packed: { amount: 1, unit: "pcs" } }, +]; + +function QuantityInput(context: QuantityContext) { + const [local, setLocal] = useState(0); + return ( + <> + context.onChange(event.target.value)} + /> + {String(context.value)} + + + + + ); +} +function parseQuantity( + draft: string, +): { ok: true; value: number | null } | { ok: false; message: string } { + if (draft.trim() === "") return { ok: true, value: null }; + const value = Number(draft); + return Number.isFinite(value) + ? { ok: true, value } + : { ok: false, message: "Quantity is incomplete" }; +} +function quantityEditor(parse = parseQuantity) { + return defineLineItemsEditor({ + toDraft: (value) => (value === null ? "" : String(value)), + parse, + render: QuantityInput, + }); +} +function quantityColumn( + editor: QuantityEdit["editor"], + policy: Omit = {}, +): LineItemsColumn { + return { accessorKey: "quantity", label: "Quantity", edit: { editor, ...policy } }; +} +function setup(columns: readonly LineItemsColumn[]) { + let current!: UseLineItemsReturn; + function Harness({ + schema, + visible, + }: { + schema: readonly LineItemsColumn[]; + visible: boolean; + }) { + current = useLineItems({ columns: schema, defaultLines: initial }); + return {visible && }; + } + const view = render(, { wrapper: createAppShellWrapper() }); + return { + user: userEvent.setup(), + current: () => current, + rerender: (schema = columns, visible = true) => + view.rerender(), + }; +} +function quantityInput() { + return screen.getByRole("textbox", { name: "Quantity, line 1" }) as HTMLInputElement; +} +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +describe("LineItems editor adapters", () => { + it("retains an invalid custom draft and accessible error without adopting it, then clears explicitly", async () => { + const { user, current } = setup([quantityColumn(quantityEditor())]); + const input = quantityInput(); + await user.click(input); + fireEvent.change(input, { target: { value: "-" } }); + expect(screen.getByTestId("adopted").textContent).toBe("1"); + expect(current().lines[0].quantity).toBe(1); + expect(current().isDirty).toBe(false); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(input.value).toBe("-"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toContain( + "Quantity is incomplete", + ); + expect(current().lines[0].quantity).toBe(1); + fireEvent.change(input, { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(current().lines[0].quantity).toBe(2); + expect(screen.getByTestId("adopted").textContent).toBe("2"); + fireEvent.change(input, { target: { value: "" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(current().lines[0].quantity).toBeNull(); + }); + + it.each([new Error("Private parser detail"), "Private parser detail"])( + "retains parser exceptions as generic input errors without exposing private details: %s", + async (exception) => { + const editor = quantityEditor(() => { + throw exception; + }); + const { user, current } = setup([quantityColumn(editor)]); + const input = quantityInput(); + fireEvent.change(input, { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(input.value).toBe("2"); + expect(current().lines[0].quantity).toBe(1); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect( + document.getElementById(input.getAttribute("aria-describedby")!)?.textContent, + ).toContain("Enter a valid value."); + expect(screen.queryByText("Private parser detail")).toBeNull(); + }, + ); + + it.each([{ ok: true }, { ok: false }, { ok: false, message: 12 }])( + "rejects malformed untyped parse results without adopting undefined: %j", + async (result) => { + const editor = quantityEditor(() => result as unknown as ReturnType); + const { user, current } = setup([quantityColumn(editor)]); + const input = quantityInput(); + fireEvent.change(input, { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(current().lines[0].quantity).toBe(1); + expect(current().isDirty).toBe(false); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect( + document.getElementById(input.getAttribute("aria-describedby")!)?.textContent, + ).toContain("Enter a valid value."); + }, + ); + + it("rejects a retained commit callback after its row is removed", async () => { + let commit!: QuantityContext["onCommit"]; + const editor = defineLineItemsEditor({ + toDraft: String, + parse: parseQuantity, + render: (context) => { + commit = context.onCommit; + return ; + }, + }); + const { current } = setup([quantityColumn(editor)]); + const retained = commit; + act(() => current().removeLine("A")); + await expect(retained()).resolves.toBe(false); + expect(current().lines).toEqual([]); + }); + + it("mounts custom render with Hooks and retains local state, DOM identity, focus and draft on rerender", async () => { + const editor = quantityEditor(); + const { user, rerender, current } = setup([quantityColumn(editor)]); + const input = quantityInput(); + await user.click(screen.getByRole("button", { name: "Local 0" })); + await user.click(input); + fireEvent.change(input, { target: { value: "12" } }); + rerender([quantityColumn(editor)]); + expect(quantityInput()).toBe(input); + expect(document.activeElement).toBe(input); + expect(screen.getByRole("button", { name: "Local 1" })).toBeDefined(); + expect(input.value).toBe("12"); + expect(current().lines[0].quantity).toBe(1); + }); + + it("keeps equivalent fresh builtin configurations on the same focused input with its draft", async () => { + const { user, rerender, current } = setup([quantityColumn({ type: "number" })]); + const input = quantityInput(); + await user.click(input); + fireEvent.change(input, { target: { value: "12" } }); + rerender([quantityColumn({ type: "number" })]); + expect(quantityInput()).toBe(input); + expect(document.activeElement).toBe(input); + expect(input.value).toBe("12"); + await user.keyboard("{Enter}"); + expect(current().lines[0].quantity).toBe(12); + }); + + it("uses updated builtin bounds even when the editor object is retained", async () => { + const editor = { type: "date" as const, options: { min: "2026-12-01" } }; + const column: LineItemsColumn = { accessorKey: "day", label: "Day", edit: { editor } }; + const { user, rerender, current } = setup([column]); + await user.click(screen.getByRole("button", { name: /^Day/ })); + editor.options.min = "2026-12-11"; + rerender([{ ...column }]); + await user.click(screen.getByRole("button", { name: /^Day/ })); + const popup = document.querySelector('[data-slot="table-cell-calendar"]')!; + const day = Array.from(popup.querySelectorAll("button")).find( + (button) => button.textContent === "20" && !button.hasAttribute("data-outside-month"), + )!; + await user.click(day); + expect(current().lines[0].day).toBe("2026-12-20"); + }); + + it("parses the latest draft when custom UI changes and commits within the same event", async () => { + const editor = defineLineItemsEditor({ + toDraft: (value) => String(value), + parse: parseQuantity, + render: (context) => ( + + ), + }); + const { user, current } = setup([quantityColumn(editor)]); + await user.click(screen.getByRole("button", { name: "Choose four" })); + expect(current().lines[0].quantity).toBe(4); + }); + + it("passes only the public context to custom render", () => { + let received: QuantityContext | undefined; + const editor = defineLineItemsEditor({ + toDraft: (value) => String(value), + parse: parseQuantity, + render: (context) => { + received = context; + return ; + }, + }); + setup([quantityColumn(editor)]); + expect(received).toHaveProperty("value", 1); + expect(received).toHaveProperty("draft", "1"); + expect(received).toHaveProperty("status", "idle"); + expect(received).not.toHaveProperty("ui"); + expect(received).not.toHaveProperty("session"); + expect(received).not.toHaveProperty("Render"); + }); + + it("keeps untouched structured drafts clean and adopts immutable edits without mutating their baseline", async () => { + type Draft = { amount: string; unit: string }; + const parse = vi.fn((draft: Draft) => ({ + ok: true as const, + value: { amount: Number(draft.amount), unit: draft.unit }, + })); + const editor = defineLineItemsEditor({ + toDraft: (value) => ({ amount: String(value?.amount ?? ""), unit: value?.unit ?? "pcs" }), + parse, + render: (context) => ( + <> + context.onChange({ ...context.draft, amount: event.target.value })} + /> + + + ), + }); + const { user, current } = setup([{ accessorKey: "packed", edit: { editor } }]); + const original = current().lines[0].packed; + const input = screen.getByRole("textbox", { name: "Amount" }); + await user.click(input); + await user.click(screen.getByRole("button", { name: "Confirm packed" })); + expect(parse).not.toHaveBeenCalled(); + expect(current().lines[0].packed).toBe(original); + expect(current().isDirty).toBe(false); + fireEvent.change(input, { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm packed" })); + expect(current().lines[0].packed).toEqual({ amount: 2, unit: "pcs" }); + expect(current().lines[0].packed).not.toBe(original); + expect(original).toEqual({ amount: 1, unit: "pcs" }); + }); + + it("retains logical drafts across table detachment while resetting component-local state", async () => { + const editor = quantityEditor(); + const { user, rerender, current } = setup([quantityColumn(editor)]); + await user.click(screen.getByRole("button", { name: "Local 0" })); + fireEvent.change(quantityInput(), { target: { value: "-" } }); + rerender(undefined, false); + expect(screen.queryByRole("textbox")).toBeNull(); + rerender(); + expect(quantityInput().value).toBe("-"); + expect(screen.getByRole("button", { name: "Local 0" })).toBeDefined(); + expect(current().lines[0].quantity).toBe(1); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(quantityInput().value).toBe("1"); + expect(current().isDirty).toBe(false); + }); + + it("deduplicates pending checks, retains rejected drafts and supports explicit retry", async () => { + const request = deferred(); + const check = vi.fn().mockReturnValueOnce(request.promise).mockReturnValue(undefined); + const { user, current } = setup([ + quantityColumn(quantityEditor(), { onValueCommitted: check }), + ]); + fireEvent.change(quantityInput(), { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(check).toHaveBeenCalledTimes(1); + expect(current().lines[0].quantity).toBe(1); + await act(async () => { + request.reject(new Error("Private check detail")); + await request.promise.catch(() => {}); + }); + expect(quantityInput().value).toBe("2"); + expect(quantityInput().getAttribute("aria-invalid")).toBe("true"); + expect(screen.queryByText("Private check detail")).toBeNull(); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(check).toHaveBeenCalledTimes(2); + expect(current().lines[0].quantity).toBe(2); + }); + + it.each(["replace", "new-draft", "revert", "update", "remove"] as const)( + "prevents late adoption after %s through the custom commit pipeline", + async (action) => { + const request = deferred(); + const check = () => request.promise; + const editor = quantityEditor(); + const { user, current, rerender } = setup([ + quantityColumn(editor, { onValueCommitted: check }), + ]); + fireEvent.change(quantityInput(), { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + if (action === "replace") + rerender([quantityColumn(quantityEditor(), { onValueCommitted: check })]); + if (action === "new-draft") fireEvent.change(quantityInput(), { target: { value: "3" } }); + if (action === "revert") act(() => current().revert()); + if (action === "update") act(() => current().updateField("A", "quantity", 7)); + if (action === "remove") act(() => current().removeLine("A")); + await act(async () => { + request.resolve(); + await request.promise; + }); + expect(current().lines).toHaveLength(action === "remove" ? 0 : 1); + if (action !== "remove") + expect(current().lines[0].quantity).toBe(action === "update" ? 7 : 1); + if (action === "new-draft") expect(quantityInput().value).toBe("3"); + }, + ); + + it("never passes an old draft into a replacement component with a different draft type", () => { + const { rerender, current } = setup([quantityColumn(quantityEditor())]); + fireEvent.change(quantityInput(), { target: { value: "-" } }); + const replacement = defineLineItemsEditor({ + toDraft: (value) => ({ text: String(value) }), + parse: (draft) => parseQuantity(draft.text), + render: (context) => ( + + ), + }); + expect(() => rerender([quantityColumn(replacement)])).not.toThrow(); + expect((screen.getByRole("textbox", { name: "Replacement" }) as HTMLInputElement).value).toBe( + "1", + ); + expect(current().lines[0].quantity).toBe(1); + }); + + it("captures definition functions so later mutation cannot change an existing editor contract", async () => { + const definition = { + toDraft: (value: number | null) => String(value), + parse: parseQuantity, + render: QuantityInput, + }; + const editor = defineLineItemsEditor(definition); + definition.parse = () => ({ ok: true, value: 99 }); + const { user, current } = setup([quantityColumn(editor)]); + expect(Object.isFrozen(editor)).toBe(true); + fireEvent.change(quantityInput(), { target: { value: "2" } }); + await user.click(screen.getByRole("button", { name: "Confirm" })); + expect(current().lines[0].quantity).toBe(2); + }); + + it.each(["toString", "constructor", "__proto__", "hasOwnProperty"])( + "rejects inherited registry keys instead of resolving %s", + (type) => { + expect(() => resolveLineItemsEditor({ type })).toThrow("Unsupported LineItems cell editor"); + }, + ); + + it("validates unsupported and incomplete editor configurations at the resolver boundary", () => { + for (const config of [ + null, + false, + { type: "unknown" }, + { type: "custom" }, + { type: "text", options: {} }, + { type: "number", options: {} }, + { type: "boolean", options: {} }, + { type: "combobox" }, + { type: "combobox", options: [] }, + { type: "combobox", fetcher: async () => [], getOptionLabel: () => "Label" }, + { type: "combobox", options: { fetcher: async () => [] } }, + { + type: "combobox", + options: { options: [], fetcher: async () => [], getOptionLabel: () => "Label" }, + }, + { type: "date", min: "2026-12-01" }, + { type: "date", options: null }, + { type: "date", options: { min: "2026-12-31", max: "2026-12-01" } }, + ]) + expect(() => resolveLineItemsEditor(config)).toThrow(); + }); +}); diff --git a/packages/core/src/components/line-items/editor-adapter.tsx b/packages/core/src/components/line-items/editor-adapter.tsx new file mode 100644 index 000000000..17e66a180 --- /dev/null +++ b/packages/core/src/components/line-items/editor-adapter.tsx @@ -0,0 +1,165 @@ +import type { ComponentProps, ComponentType, ReactNode } from "react"; +import { Table } from "@/components/table"; +import { + EditableCellSurface, + type CellSurfaceLabels, + type EditableCellSurfaceProps, +} from "@/components/internals/table-core/cell-surface"; +import type { CellErrorUI } from "@/components/internals/table-core/cell-error"; +import type { createCellEditSession } from "@/components/internals/table-core/cell-session"; +import { OVERLAY_CLASS_NAME } from "@/components/internals/table-core/cell-styles"; +import { cn } from "@/lib/utils"; +import type { useLineItemsT } from "./i18n"; +import type { LineItemsCustomEditorContext } from "./types"; + +export type LineItemsParseResult = + | { ok: true; value: TValue } + | { ok: false; message: string }; + +type EditorUI = { + cellProps: ComponentProps; + display: ReactNode; + error?: CellErrorUI; + containsTarget: (target: EventTarget | null) => boolean; + session: ReturnType>; + labels: CellSurfaceLabels; +}; + +export type LineItemsEditorRenderContext< + TRow extends Record, + TValue, + TDraft, +> = LineItemsCustomEditorContext & { ui: EditorUI }; + +type RuntimeContext = LineItemsEditorRenderContext, unknown, unknown>; + +export type LineItemsEditorAdapter = { + readonly contract: readonly unknown[]; + readonly toDraft: (value: unknown) => unknown; + readonly parse: ( + draft: unknown, + t: ReturnType, + ) => LineItemsParseResult; + readonly render: (context: RuntimeContext) => ReactNode; +}; + +export const LINE_ITEMS_EDITOR_ADAPTER = Symbol("LineItems.editor-adapter"); +declare const compatibility: unique symbol; + +/** The draft type is captured privately; both current input and possible output must fit. */ +export interface DefinedLineItemsEditor, TValue> { + readonly type: "custom"; + readonly [LINE_ITEMS_EDITOR_ADAPTER]: LineItemsEditorAdapter; + readonly [compatibility]?: { + row: (row: TRow) => TRow; + value: (value: TValue) => TValue; + }; +} + +/** The owner may only feed a draft back to its matching editor contract. */ +export function packLineItemsEditor< + TRow extends Record, + TValue, + TDraft, + TProps extends object, +>( + definition: { + toDraft: (value: TValue) => TDraft; + parse: (draft: TDraft, t: ReturnType) => LineItemsParseResult; + render: ComponentType & TProps>; + }, + props: TProps, + contract: readonly unknown[], +): LineItemsEditorAdapter { + const { toDraft, parse, render: Render } = definition; + const boundProps = Object.freeze({ ...props }); + return Object.freeze({ + contract: Object.freeze([...contract]), + toDraft: (value: unknown) => toDraft(value as TValue), + parse: (draft: unknown, t: ReturnType) => parse(draft as TDraft, t), + render: (context: RuntimeContext) => ( + )} + {...boundProps} + /> + ), + }); +} + +export function sameEditorContract( + left: LineItemsEditorAdapter, + right: LineItemsEditorAdapter, +): boolean { + return ( + left.contract.length === right.contract.length && + left.contract.every((value, index) => Object.is(value, right.contract[index])) + ); +} + +export function EditorFrame({ + context, + ...props +}: { + context: Pick; +} & Pick< + EditableCellSurfaceProps, + "children" | "icon" | "hideDisplay" | "clearTintOnFocus" | "onEditorLeave" +> & { display?: ReactNode }) { + return ( + + ); +} + +function CustomEditor, TValue, TDraft>({ + Render, + ui, + ...context +}: LineItemsEditorRenderContext & { + Render: ComponentType>; +}) { + return ( + +
+ +
+
+ ); +} + +/** Define a stable custom editor with synchronous, pure conversion and immutable drafts. */ +export function defineLineItemsEditor< + TRow extends Record, + TValue, + TDraft, +>(definition: { + toDraft: (value: TValue) => TDraft; + parse: (draft: TDraft) => LineItemsParseResult; + render: ComponentType>; +}): DefinedLineItemsEditor { + const { toDraft, parse, render } = definition; + if (typeof toDraft !== "function" || typeof parse !== "function") + throw new Error("Custom editors require toDraft and parse functions"); + return Object.freeze({ + type: "custom", + [LINE_ITEMS_EDITOR_ADAPTER]: packLineItemsEditor< + TRow, + TValue, + TDraft, + { Render: typeof render } + >({ toDraft, parse, render: CustomEditor }, { Render: render }, [{}]), + }); +} diff --git a/packages/core/src/components/line-items/editors.test-d.ts b/packages/core/src/components/line-items/editors.test-d.ts new file mode 100644 index 000000000..ee4679112 --- /dev/null +++ b/packages/core/src/components/line-items/editors.test-d.ts @@ -0,0 +1,124 @@ +import { expectTypeOf, test } from "vitest"; +import type { LineItemsColumn, LineItemsCustomEditorContext, LineItemsEditor } from "./types"; +import { defineLineItemsEditor } from "./editor-adapter"; + +type Row = { + name: string | null; + count: number | null; + enabled: boolean; + day: string | null; + priority: 1 | 2 | null; + pack: { units: number } | null; + required: number; + timestamp: Date | null; +}; + +test("property editors retain output and callback types, including explicit clears", () => { + const columns: LineItemsColumn[] = [ + { + accessorKey: "name", + edit: { + editor: { type: "text" }, + onValueCommitted: (row, value) => { + expectTypeOf(row).toEqualTypeOf>(); + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }, + { + accessorKey: "count", + edit: { + editor: { type: "number" }, + onValueCommitted: (_row, value) => { + expectTypeOf(value).toEqualTypeOf(); + }, + }, + }, + { accessorKey: "enabled", edit: { editor: { type: "boolean" } } }, + { accessorKey: "day", edit: { editor: { type: "date", options: { min: "2026-12-01" } } } }, + { + accessorKey: "priority", + edit: { + editor: { + type: "combobox", + options: { + options: [ + { value: 1, label: "Low" }, + { value: 2, label: "High" }, + ], + }, + }, + onValueCommitted: (_row, value) => { + expectTypeOf(value).toEqualTypeOf<1 | 2 | null>(); + }, + }, + }, + { + accessorKey: "pack", + edit: { + editor: defineLineItemsEditor({ + toDraft: (value) => value, + parse: (draft) => ({ ok: true, value: draft }), + render: (context) => { + expectTypeOf(context).toEqualTypeOf< + LineItemsCustomEditorContext + >(); + context.onChange({ units: 1 }); + context.onChange(null); + void context.onCommit(); + return null; + }, + }), + }, + }, + ]; + void columns; + // @ts-expect-error Numeric editors cannot adopt into a string property. + const wrongOutput: LineItemsColumn = { + accessorKey: "name", + edit: { editor: { type: "number" } }, + }; + // @ts-expect-error An explicit clear emits null, which a required number cannot represent. + const required: LineItemsColumn = { + accessorKey: "required", + edit: { editor: { type: "number" } }, + }; + // @ts-expect-error Date-only editors do not convert Date objects into implicit calendar days. + const timestamp: LineItemsColumn = { + accessorKey: "timestamp", + edit: { editor: { type: "date" } }, + }; + // @ts-expect-error Computed values do not have a document write owner. + const computed: LineItemsColumn = { + id: "computed", + accessor: (row) => row.count, + edit: { editor: { type: "number" as const } }, + }; + // @ts-expect-error Source-less columns cannot adopt editor output. + const sourceLess: LineItemsColumn = { id: "empty", edit: { editor: { type: "text" } } }; + // @ts-expect-error A numeric choice must fit the property's literal union. + const choice: LineItemsColumn = { + accessorKey: "priority", + edit: { editor: { type: "combobox", options: { options: [{ value: 3, label: "Invalid" }] } } }, + }; + const remote: LineItemsEditor = { + type: "combobox", + // @ts-expect-error Remote choices require a label resolver for values outside the current results. + options: { fetcher: async () => [] }, + }; + const callback: NonNullable, { accessorKey: "count" }>["edit"]> = { + editor: { type: "number" }, + // @ts-expect-error A callback cannot narrow away an explicit clear. + onValueCommitted: (_row, value: number) => { + void value; + }, + }; + void wrongOutput; + void required; + void timestamp; + void computed; + void sourceLess; + void choice; + void remote; + void callback; +}); diff --git a/packages/core/src/components/line-items/editors.test.tsx b/packages/core/src/components/line-items/editors.test.tsx new file mode 100644 index 000000000..56f561a41 --- /dev/null +++ b/packages/core/src/components/line-items/editors.test.tsx @@ -0,0 +1,558 @@ +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { StrictMode } from "react"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { LineItems } from "./line-items"; +import { useLineItems } from "./use-line-items"; +import { defineLineItemsEditor } from "./editor-adapter"; +import type { LineItemsColumn, UseLineItemsReturn } from "./types"; + +afterEach(cleanup); +type Row = { + id?: string; + name: string | null; + quantity: number | null; + enabled: boolean; + day: string | null; + unit: string | null; + priority: 1 | 2 | null; + supplier: string | null; + pack: { units: number } | null; +}; +const columns: LineItemsColumn[] = [ + { accessorKey: "name", label: "Name", type: "text", edit: { editor: { type: "text" } } }, + { + accessorKey: "quantity", + label: "Quantity", + type: "number", + edit: { editor: { type: "number" } }, + }, + { accessorKey: "enabled", label: "Enabled", type: "text", edit: { editor: { type: "boolean" } } }, + { + accessorKey: "day", + label: "Date", + type: "date", + edit: { editor: { type: "date", options: { min: "2026-12-01", max: "2026-12-31" } } }, + }, + { + accessorKey: "unit", + label: "Unit", + render: (row) => (row.unit === "box" ? "Display box" : "Display each"), + edit: { + editor: { + type: "combobox", + options: { + options: [ + { value: "pcs", label: "Each" }, + { value: "box", label: "Box" }, + ], + }, + }, + }, + }, + { + accessorKey: "priority", + label: "Priority", + type: "number", + edit: { + editor: { + type: "combobox", + options: { + options: [ + { value: 1, label: "Normal" }, + { value: 2, label: "High" }, + ], + }, + }, + }, + }, + { + accessorKey: "supplier", + label: "Supplier", + type: "text", + edit: { + editor: { + type: "combobox", + options: { + fetcher: { fn: async () => [{ value: "S2", label: "Second supplier" }], debounceMs: 0 }, + getOptionLabel: (value) => (value === "S1" ? "Current supplier" : "Second supplier"), + }, + }, + }, + }, + { + accessorKey: "pack", + label: "Pack", + render: (row) => `${row.pack?.units ?? 0} units`, + edit: { + editor: defineLineItemsEditor({ + toDraft: (value) => value, + parse: (draft) => ({ ok: true, value: draft }), + render: ({ draft, onChange, onCommit, onCancel, ariaLabel }) => ( + + ), + }), + }, + }, +]; +const initial: Row[] = [ + { + id: "A", + name: "First", + quantity: 0.3, + enabled: false, + day: "2026-12-10", + unit: "pcs", + priority: 1, + supplier: "S1", + pack: { units: 5 }, + }, +]; +let current: UseLineItemsReturn; +function Harness({ + schema = columns, + visible = true, +}: { + schema?: readonly LineItemsColumn[]; + visible?: boolean; +}) { + current = useLineItems({ columns: schema, defaultLines: initial }); + return ( + <> + {visible && } + + + + ); +} +function setup(schema?: readonly LineItemsColumn[], locale = "en") { + const view = render( + + + , + { wrapper: createAppShellWrapper(locale) }, + ); + return { ...view, user: userEvent.setup() }; +} +function dayButton(day: string) { + const popup = document.querySelector('[data-slot="table-cell-calendar"]')!; + return within(popup) + .getAllByRole("button") + .find((button) => button.textContent === day && !button.hasAttribute("data-outside-month"))!; +} +function deferred() { + let resolve!: () => void; + let reject!: (error: unknown) => void; + const promise = new Promise((success, failure) => { + resolve = success; + reject = failure; + }); + return { promise, resolve, reject }; +} + +describe("LineItems cell editing", () => { + it("keeps text drafts local, confirms on Enter/Tab/blur, and clears explicitly to null", async () => { + const { user } = setup(); + const input = screen.getByRole("textbox", { name: "Name, line 1" }); + const row = input.closest("tr"); + await user.click(input); + await user.keyboard("Updated"); + expect(current.lines[0].name).toBe("First"); + expect(current.isDirty).toBe(false); + await user.keyboard("{Enter}"); + expect(current.lines[0].name).toBe("Updated"); + expect(input.closest("tr")).toBe(row); + await user.clear(input); + await user.keyboard("{Tab}"); + expect(current.lines[0].name).toBeNull(); + await user.click(input); + await user.keyboard("Blurred"); + await user.click(screen.getByRole("button", { name: "Outside" })); + expect(current.lines[0].name).toBe("Blurred"); + await user.click(screen.getByRole("button", { name: "Discard" })); + expect(current.lines[0].name).toBe("First"); + expect(current.isDirty).toBe(false); + }); + + it("retains malformed number drafts, blocks Tab, and cancels without adopting them", async () => { + const { user } = setup(); + const input = screen.getByRole("textbox", { name: "Quantity, line 1" }) as HTMLInputElement; + await user.click(input); + await user.keyboard("1,5{Tab}"); + expect(document.activeElement).toBe(input); + expect(input.value).toBe("1,5"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(current.lines[0].quantity).toBe(0.3); + expect(current.isDirty).toBe(false); + await user.keyboard("{Escape}{Escape}"); + expect(input.value).toBe("0.3"); + expect(input.getAttribute("aria-invalid")).toBeNull(); + }); + + it("normalizes pasted number syntax but never rounds adopted values to display precision", async () => { + const { user } = setup(); + const input = screen.getByRole("textbox", { name: "Quantity, line 1" }); + await user.click(input); + fireEvent.change(input, { target: { value: "¥1,234.5" } }); + await user.keyboard("{Enter}"); + expect(current.lines[0].quantity).toBe(1234.5); + await user.click(input); + fireEvent.change(input, { target: { value: "0.30000000000000004" } }); + await user.keyboard("{Enter}"); + expect(current.lines[0].quantity).toBe(0.1 + 0.2); + expect(current.isDirty).toBe(true); + fireEvent.change(input, { target: { value: "0.3" } }); + await user.keyboard("{Enter}"); + expect(current.isDirty).toBe(false); + }); + + it("does not confirm text when Enter belongs to an IME composition", () => { + setup(); + const input = screen.getByRole("textbox", { name: "Name, line 1" }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "編集中" } }); + fireEvent.compositionStart(input); + fireEvent.keyDown(input, { key: "Enter", isComposing: true }); + expect(current.lines[0].name).toBe("First"); + fireEvent.compositionEnd(input); + fireEvent.keyDown(input, { key: "Enter" }); + expect(current.lines[0].name).toBe("編集中"); + }); + + it("confirms booleans with Space and restores them on revert", async () => { + const { user } = setup(); + const checkbox = screen.getByRole("checkbox", { name: "Enabled, line 1" }); + act(() => checkbox.focus()); + await user.keyboard(" "); + expect(current.lines[0].enabled).toBe(true); + expect(checkbox.closest("td")?.className).not.toContain( + "astw:focus-within:[background-image:none]", + ); + await user.click(screen.getByRole("button", { name: "Discard" })); + expect(current.lines[0].enabled).toBe(false); + }); + + it("adopts date-only selections, enforces bounds, and cancels/clears explicitly", async () => { + const { user } = setup(); + const trigger = () => screen.getByRole("button", { name: /^Date, line 1/ }); + await user.click(trigger()); + expect(dayButton("1").getAttribute("aria-disabled")).not.toBe("true"); + await user.click(dayButton("20")); + expect(current.lines[0].day).toBe("2026-12-20"); + await user.click(trigger()); + await user.keyboard("{Escape}"); + expect(current.lines[0].day).toBe("2026-12-20"); + await user.click(trigger()); + await user.click(screen.getByRole("button", { name: "Clear date" })); + expect(current.lines[0].day).toBeNull(); + await user.click(trigger()); + act(() => current.revert()); + await waitFor(() => expect(screen.queryByRole("grid")).toBeNull()); + }); + + it("preserves choice value types and keeps labels/search independent of document values", async () => { + const { user } = setup(); + await user.click(screen.getByRole("combobox", { name: "Priority, line 1" })); + await user.click(screen.getByRole("option", { name: "High" })); + expect(current.lines[0].priority).toBe(2); + const combo = screen.getByRole("combobox", { name: "Unit, line 1" }) as HTMLInputElement; + await user.click(combo); + fireEvent.change(combo, { target: { value: "Box" } }); + expect(current.lines[0].unit).toBe("pcs"); + await user.click(screen.getByRole("option", { name: "Box" })); + expect(current.lines[0].unit).toBe("box"); + expect(screen.getByText("Display box")).toBeDefined(); + await user.click(combo); + await user.click(screen.getByRole("button", { name: "Clear value" })); + expect(current.lines[0].unit).toBeNull(); + await user.click(combo); + fireEvent.change(combo, { target: { value: "Box" } }); + act(() => current.revert()); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + expect(combo.value).toBe("Each"); + }); + + it("preserves active choice UI across row updates and restores the display on leave", async () => { + const { user } = setup(); + const combo = screen.getByRole("combobox", { name: "Unit, line 1" }) as HTMLInputElement; + const display = screen.getByText("Display each"); + expect(display.classList.contains("astw:invisible")).toBe(false); + await user.click(combo); + fireEvent.change(combo, { target: { value: "Box" } }); + expect(display.classList.contains("astw:invisible")).toBe(true); + act(() => current.updateField("A", "name", "Updated elsewhere")); + expect(screen.getByRole("combobox", { name: "Unit, line 1" })).toBe(combo); + expect(document.activeElement).toBe(combo); + expect(combo.value).toBe("Box"); + expect(screen.getByRole("option", { name: "Box" })).toBeDefined(); + expect(display.classList.contains("astw:invisible")).toBe(true); + expect(current.lines[0].unit).toBe("pcs"); + await user.keyboard("{Escape}"); + expect(display.classList.contains("astw:invisible")).toBe(true); + await user.click(screen.getByRole("button", { name: "Outside" })); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(display.classList.contains("astw:invisible")).toBe(false); + expect(combo.value).toBe("Each"); + }); + + it("retains failed choice drafts without retaining transient UI across table detachment", async () => { + const request = deferred(); + const check = vi.fn().mockReturnValueOnce(request.promise).mockReturnValue(undefined); + const schema: LineItemsColumn[] = [ + { + accessorKey: "unit", + label: "Unit", + render: columns[4].render, + edit: { + editor: { + type: "combobox", + options: { + options: [ + { value: "pcs", label: "Each" }, + { value: "box", label: "Box" }, + ], + }, + }, + onValueCommitted: check, + }, + }, + ]; + const { user, rerender } = setup(schema); + await user.click(screen.getByRole("combobox", { name: "Unit, line 1" })); + await user.click(screen.getByRole("option", { name: "Box" })); + expect(check).toHaveBeenCalledTimes(1); + expect(screen.getByRole("cell").getAttribute("aria-busy")).toBe("true"); + expect(current.lines[0].unit).toBe("pcs"); + expect(current.isDirty).toBe(false); + rerender( + + + , + ); + await act(async () => { + request.reject(new Error("Rejected choice")); + await request.promise.catch(() => undefined); + }); + rerender( + + + , + ); + const combo = screen.getByRole("combobox", { name: "Unit, line 1" }) as HTMLInputElement; + expect(combo.value).toBe("Box"); + expect(combo.getAttribute("aria-invalid")).toBe("true"); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(screen.getByText("Display each").classList.contains("astw:invisible")).toBe(true); + expect(current.lines[0].unit).toBe("pcs"); + await user.click(combo); + await user.click(screen.getByRole("option", { name: "Box" })); + expect(current.lines[0].unit).toBe("box"); + expect(check).toHaveBeenCalledTimes(2); + }); + + it("loads remote choices only on demand and never adopts their query/fallback label", async () => { + const { user } = setup(); + const combo = screen.getByRole("combobox", { name: "Supplier, line 1" }); + await user.click(combo); + await screen.findByRole("option", { name: "Second supplier" }); + fireEvent.change(combo, { target: { value: "query" } }); + expect(current.lines[0].supplier).toBe("S1"); + await user.click(await screen.findByRole("option", { name: "Second supplier" })); + expect(current.lines[0].supplier).toBe("S2"); + }); + + it("adopts structured custom values without serializing or mutating the original leaf", async () => { + const { user } = setup(); + const original = current.lines[0].pack; + await user.click(screen.getByRole("button", { name: "Increase Pack, line 1" })); + expect(current.lines[0].pack).toEqual({ units: 6 }); + expect(current.lines[0].pack).not.toBe(original); + expect(original).toEqual({ units: 5 }); + }); + + it("waits for checks, retains rejection with its draft, and allows explicit retry", async () => { + const request = deferred(); + const check = vi.fn().mockReturnValueOnce(request.promise).mockReturnValue(undefined); + const schema: LineItemsColumn[] = [ + { + accessorKey: "name", + label: "Name", + edit: { editor: { type: "text" }, onValueCommitted: check }, + }, + ]; + const { user } = setup(schema); + const input = screen.getByRole("textbox", { name: "Name, line 1" }) as HTMLInputElement; + await user.click(input); + await user.keyboard("Candidate{Enter}{Enter}"); + expect(check).toHaveBeenCalledTimes(1); + expect(input.closest("td")?.getAttribute("aria-busy")).toBe("true"); + expect(current.lines[0].name).toBe("First"); + await act(async () => { + request.reject(new Error("Private failure details")); + await request.promise.catch(() => undefined); + }); + expect(input.value).toBe("Candidate"); + expect(input.getAttribute("aria-invalid")).toBe("true"); + expect(screen.queryByText("Private failure details")).toBeNull(); + await user.keyboard("{Enter}"); + expect(current.lines[0].name).toBe("Candidate"); + expect(check).toHaveBeenCalledTimes(2); + }); + + it("retains a logical draft across table detachment and keeps disabled rows readonly", async () => { + const schema: LineItemsColumn[] = [ + { + accessorKey: "name", + label: "Name", + edit: { editor: { type: "text" }, canEdit: (row) => !row.enabled }, + }, + { accessorKey: "enabled", label: "Enabled" }, + ]; + const { user, rerender } = setup(schema); + await user.click(screen.getByRole("textbox", { name: "Name, line 1" })); + await user.keyboard("Retained"); + rerender( + + + , + ); + rerender( + + + , + ); + const input = screen.getByRole("textbox", { name: "Name, line 1" }) as HTMLInputElement; + expect(input.value).toBe("Retained"); + expect(current.lines[0].name).toBe("First"); + act(() => current.updateField("A", "enabled", true)); + expect(screen.queryByRole("textbox")).toBeNull(); + }); + + it("completes an in-flight edit after only its table view detaches", async () => { + const request = deferred(); + const schema: LineItemsColumn[] = [ + { + accessorKey: "name", + label: "Name", + edit: { editor: { type: "text" }, onValueCommitted: () => request.promise }, + }, + ]; + const { user, rerender } = setup(schema); + await user.click(screen.getByRole("textbox", { name: "Name, line 1" })); + await user.keyboard("Confirmed{Enter}"); + rerender( + + + , + ); + await act(async () => { + request.resolve(); + await request.promise; + }); + expect(current.lines[0].name).toBe("Confirmed"); + rerender( + + + , + ); + expect((screen.getByRole("textbox", { name: "Name, line 1" }) as HTMLInputElement).value).toBe( + "Confirmed", + ); + }); + + it("rebinds a reused column ID to its new property without retaining the previous draft", () => { + const before: LineItemsColumn[] = [ + { id: "editor", accessorKey: "name", label: "Name", edit: { editor: { type: "text" } } }, + { id: "other", accessorKey: "quantity" }, + ]; + const after: LineItemsColumn[] = [ + { + id: "editor", + accessorKey: "quantity", + label: "Quantity", + edit: { editor: { type: "number" } }, + }, + { id: "other", accessorKey: "name" }, + ]; + const { rerender } = setup(before); + const input = screen.getByRole("textbox", { name: "Name, line 1" }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "Stale" } }); + rerender( + + + , + ); + const next = screen.getByRole("textbox", { name: "Quantity, line 1" }) as HTMLInputElement; + expect(next.value).toBe("0.3"); + fireEvent.change(next, { target: { value: "3" } }); + fireEvent.keyDown(next, { key: "Enter" }); + expect(current.lines[0]).toMatchObject({ name: "First", quantity: 3 }); + }); + + it("does not pass an old numeric text draft into a newly configured custom editor", () => { + const before: LineItemsColumn[] = [ + { accessorKey: "quantity", label: "Quantity", edit: { editor: { type: "number" } } }, + ]; + const after: LineItemsColumn[] = [ + { + accessorKey: "quantity", + label: "Quantity", + edit: { + editor: defineLineItemsEditor({ + toDraft: (value) => value, + parse: (draft) => ({ ok: true, value: draft }), + render: ({ draft, onChange, onCommit }) => ( + + ), + }), + }, + }, + ]; + const { rerender } = setup(before); + const input = screen.getByRole("textbox", { name: "Quantity, line 1" }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "12" } }); + expect(() => + rerender( + + + , + ), + ).not.toThrow(); + const button = screen.getByRole("button", { name: "Custom 0.3" }); + expect(current.lines[0].quantity).toBe(0.3); + fireEvent.click(button); + expect(current.lines[0].quantity).toBe(4); + }); + + it("localizes editor names and parse feedback in Japanese", () => { + setup(undefined, "ja"); + const input = screen.getByRole("textbox", { name: "Quantity, 行1" }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: "-" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(document.getElementById(input.getAttribute("aria-describedby")!)?.textContent).toContain( + "有効な数値を入力してください。", + ); + }); +}); diff --git a/packages/core/src/components/line-items/editors.tsx b/packages/core/src/components/line-items/editors.tsx new file mode 100644 index 000000000..9be8f0c81 --- /dev/null +++ b/packages/core/src/components/line-items/editors.tsx @@ -0,0 +1,331 @@ +import { useState } from "react"; +import { CalendarDays, ChevronDown } from "lucide-react"; +import type { AsyncFetcher } from "@/hooks/use-async-items"; +import { TypingEditor } from "@/components/internals/table-core/editors/typing"; +import { BooleanEditor } from "@/components/internals/table-core/editors/boolean"; +import { DateEditor } from "@/components/internals/table-core/editors/date"; +import { + ChoiceEditor, + type ChoiceEditorConfig, +} from "@/components/internals/table-core/editors/choice"; +import { parseDateValue } from "@/components/internals/table-core/date-values"; +import { + normalizeNumberText, + parseNumberText, + toNumberEditText, + toNumberValue, + toTextValue, +} from "@/components/internals/table-core/input-values"; +import { + EditorFrame, + LINE_ITEMS_EDITOR_ADAPTER, + packLineItemsEditor, + type DefinedLineItemsEditor, + type LineItemsEditorAdapter, + type LineItemsEditorRenderContext, +} from "./editor-adapter"; +import type { LineItemsEditorType, LineItemsOption } from "./types"; + +type Context> = LineItemsEditorRenderContext< + TRow, + unknown, + unknown +>; + +function TypingCell>({ + numeric, + ...context +}: Context & { numeric: boolean }) { + const isEditing = context.status !== "idle"; + return ( + { + void context.onCommit(); + }} + > + { + void context.onCommit(); + }} + onCancel={context.onCancel} + onTab={(event) => { + void context.onCommit(); + // Parsing is synchronous, before native Tab moves focus. + if (context.ui.session.getFailure()?.source === "parse") event.preventDefault(); + }} + /> + + ); +} + +function BooleanCell>(context: Context) { + return ( + + { + context.onChange(next); + void context.onCommit(); + }} + /> + + ); +} + +function DateCell>({ + bounds, + ...context +}: Context & { bounds: { min?: string; max?: string } }) { + const draftDisplay = typeof context.draft === "string" ? context.draft : "—"; + return ( + } + hideDisplay="forced-colors" + > + { + context.onChange(next); + void context.onCommit(); + }} + /> + + ); +} + +function ChoiceCell>({ + config, + ...context +}: Context & { config: ChoiceEditorConfig }) { + const [active, setActive] = useState(false); + const showValue = context.status === "failed" || context.status === "confirming"; + return ( + } + hideDisplay={active || showValue ? "focused" : "forced-colors"} + > + { + context.onChange(next); + return context.onCommit(); + }} + onActiveChange={setActive} + showValue={showValue} + /> + + ); +} + +const TEXT = Symbol("text"); +const NUMBER = Symbol("number"); +const BOOLEAN = Symbol("boolean"); +const DATE = Symbol("date"); +const CHOICE = Symbol("choice"); + +export type EditorFactory = ( + config: Record, + options: Record, +) => LineItemsEditorAdapter; + +type Adoptable = [TOutput] extends [TValue] ? TEditor : never; +type ChoiceEditorOptions, TValue extends string | number> = + | { + options: readonly LineItemsOption[]; + fetcher?: never; + getOptionLabel?: never; + placeholder?: string; + } + | { + options?: never; + fetcher: AsyncFetcher>; + getOptionLabel: (value: TValue, row: TRow) => string; + placeholder?: string; + }; + +/** Factory signatures are the source of supported kinds and field-specific configuration. */ +export function createEditorFactories, TValue>() { + return { + text(config: Adoptable) { + if (config.options !== undefined) throw new Error("Text editors have no options"); + return packLineItemsEditor( + { + toDraft: (current: unknown) => toTextValue(current) ?? "", + parse: (draft: unknown, t) => + typeof draft === "string" + ? { ok: true, value: toTextValue(draft) } + : { ok: false, message: t("invalidValue") }, + render: TypingCell, + }, + { numeric: false }, + [TEXT], + ); + }, + number(config: Adoptable) { + if (config.options !== undefined) throw new Error("Number editors have no options"); + return packLineItemsEditor( + { + toDraft: (current: unknown) => toNumberEditText(toNumberValue(current)), + parse: (draft: unknown, t) => { + const parsed = + typeof draft === "string" ? parseNumberText(normalizeNumberText(draft)) : undefined; + return parsed === undefined + ? { ok: false, message: t("invalidNumber") } + : { ok: true, value: parsed }; + }, + render: TypingCell, + }, + { numeric: true }, + [NUMBER], + ); + }, + boolean(config: Adoptable) { + if (config.options !== undefined) throw new Error("Boolean editors have no options"); + return packLineItemsEditor( + { + toDraft: (current: unknown) => current, + parse: (draft: unknown, t) => + typeof draft === "boolean" + ? { ok: true, value: draft } + : { ok: false, message: t("invalidValue") }, + render: BooleanCell, + }, + {}, + [BOOLEAN], + ); + }, + date( + config: [TValue] extends [string | null | undefined] + ? Adoptable + : never, + options: Record, + ) { + if ("min" in config || "max" in config) + throw new Error("Date settings belong in editor.options"); + const { min, max } = options; + if ( + (min !== undefined && typeof min !== "string") || + (max !== undefined && typeof max !== "string") + ) + throw new Error("Date bounds must be date-only strings"); + const bounds = { min, max }; + parseDateValue(null, bounds); + return packLineItemsEditor( + { + toDraft: (current: unknown) => current, + parse: (draft: unknown, t) => { + const parsed = parseDateValue(draft, bounds); + return parsed === undefined + ? { ok: false, message: t("invalidDate") } + : { ok: true, value: parsed }; + }, + render: DateCell, + }, + { bounds }, + [DATE, min, max], + ); + }, + combobox( + config: null extends TValue + ? [Extract] extends [never] + ? never + : { options: ChoiceEditorOptions> } + : never, + options: Record, + ) { + if ("fetcher" in config || "getOptionLabel" in config || "placeholder" in config) + throw new Error("Choice settings belong in editor.options"); + if (options.fetcher === undefined && !Array.isArray(options.options)) + throw new Error("Choice editors require options or a fetcher"); + if ( + options.fetcher !== undefined && + (options.options !== undefined || typeof options.getOptionLabel !== "function") + ) + throw new Error("Remote choices require getOptionLabel and cannot also define options"); + return packLineItemsEditor( + { + toDraft: (current: unknown) => current, + parse: (draft: unknown, t) => + draft === null || + typeof draft === "string" || + (typeof draft === "number" && Number.isFinite(draft)) + ? { ok: true, value: draft } + : { ok: false, message: t("invalidValue") }, + render: ChoiceCell, + }, + { config: { ...options } as ChoiceEditorConfig> }, + [CHOICE, options.fetcher], + ); + }, + custom(config: Omit, "type">) { + if (!(LINE_ITEMS_EDITOR_ADAPTER in config)) + throw new Error("Custom editors must be created with defineLineItemsEditor"); + return config[LINE_ITEMS_EDITOR_ADAPTER]; + }, + } satisfies Record LineItemsEditorAdapter>; +} + +// Only the validated runtime dispatcher erases the field-dependent input signatures. +export const editorFactories = createEditorFactories< + Record, + unknown +>() as unknown as Record; + +/** Validate editor configuration and resolve every editor into the same execution contract. */ +export function resolveLineItemsEditor(value: unknown): LineItemsEditorAdapter { + if (!value || typeof value !== "object") throw new Error("Unsupported LineItems cell editor"); + const config = value as Record; + const type = config.type; + if (typeof type !== "string" || !Object.hasOwn(editorFactories, type)) + throw new Error("Unsupported LineItems cell editor"); + if ( + config.options !== undefined && + (!config.options || typeof config.options !== "object" || Array.isArray(config.options)) + ) + throw new Error("LineItems editor.options must be a configuration object"); + return editorFactories[type as LineItemsEditorType]( + config, + (config.options ?? {}) as Record, + ); +} diff --git a/packages/core/src/components/line-items/i18n.ts b/packages/core/src/components/line-items/i18n.ts index f0190b763..8b0cc810d 100644 --- a/packages/core/src/components/line-items/i18n.ts +++ b/packages/core/src/components/line-items/i18n.ts @@ -1,8 +1,32 @@ import { defineI18nLabels } from "@/hooks/i18n"; const labels = defineI18nLabels({ - en: { title: "Line items", empty: "No lines yet." }, - ja: { title: "明細", empty: "明細がありません。" }, + en: { + title: "Line items", + empty: "No lines yet.", + row: ({ index }: { index: number }) => `line ${index}`, + invalidNumber: "Enter a valid number.", + invalidDate: "Enter a valid date within the allowed range.", + invalidValue: "Enter a valid value.", + commitError: "The value could not be confirmed.", + errorHint: "Press Alt+ArrowDown to inspect the error.", + showError: ({ label }: { label: string }) => `Show error for ${label}`, + errorDialog: ({ label }: { label: string }) => `Error for ${label}`, + closeError: "Close error details", + }, + ja: { + title: "明細", + empty: "明細がありません。", + row: ({ index }: { index: number }) => `行${index}`, + invalidNumber: "有効な数値を入力してください。", + invalidDate: "指定された範囲の有効な日付を入力してください。", + invalidValue: "有効な値を入力してください。", + commitError: "値を確定できませんでした。", + errorHint: "Alt+ArrowDownでエラーの詳細を確認できます。", + showError: ({ label }: { label: string }) => `${label}のエラーを表示`, + errorDialog: ({ label }: { label: string }) => `${label}のエラー`, + closeError: "エラーの詳細を閉じる", + }, }); export const useLineItemsT = labels.useT; diff --git a/packages/core/src/components/line-items/index.ts b/packages/core/src/components/line-items/index.ts index 75c4201ed..c816a5e34 100644 --- a/packages/core/src/components/line-items/index.ts +++ b/packages/core/src/components/line-items/index.ts @@ -1,3 +1,11 @@ export { LineItems, type LineItemsRootProps, type LineItemsTableProps } from "./line-items"; export { useLineItems } from "./use-line-items"; -export type { LineItemsColumn, UseLineItemsOptions, UseLineItemsReturn } from "./types"; +export { defineLineItemsEditor } from "./editor-adapter"; +export type { + LineItemsColumn, + LineItemsEditor, + LineItemsOption, + LineItemsCustomEditorContext, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; diff --git a/packages/core/src/components/line-items/line-items-cell.tsx b/packages/core/src/components/line-items/line-items-cell.tsx new file mode 100644 index 000000000..7a207bd87 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-cell.tsx @@ -0,0 +1,85 @@ +import { useSyncExternalStore, type ComponentProps, type ReactNode } from "react"; +import { Table } from "@/components/table"; +import { useCellError } from "@/components/internals/table-core/cell-error"; +import { CellEditError } from "@/components/internals/table-core/cell-edit-error"; +import type { LineItemsEditorAdapter, LineItemsEditorRenderContext } from "./editor-adapter"; +import type { LineItemsEditing } from "./editing"; +import { useLineItemsT } from "./i18n"; +import type { LineItemsColumn } from "./types"; + +type LineItemsCellProps> = { + row: T; + column: LineItemsColumn; + index: number; + editing: LineItemsEditing; + display: ReactNode; + adapter?: LineItemsEditorAdapter; + cellProps: ComponentProps; +}; + +/** Views render adapters; their document owner coordinates every logical edit. */ +export function LineItemsCell>({ + row, + column, + index, + editing, + display, + adapter, + cellProps, +}: LineItemsCellProps) { + const t = useLineItemsT(); + const config = column.edit; + const session = editing.getSession(row, column); + const snapshot = useSyncExternalStore(session.subscribe, session.getState, session.getState); + // A replacement editor must never receive its predecessor's draft, even before commit. + const state = editing.isCurrentEditor(column, adapter) ? snapshot : { status: "idle" as const }; + const failure = state.status === "failed" ? state : undefined; + let message = t("commitError"); + if (failure?.source === "parse") + message = typeof failure.error === "string" ? failure.error : t("invalidValue"); + else if (failure?.error instanceof CellEditError && failure.error.message) + message = failure.error.message; + const renderError = config?.error && config.error.render; + const { error, containsTarget } = useCellError({ + failure, + message: renderError && failure?.source !== "parse" ? t("commitError") : message, + renderContent: + failure && renderError && failure.source !== "parse" + ? () => renderError({ row, error: failure.error }) + : undefined, + }); + if (!adapter || !(config?.canEdit?.(row) ?? true)) + return {display}; + + const columnId = (column.id ?? column.accessorKey)!; + const label = `${column.label ?? columnId}, ${t("row", { index: index + 1 })}`; + const value = column.accessorKey === undefined ? undefined : row[column.accessorKey]; + const context: LineItemsEditorRenderContext = { + row, + value, + draft: state.status === "idle" ? adapter.toDraft(value) : state.draft, + status: state.status, + error: failure ? { source: failure.source, error: failure.error } : undefined, + onBegin: () => editing.begin(row, column, adapter), + onChange: (draft) => editing.change(row, column, adapter, draft), + onCommit: () => editing.commit(row, column, adapter, t), + onCancel: session.cancel, + ariaLabel: label, + invalid: !!error, + describedBy: error?.id, + ui: { + cellProps, + display, + error, + containsTarget, + session, + labels: { + errorHint: t("errorHint"), + showError: t("showError", { label }), + errorDialog: t("errorDialog", { label }), + closeError: t("closeError"), + }, + }, + }; + return adapter.render(context); +} diff --git a/packages/core/src/components/line-items/line-items.test-d.ts b/packages/core/src/components/line-items/line-items.test-d.ts index d81cdc456..fc8ff4ba6 100644 --- a/packages/core/src/components/line-items/line-items.test-d.ts +++ b/packages/core/src/components/line-items/line-items.test-d.ts @@ -39,7 +39,7 @@ test("row commands retain row types without exposing unfinished save APIs", () = value.updateField("A", "missing", 2); // @ts-expect-error A new line must supply the complete editing model. value.addLine({ name: "Incomplete" }); - // @ts-expect-error This slice does not implement cell confirmation or persistence. + // @ts-expect-error Save preparation and persistence are still deferred. value.commitEdits(); // @ts-expect-error Controlled rows are not supported. const controlled: UseLineItemsOptions = { columns: [], lines: [] }; @@ -87,8 +87,8 @@ test("property, computed and value-less columns keep source and display contract }; // @ts-expect-error A link still requires href options. const link: LineItemsColumn = { accessorKey: "name", type: "link" }; - // @ts-expect-error No cell editor is implemented in the readonly slice. - const editing: LineItemsColumn = { accessorKey: "name", edit: { editorType: "text" } }; + // @ts-expect-error A text editor may clear to null, so the property must accept null. + const editing: LineItemsColumn = { accessorKey: "name", edit: { editor: { type: "text" } } }; void property; void computed; void custom; diff --git a/packages/core/src/components/line-items/line-items.tsx b/packages/core/src/components/line-items/line-items.tsx index 0191d2883..d091782fd 100644 --- a/packages/core/src/components/line-items/line-items.tsx +++ b/packages/core/src/components/line-items/line-items.tsx @@ -2,13 +2,16 @@ import { useMemo, type ReactNode } from "react"; import { Table } from "@/components/table"; import { Text } from "@/components/text"; import { useTimeZone } from "@/contexts/appshell-context"; -import { createValueRenderer } from "@/components/internals/table-core"; import { cn } from "@/lib/utils"; import { LineItemsContext, useLineItemsContext } from "./line-items-context"; +import { LineItemsCell } from "./line-items-cell"; +import { resolveLineItemsColumn } from "./column-adapter"; +import { getLineItemsEditing } from "./editing"; import { useLineItemsT } from "./i18n"; import type { UseLineItemsReturn } from "./types"; export type LineItemsRootProps> = { + /** Pass the hook result directly; editable cells use the document owner bound to that record. */ value: UseLineItemsReturn; children: ReactNode; className?: string; @@ -49,12 +52,7 @@ function LineItemsTable({ className, emptyMessage, "aria-label": label }: LineIt value.columns.map((column) => ({ column, id: column.id ?? column.accessorKey!, - align: - column.align ?? - (column.type === "number" || column.type === "money" - ? ("right" as const) - : ("left" as const)), - renderValue: column.render ? undefined : createValueRenderer(column, timeZone), + adapter: resolveLineItemsColumn(column, timeZone), })), [value.columns, timeZone], ); @@ -63,28 +61,36 @@ function LineItemsTable({ className, emptyMessage, "aria-label": label }: LineIt {label ?? t("title")} - {columns.map(({ column, id, align }) => ( - + {columns.map(({ column, id, adapter }) => ( + {column.label} ))} - {value.lines.map((row) => ( + {value.lines.map((row, index) => ( - {columns.map(({ column, id, align, renderValue }) => ( - - {column.render - ? column.render(row) - : renderValue!( - column.accessorKey !== undefined - ? row[column.accessorKey] - : column.accessor?.(row), - row, - )} - - ))} + {columns.map(({ column, id, adapter }) => { + const display = adapter.renderDisplay(row); + const align = adapter.align; + return column.edit ? ( + >(value)} + display={display} + adapter={adapter.editor} + cellProps={{ align }} + /> + ) : ( + + {display} + + ); + })} ))} {value.lines.length === 0 && ( diff --git a/packages/core/src/components/line-items/types.ts b/packages/core/src/components/line-items/types.ts index 9fbf25a54..7cd098039 100644 --- a/packages/core/src/components/line-items/types.ts +++ b/packages/core/src/components/line-items/types.ts @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import type { createEditorFactories } from "./editors"; import type { ColumnCellType, ColumnDisplay, @@ -6,6 +7,56 @@ import type { TypedColumnDisplay, } from "@/components/internals/table-core/column-types"; +type EditorBase, TValue> = { + /** A check before adoption; throw/reject retains the draft. This is not a persistence callback. */ + onValueCommitted?: (row: Readonly, value: TValue) => void | Promise; + canEdit?: (row: TRow) => boolean; + /** Opt out of interception and adopt eagerly; the caller owns callback failures. */ + error?: false | { render: (ctx: { row: TRow; error: unknown }) => ReactNode }; +}; + +export type LineItemsOption = { + value: TValue; + label: string; + description?: string; +}; + +export interface LineItemsCustomEditorContext< + TRow extends Record, + TValue, + TDraft, +> { + /** Current adopted value; draft changes do not update the document. */ + value: TValue; + /** An immutable input snapshot, which may be incomplete or invalid. */ + draft: TDraft; + onBegin: () => void; + onChange: (draft: TDraft) => void; + /** Parse and confirm the session's latest draft, never a supplied value. */ + onCommit: () => Promise; + onCancel: () => void; + row: Readonly; + status: "idle" | "editing" | "confirming" | "failed"; + error: Readonly<{ source: "parse" | "change" | "commit"; error: unknown }> | undefined; + ariaLabel: string; + invalid: boolean; + describedBy?: string; +} + +type EditorFactories, TValue> = ReturnType< + typeof createEditorFactories +>; +type EditorTypeMap, TValue> = { + [K in keyof EditorFactories]: Parameters[K]>[0]; +}; + +export type LineItemsEditorType = keyof EditorFactories, unknown>; + +/** Editor output must fit its property, including the null emitted by an explicit clear. */ +export type LineItemsEditor, TValue> = { + [K in LineItemsEditorType]: { type: K } & EditorTypeMap[K]; +}[LineItemsEditorType]; + type DisplayProps> = { label?: string; /** Overrides the built-in renderer, including when it returns null. */ @@ -19,12 +70,14 @@ type PropertyColumn> = { accessorKey: K; accessor?: never; id?: string; + edit?: EditorBase & { editor: LineItemsEditor }; }; }[keyof TRow & string]; type ComputedColumn> = DisplayProps & { id: string; accessorKey?: never; + edit?: never; } & ( | { type?: undefined; typeOptions?: never; accessor: (row: TRow) => unknown } | { @@ -39,18 +92,19 @@ type SourceLessColumn> = DisplayProps id: string; accessorKey?: never; accessor?: never; + edit?: never; }; -/** A property, computed value, or explicitly value-less display column. */ +/** A property with an optional editor, computed value, or value-less display column. */ export type LineItemsColumn> = | PropertyColumn | ComputedColumn | SourceLessColumn; -/** Initial document ownership and row commands; cell editors and persistence are not yet supported. */ +/** Initial document ownership and cell editing; persistence and business validation are not supported. */ export type UseLineItemsOptions> = { columns: readonly LineItemsColumn[]; - /** Read once. ID-less rows are unsaved creations and are removed by revert. Remount the hook's owner to display another document. */ + /** Read once. ID-less rows are unsaved creations removed by revert. Remount to change documents. */ defaultLines?: readonly TRow[]; /** Server identity property; missing IDs receive stable client-only keys. Defaults to id. */ idKey?: keyof TRow & string; @@ -62,16 +116,16 @@ export type UseLineItemsReturn> = { readonly columns: readonly LineItemsColumn[]; readonly lines: readonly TRow[]; readonly orderer: boolean; - /** Changes to persisted rows or the presence of unsaved creations. */ + /** Adopted changes to the persisted baseline; unconfirmed cell drafts are not included. */ readonly isDirty: boolean; getLineKey: (row: TRow) => string; - /** Updates a property owned by an accessorKey column; server identity cannot change. */ + /** Updates an accessorKey-owned property and discards drafts for that property. */ updateField: (lineKey: string, key: K, value: TRow[K]) => void; /** Appends a new creation, removing any server ID from the supplied row. */ addLine: (line: TRow) => string; removeLine: (lineKey: string) => void; /** Moves a line after another current line; null moves it to the front. Requires orderer: true. */ reorderLine: (lineKey: string, afterLineKey: string | null) => void; - /** Restores persisted initial rows and discards every unsaved creation. */ + /** Restores persisted initial rows and discards every unsaved creation and cell draft. */ revert: () => void; }; diff --git a/packages/core/src/components/line-items/use-line-items.test.tsx b/packages/core/src/components/line-items/use-line-items.test.tsx index bf860017b..e57857fbd 100644 --- a/packages/core/src/components/line-items/use-line-items.test.tsx +++ b/packages/core/src/components/line-items/use-line-items.test.tsx @@ -76,10 +76,11 @@ it("rejects duplicate and empty column identities", () => { ).toThrow("column IDs must be non-empty and unique"); }); -it("rejects cell editors instead of silently ignoring them", () => { - const editable = { accessorKey: "name" as const, edit: { editorType: "text" } }; +it("rejects editors on computed columns instead of pretending to own their values", () => { + const editable: LineItemsColumn = { id: "computed", accessor: (row) => row.name }; + Object.assign(editable, { edit: { editor: { type: "text" } } }); expect(() => renderHook(() => useLineItems({ columns: [editable] }))).toThrow( - "cell editors are not supported yet", + "Only accessorKey properties may have cell editors", ); }); diff --git a/packages/core/src/components/line-items/use-line-items.ts b/packages/core/src/components/line-items/use-line-items.ts index d4cdb77bf..720320484 100644 --- a/packages/core/src/components/line-items/use-line-items.ts +++ b/packages/core/src/components/line-items/use-line-items.ts @@ -1,5 +1,6 @@ import { useCallback, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { createLineItemsDocumentStore } from "./document-store"; +import { bindLineItemsEditing, createLineItemsEditing } from "./editing"; import type { UseLineItemsOptions, UseLineItemsReturn } from "./types"; /** Owns one document; changing the document identity requires remounting the hook's owner. */ @@ -19,10 +20,20 @@ export function useLineItems>( idKey: options.idKey, }), ); + const [editing] = useState(() => createLineItemsEditing(store, options.columns)); if ((options.idKey ?? "id") !== store.idKey) throw new Error("Remount LineItems to change idKey"); + editing.assertColumns(options.columns); // This checks ownership only; abandoned renders cannot modify the document. store.setColumns(options.columns); const lines = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot); + // Only the document owner, not an individual cell view, owns session disposal. + useLayoutEffect(() => { + editing.activate(); + return editing.deactivate; + }, [editing]); + useLayoutEffect(() => { + editing.setColumns(options.columns); + }, [editing, options.columns, lines]); // Retained commands must not observe a movement policy from an abandoned render. useLayoutEffect(() => { committedOrderer.current = orderer; @@ -34,16 +45,18 @@ export function useLineItems>( }, [store], ); - return { + const value = { columns: options.columns, lines, orderer, isDirty: store.isDirty(), getLineKey: store.getLineKey, - updateField: store.updateField, + updateField: editing.updateField, addLine: store.addLine, - removeLine: store.removeLine, + removeLine: editing.removeLine, reorderLine, - revert: store.revert, + revert: editing.revert, }; + bindLineItemsEditing(value, editing); + return value; } diff --git a/packages/core/src/hooks/i18n.ts b/packages/core/src/hooks/i18n.ts index 88a1ba9b1..962a4b075 100644 --- a/packages/core/src/hooks/i18n.ts +++ b/packages/core/src/hooks/i18n.ts @@ -70,14 +70,15 @@ export type I18nLabels< * t("welcome", { name: "John" }); // "Welcome, John!" * ``` */ -export const defineI18nLabels = < +// Hoist the factory so configuration/label import cycles can initialize safely. +export function defineI18nLabels< const L extends Exclude, const Def extends Record, >( labels: { en: Def } & { [K in L]: LabelDefinition; } & DynamicLocales, -) => { +) { type T = keyof Def & string; const resolveLabel = (locale: string) => (key: T) => @@ -188,4 +189,4 @@ export const defineI18nLabels = < { [LOCALIZED_LABEL]: true }, ), }; -}; +}