diff --git a/.changeset/wise-lines-gather.md b/.changeset/wise-lines-gather.md new file mode 100644 index 000000000..5e906eb24 --- /dev/null +++ b/.changeset/wise-lines-gather.md @@ -0,0 +1,52 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `LineItems` — a spreadsheet-grade editable table for the lines of a document (purchase orders, invoices, journals, stock transfers), plus the `useLineItems` hook and the `createLineItemHelper` field-schema builder. + +State lives in the hook; the compound parts render it. `getChangeSet()` returns a transport-agnostic description of what changed, so only real edits are submitted. + +```tsx +import { LineItems, useLineItems, createLineItemHelper } from "@tailor-platform/app-shell"; + +const f = createLineItemHelper(); +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + }), +]; + +function Lines({ lines }: { lines: POLine[] }) { + const lineItems = useLineItems({ + fields, + defaultLines: lines, + ordering: "manual", + selection: true, + }); + + return ( + + + + submit(changeSet.lineChanges)} /> + + ); +} +``` + +For documents with more than one list of lines (journal debits and credits, work-order components and operations), `useLineItemsGroup({ debits, credits })` puts several `useLineItems` collections under one save boundary: a shared `isDirty`, one keyed `getChangeSet()`, and group-wide `reset()` / `revert()`. + +Includes view and edit modes, inline cell editors that commit on blur or Enter, range selection with TSV copy/paste, pinned columns, a sticky totals row, row selection with an optional bulk-action bar (`selection: { actions }`, the same bar as DataTable, at the table's foot), row virtualization for long documents, and drag reordering with a keyboard fallback on the grip handle. + +Built-in labels are localized (English and Japanese) through the app locale, like DataTable. diff --git a/docs-manifest.json b/docs-manifest.json index ca2b38e1c..6e025e80d 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -987,6 +987,50 @@ "examples": "2a4aa694c4d48324" } }, + "line-items": { + "slug": "line-items", + "kind": "code-backed", + "outline": "docs-src/components/line-items.docs.outline.md", + "output": "docs/components/line-items.md", + "examples": null, + "sources": ["packages/core/src/components/line-items/**"], + "claims": [], + "symbols": [ + "LineItems", + "LineItemsChangeSet", + "LineItemsColumnAlign", + "LineItemsCustomEditorContext", + "LineItemsField", + "LineItemsFieldCommit", + "LineItemsFieldType", + "LineItemsGroupChangeSet", + "LineItemsGroupMembers", + "LineItemsLineChange", + "LineItemsMetadataCommit", + "LineItemsMode", + "LineItemsOrderingMode", + "LineItemsRootProps", + "LineItemsRowData", + "LineItemsRowPatch", + "LineItemsSelectOption", + "LineItemsSortDirection", + "LineItemsSortState", + "LineItemsTableProps", + "UseLineItemsGroupReturn", + "UseLineItemsOptions", + "UseLineItemsReturn", + "createLineItemHelper", + "useLineItems", + "useLineItemsGroup" + ], + "hashes": { + "typeSurface": "5490757b7147eb0e", + "outline": "02bd4817ec79d1df", + "snapshot": "0fe7d573725826ba", + "outputMd": "a3f0e85fa5875c54", + "examples": null + } + }, "list-dense-scan": { "slug": "list-dense-scan", "kind": "prose", @@ -1440,9 +1484,9 @@ "symbols": ["TreePicker", "TreePickerNode", "TreePickerProps"], "hashes": { "typeSurface": "d58cf3e3b3559ef4", - "outline": "422bea1a7c11a58d", + "outline": "d7367171b068e96a", "snapshot": "4255a571e84cf5f5", - "outputMd": "010084030ccb6fb5", + "outputMd": "d56641431b2fac6f", "examples": null } }, @@ -1722,6 +1766,7 @@ "packages/core/skills/app-shell-patterns/references/components/grid.md": "9051eb53dde4179f", "packages/core/skills/app-shell-patterns/references/components/input.md": "a3a6c9679af99d37", "packages/core/skills/app-shell-patterns/references/components/layout.md": "66696139a46fabb0", + "packages/core/skills/app-shell-patterns/references/components/line-items.md": "a3f0e85fa5875c54", "packages/core/skills/app-shell-patterns/references/components/menu.md": "ceb4a05822538d20", "packages/core/skills/app-shell-patterns/references/components/metric-card.md": "5a1c557768c6590f", "packages/core/skills/app-shell-patterns/references/components/select.md": "ae287e2bb5abc1c6", @@ -1737,7 +1782,7 @@ "packages/core/skills/app-shell-patterns/references/components/timeline.md": "699249c9452485ab", "packages/core/skills/app-shell-patterns/references/components/toolbar.md": "91d70b140c7bb747", "packages/core/skills/app-shell-patterns/references/components/tooltip.md": "113c95b2995917ed", - "packages/core/skills/app-shell-patterns/references/components/tree-picker.md": "010084030ccb6fb5", + "packages/core/skills/app-shell-patterns/references/components/tree-picker.md": "d56641431b2fac6f", "packages/core/skills/app-shell-patterns/references/components/with-guard.md": "d79aaf40cb97073f", "packages/core/skills/app-shell-patterns/references/api/create-ai-gateway-client.md": "7eaff97521a14ab0", "packages/core/skills/app-shell-patterns/references/api/create-typed-paths.md": "4c3a858d3eb02a2a", @@ -1775,6 +1820,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "packages/core/skills/app-shell-patterns/SKILL.md": "59a0b9d40318e6ba" + "packages/core/skills/app-shell-patterns/SKILL.md": "11831fe5f4b8eb7c" } } diff --git a/docs-src/components/line-items.docs.outline.md b/docs-src/components/line-items.docs.outline.md new file mode 100644 index 000000000..4f446e2c0 --- /dev/null +++ b/docs-src/components/line-items.docs.outline.md @@ -0,0 +1,289 @@ +--- +kind: code-backed +group: line-items +title: LineItems +description: Spreadsheet-grade editable table for the lines of a document, with a change-set for submitting only what changed +sources: + - packages/core/src/components/line-items/** +--- + +# LineItems + +`LineItems` edits the rows of a document — purchase orders, invoices, journals, work orders, stock transfers — with spreadsheet ergonomics: inline cell editors, range selection, TSV copy/paste, keyboard navigation, drag reordering, and row virtualization for long documents. + +State lives in the `useLineItems` hook; `LineItems.*` renders it. The component is UI-only — it takes no opinion on transport. `getChangeSet()` returns a transport-agnostic description of what changed, which you map onto your own mutations. + +## Import + +```tsx +import { LineItems, useLineItems, createLineItemHelper } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type POLine = { + lineRef: string; + sku: string; + quantity: number; + unitPrice: number; +}; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 90, + }), + f.field({ + key: "total", + label: "Total", + render: (l) => `$${((l.quantity ?? 0) * (l.unitPrice ?? 0)).toFixed(2)}`, + width: 120, + }), +]; + +function PurchaseOrderLines({ lines }: { lines: POLine[] }) { + const lineItems = useLineItems({ fields, defaultLines: lines, selection: true }); + + return ( + + + + submit(changeSet)} /> + + ); +} +``` + +Every row needs a stable `lineRef` — the server id for a persisted line. Rows that arrive without one are assigned a generated id. + +## Parts + +| Part | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LineItems.Root` | Provides the document to the parts beneath it. Holds no row data of its own. | +| `LineItems.Table` | The editable grid: virtualized rows, pinned columns, cell editors, drag reorder. | +| `LineItems.Search` | Search box bound to the filter. Only fields with a `search` function participate. | +| `LineItems.AddRow` | Appends a line. Renders nothing in view mode. | +| `LineItems.SaveActions` | Save and discard buttons, both disabled while the document is clean. **The default save surface** — place it in the card, next to the table. | +| `LineItems.FloatingDock` | Sticky container for `DirtyBar`. | +| `LineItems.DirtyBar` | Floating alternative to `SaveActions` for very long pages where the card's buttons scroll out of view. Appears only while there are uncommitted changes; `warnOnNav` keeps the user on the page while dirty — any in-app navigation to another route (links, `navigate()`, back/forward) is held via React Router's `useBlocker`, so it needs AppShell's router; closing or reloading the tab shows the browser's prompt. | + +## useLineItems(options) + +| Option | Type | Default | Description | +| ------------------ | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fields` | `LineItemsField[]` | required | Column schema. Build with `createLineItemHelper().field({…})`. | +| `defaultLines` | `readonly T[]` | `[]` | Uncontrolled seed rows, also the dirty-tracking baseline. Read once on mount; to load a different document, remount (e.g. with a `key`). | +| `lines` | `readonly T[]` | – | Controlled rows. Requires `onLinesChange`; can't be combined with `defaultLines`. | +| `onLinesChange` | `(next: T[]) => void` | – | Called once per mutation with the new rows. Required with `lines`; optional with `defaultLines`, to observe edits. | +| `mode` | `"view" \| "edit" \| "amend"` | `"edit"` | `"view"` renders every cell read-only. `"amend"` keeps only fields whose `editable` lists it open — for a confirmed document where a few fields may still change. | +| `ordering` | `"sort" \| "manual"` | `"sort"` | `"manual"` enables drag reordering and emits `reorder` ops. | +| `selection` | `boolean \| { actions: SelectionAction[] }` | `false` | `true` adds the row-selection checkbox column. `{ actions }` also shows the bulk-action bar — see [Selection actions](#selection-actions). | +| `onMetadataCommit` | `(event: LineItemsMetadataCommit) => void` | – | Receives edits to `commit: "metadata"` fields, which bypass the change-set. | + +### Return value + +| Member | Description | +| ----------------------------------------------- | ------------------------------------------------------------------------------ | +| `lines` / `allLines` | Rows with the search filter applied, and the full document order. | +| `isDirty` | Whether current state diverges from the baseline. | +| `selectedIds` / `selectedRows` | The selected lines, as `lineRef`s and as rows, in document order. | +| `sortState` | Active sort, or `null` in document order. Always `null` under manual ordering. | +| `toggleSort(columnId)` | Cycles a column ascending, descending, then unsorted. | +| `addLine(data, opts?)` | Inserts one line, returning its new `lineRef`. | +| `addLines(items, opts?)` | Inserts many in one render. Use this for bulk pickers. | +| `removeLine(lineRef)` / `removeLines(lineRefs)` | Deletes one line, or several in one update — for a bulk action's rows. | +| `updateField(lineRef, key, value)` | Typed single-cell update. | +| `updateLines(patches)` | Batched updates — one render for a whole paste or fill. | +| `reorderLine(lineRef, afterLineRef)` | Moves a line. Manual ordering only; pass `null` to move to the head. | +| `bulkUpdate(patch)` / `bulkRemove()` | Apply to every selected row. | +| `reset()` | Snaps the baseline forward. Call after a successful save. | +| `revert()` | Discards every uncommitted change back to the baseline. | +| `getChangeSet()` | What changed, ready to submit. | + +`addLine` and `addLines` take `{ afterLineRef }`: omit it to append, pass `null` to prepend, or pass a `lineRef` to insert directly after that line. + +## Selection actions + +Pass `selection: { actions }` to `useLineItems` and, while at least one line is selected, `LineItems.Table` shows a bulk-action bar at its foot: the selection count, your actions, and **Clear**. It is the same bar, and the same [`SelectionAction`](./data-table.md#selectionaction) shape, as DataTable's [selection actions](./data-table.md#selection-actions), so selection reads the same across both. + +```tsx +const lineItems = useLineItems({ + fields, + defaultLines: lines, + selection: { + actions: [ + { + id: "receive", + label: "Mark received", + canApply: (line) => line.receivedQty < line.quantity, + onClick: (rows) => { + lineItems.updateLines( + rows.map((l) => ({ lineRef: l.lineRef, patch: { receivedQty: l.quantity } })), + ); + }, + }, + { + id: "remove", + label: "Remove", + variant: "destructive", + onClick: (rows) => { + lineItems.removeLines(rows.map((l) => l.lineRef)); + }, + }, + ], + }, +}); +``` + +- **`canApply` scopes an action.** The button shows how many selected lines qualify — `Mark received (2)` — is disabled when none do, and `onClick` receives only those lines. +- **Give handlers a block body** when they call the hook's own return value, as above. An expression body makes TypeScript infer `lineItems` from itself and fail with "implicitly has type any". +- **Edits stay local.** Actions that change lines with `updateLines`, `addLines` or `removeLines` land in the change-set like any other edit, so Save and Discard cover them. A synchronous handler leaves the selection alone; a returned promise gets DataTable's pending state and clears the selection once it resolves, unless the action sets `keepSelection`. +- Up to three actions show inline; the rest collapse into a **More actions** menu. The bar is a `role="toolbar"` named "Bulk actions", its count is announced politely, and when it closes with focus inside, focus returns to the header checkbox. + +## Field schema + +| Property | Type | Description | +| ----------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | +| `key` | `string` | Column id, and the property name on `T` for editable fields. | +| `label` | `ReactNode` | Header content. | +| `render` | `(line: T) => ReactNode` | Cell content when no editor is shown. | +| `editable` | `LineItemsMode[]` | Modes that show an editor. Omit for a read-only or computed column. | +| `type` | `LineItemsFieldType` | Required when editable. Drives the editor and equality semantics. | +| `sort` | `{ comparator }` | Makes the header sortable (asc, desc, unsorted). Ignored under manual ordering. | +| `search` | `(line: T, query: string) => boolean` | Opts the column into `LineItems.Search`. | +| `align` | `"left" \| "center" \| "right"` | Defaults to right for numbers, center for booleans. | +| `className` | `string \| ((line: T) => string \| undefined)` | Per-cell classes — useful for flagging discrepancies. | +| `normalize` | `(value, row) => unknown` | Coerce before comparison and before entering the change-set. | +| `equals` | `(a, b, row) => boolean` | Custom dirty-equality. | +| `width` | `number` | Resting width in pixels. | +| `pin` | `"left" \| "right"` | Freezes the column during horizontal scroll. | +| `flex` | `boolean` | Absorbs leftover horizontal space. | + +### Field types + +```tsx +{ kind: "text" } +{ kind: "number", decimals?: number } +{ kind: "select", options: { value, label, description? }[], placeholder?: string } +{ kind: "boolean" } +{ kind: "date", min?: string, max?: string } +{ kind: "custom", renderEditor: (ctx) => ReactNode, normalize?, equals? } +``` + +A `custom` editor receives `ariaLabel` (the column and line, e.g. "Qty, line 2") in its context — put it on your input so screen readers can name the cell, as the built-in editors do. + +Numeric fields normalize strings to numbers, round by `decimals`, and compare with a small epsilon, so `"1"` and `"1.00"` are not a change. Strings are trimmed. A field's own `normalize` and `equals` win over both. + +## LineItems.Table Props + +| Prop | Type | Default | Description | +| --------------------------------------- | ----------------------- | -------------------- | ---------------------------------------------------------------------------------- | +| `rowHeight` | `number` | `36` | Uniform row height, which the virtualizer's math depends on. | +| `maxBodyHeight` | `number \| string` | `"min(70vh, 600px)"` | Height the grid scrolls within. Ignored in fullscreen, where it fills the overlay. | +| `virtualized` | `boolean` | `true` | Set `false` to mount every row. | +| `renderRowActions` | `(row: T) => ReactNode` | – | Trailing pinned column of per-row controls. | +| `rowActionsWidth` | `number` | `56` | Width of the trailing row-actions column. | +| `emptyMessage` | `ReactNode` | `"No lines yet."` | Shown when the document has no lines. | +| `loading` | `boolean` | `false` | Swaps the body for skeleton rows while an initial fetch is in flight. | +| `skeletonRowCount` | `number` | `12` | How many skeleton rows `loading` shows. | +| `className` / `tableContainerClassName` | `string` | – | Merged onto the bordered wrapper / the scroll container. | + +`LineItems.Table` cannot infer `T` from `Root`, so annotate the callback parameters (`renderRowActions={(row: POLine) => …}`) or pass the type argument explicitly (` … />`). + +## Change-set + +`getChangeSet()` returns `{ isEmpty, lineChanges }`, where each change is one of: + +```tsx +{ action: "add", tempId: string, data: Record } +{ action: "update", lineId: string, patch: Record } +{ action: "remove", lineId: string } +{ action: "reorder", lineId: string, position: number } +``` + +`tempId` is client-only — the server mints the real id on insert. `patch` carries only the fields that actually changed. `reorder` is emitted for manual ordering only, and only for persisted lines whose index moved, so adding or removing a line does not read as a move of everything below it. Read-only and computed columns never appear. + +```tsx + { + if (changeSet.isEmpty) return; + await submitLines(changeSet.lineChanges); + lineItems.reset(); // baseline moves forward; the document is clean again + }} +/> +``` + +## Multiple collections: useLineItemsGroup + +Some documents own more than one list of lines — a Journal Entry with debits and credits, a Work Order with components and operations. Give each list its own `useLineItems` and `LineItems.Root`, then put them under one save boundary with `useLineItemsGroup`. Members can have different row types. + +```tsx +import { useLineItems, useLineItemsGroup } from "@tailor-platform/app-shell"; + +const debits = useLineItems({ fields, defaultLines: entry.debits }); +const credits = useLineItems({ fields, defaultLines: entry.credits }); +const group = useLineItemsGroup({ debits, credits }); + +const onSave = async () => { + const { isEmpty, changeSets } = group.getChangeSet(); + if (isEmpty) return; + await saveJournalEntry(entry.id, changeSets.debits.lineChanges, changeSets.credits.lineChanges); + group.reset(); +}; +``` + +| Return | Description | +| -------------- | ----------------------------------------------------------------------------------- | +| `members` | The hooks passed in, for forwarding into each `LineItems.Root` | +| `isDirty` | True when any member has uncommitted changes | +| `getChangeSet` | Returns `{ isEmpty, changeSets }`; `changeSets` is keyed by the names you passed in | +| `reset` | Snaps every member's baseline forward — call after a successful save | +| `revert` | Discards every member's changes — call on Discard | + +Each member keeps its own selection, search and dirty tracking. The group only adds the shared `isDirty` and the single Save / Discard. + +## Spreadsheet behavior + +Only editable columns take part in the grid's selection. + +| Interaction | Result | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| Click a cell | Selects it and opens its editor with the value selected | +| Shift-click, or click and drag | Selects the rectangle between the anchor and that cell | +| Arrow keys | Moves the active cell; hold Shift to extend the selection | +| Enter / Tab | Commits and moves down / right across editable cells (Shift reverses). Tab past the last editable cell leaves the grid | +| Cmd/Ctrl+D | Fills down: copies the top selected cell of each column into the selected cells below | +| Escape | Abandons the edit, keeping the previous value | +| Copy | Writes the selected rectangle as TSV | +| Paste | Writes a TSV block from the active cell; a single value broadcasts across the selection | + +Edits commit on blur or Enter, never per keystroke. + +## Sorting + +Under the default `ordering: "sort"`, a field with a `sort.comparator` gets a clickable header that cycles ascending, descending, then back to document order, and carries `aria-sort` throughout. Sorting only reorders what you see: `allLines` stays in document order, sorting never marks the document dirty, and it emits no `reorder` ops. Under `ordering: "manual"` the document owns row order outright, so sort affordances are not rendered. + +## Drag reordering + +Set `ordering: "manual"` to get a grip handle on each row. Reordering uses native HTML5 drag-and-drop, which works with virtualized rows. Because native drag has no keyboard equivalent, the grip also responds to the keyboard: Enter or Space lifts the row, Arrow Up/Down moves it, and Enter or Escape drops it. Touch drag is not supported. + +## Related + +- [DataTable](./data-table.md) — read-oriented collection table with sorting, filtering, and pagination +- [TreePicker](./tree-picker.md) — tree-select dialog that pairs with `addLines` diff --git a/docs-src/components/tree-picker.docs.outline.md b/docs-src/components/tree-picker.docs.outline.md index 1a985566a..212e310f1 100644 --- a/docs-src/components/tree-picker.docs.outline.md +++ b/docs-src/components/tree-picker.docs.outline.md @@ -116,7 +116,32 @@ const { data, isLoading } = useCatalogQuery(); />; ``` +## Pairing with LineItems + +`onCommit` hands back leaf nodes; map them into rows and pass them to `addLines`, which inserts them in a single render and produces one `add` op per line. + +```tsx +const lineItems = useLineItems({ fields, defaultLines: lines }); + + node.data.name} + onCommit={(picked) => + lineItems.addLines( + picked.map((node) => ({ + sku: node.data.sku, + quantity: 1, + unitPrice: node.data.price, + })), + ) + } +/>; +``` + ## Related +- [LineItems](./line-items.md) — the editable document grid this commonly feeds - [Combobox](./combobox.md) — inline single or multi select for flat option lists - [Dialog](./dialog.md) — the modal shell this composes diff --git a/docs/components/line-items.md b/docs/components/line-items.md new file mode 100644 index 000000000..1bddadbc8 --- /dev/null +++ b/docs/components/line-items.md @@ -0,0 +1,287 @@ +--- +title: LineItems +description: Spreadsheet-grade editable table for the lines of a document, with a change-set for submitting only what changed +--- + + + +# LineItems + +`LineItems` edits the rows of a document — purchase orders, invoices, journals, work orders, stock transfers — with spreadsheet ergonomics: inline cell editors, range selection, TSV copy/paste, keyboard navigation, drag reordering, and row virtualization for long documents. + +State lives in the `useLineItems` hook; `LineItems.*` renders it. The component is UI-only — it takes no opinion on transport. `getChangeSet()` returns a transport-agnostic description of what changed, which you map onto your own mutations. + +## Import + +```tsx +import { LineItems, useLineItems, createLineItemHelper } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type POLine = { + lineRef: string; + sku: string; + quantity: number; + unitPrice: number; +}; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 90, + }), + f.field({ + key: "total", + label: "Total", + render: (l) => `$${((l.quantity ?? 0) * (l.unitPrice ?? 0)).toFixed(2)}`, + width: 120, + }), +]; + +function PurchaseOrderLines({ lines }: { lines: POLine[] }) { + const lineItems = useLineItems({ fields, defaultLines: lines, selection: true }); + + return ( + + + + submit(changeSet)} /> + + ); +} +``` + +Every row needs a stable `lineRef` — the server id for a persisted line. Rows that arrive without one are assigned a generated id. + +## Parts + +| Part | Description | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `LineItems.Root` | Provides the document to the parts beneath it. Holds no row data of its own. | +| `LineItems.Table` | The editable grid: virtualized rows, pinned columns, cell editors, drag reorder. | +| `LineItems.Search` | Search box bound to the filter. Only fields with a `search` function participate. | +| `LineItems.AddRow` | Appends a line. Renders nothing in view mode. | +| `LineItems.SaveActions` | Save and discard buttons, both disabled while the document is clean. **The default save surface** — place it in the card, next to the table. | +| `LineItems.FloatingDock` | Sticky container for `DirtyBar`. | +| `LineItems.DirtyBar` | Floating alternative to `SaveActions` for very long pages where the card's buttons scroll out of view. Appears only while there are uncommitted changes; `warnOnNav` keeps the user on the page while dirty — any in-app navigation to another route (links, `navigate()`, back/forward) is held via React Router's `useBlocker`, so it needs AppShell's router; closing or reloading the tab shows the browser's prompt. | + +## useLineItems(options) + +| Option | Type | Default | Description | +| ------------------ | ---------------------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fields` | `LineItemsField[]` | required | Column schema. Build with `createLineItemHelper().field({…})`. | +| `defaultLines` | `readonly T[]` | `[]` | Uncontrolled seed rows, also the dirty-tracking baseline. Read once on mount; to load a different document, remount (e.g. with a `key`). | +| `lines` | `readonly T[]` | – | Controlled rows. Requires `onLinesChange`; can't be combined with `defaultLines`. | +| `onLinesChange` | `(next: T[]) => void` | – | Called once per mutation with the new rows. Required with `lines`; optional with `defaultLines`, to observe edits. | +| `mode` | `"view" \| "edit" \| "amend"` | `"edit"` | `"view"` renders every cell read-only. `"amend"` keeps only fields whose `editable` lists it open — for a confirmed document where a few fields may still change. | +| `ordering` | `"sort" \| "manual"` | `"sort"` | `"manual"` enables drag reordering and emits `reorder` ops. | +| `selection` | `boolean \| { actions: SelectionAction[] }` | `false` | `true` adds the row-selection checkbox column. `{ actions }` also shows the bulk-action bar — see [Selection actions](#selection-actions). | +| `onMetadataCommit` | `(event: LineItemsMetadataCommit) => void` | – | Receives edits to `commit: "metadata"` fields, which bypass the change-set. | + +### Return value + +| Member | Description | +| ----------------------------------------------- | ------------------------------------------------------------------------------ | +| `lines` / `allLines` | Rows with the search filter applied, and the full document order. | +| `isDirty` | Whether current state diverges from the baseline. | +| `selectedIds` / `selectedRows` | The selected lines, as `lineRef`s and as rows, in document order. | +| `sortState` | Active sort, or `null` in document order. Always `null` under manual ordering. | +| `toggleSort(columnId)` | Cycles a column ascending, descending, then unsorted. | +| `addLine(data, opts?)` | Inserts one line, returning its new `lineRef`. | +| `addLines(items, opts?)` | Inserts many in one render. Use this for bulk pickers. | +| `removeLine(lineRef)` / `removeLines(lineRefs)` | Deletes one line, or several in one update — for a bulk action's rows. | +| `updateField(lineRef, key, value)` | Typed single-cell update. | +| `updateLines(patches)` | Batched updates — one render for a whole paste or fill. | +| `reorderLine(lineRef, afterLineRef)` | Moves a line. Manual ordering only; pass `null` to move to the head. | +| `bulkUpdate(patch)` / `bulkRemove()` | Apply to every selected row. | +| `reset()` | Snaps the baseline forward. Call after a successful save. | +| `revert()` | Discards every uncommitted change back to the baseline. | +| `getChangeSet()` | What changed, ready to submit. | + +`addLine` and `addLines` take `{ afterLineRef }`: omit it to append, pass `null` to prepend, or pass a `lineRef` to insert directly after that line. + +## Selection actions + +Pass `selection: { actions }` to `useLineItems` and, while at least one line is selected, `LineItems.Table` shows a bulk-action bar at its foot: the selection count, your actions, and **Clear**. It is the same bar, and the same [`SelectionAction`](./data-table.md#selectionaction) shape, as DataTable's [selection actions](./data-table.md#selection-actions), so selection reads the same across both. + +```tsx +const lineItems = useLineItems({ + fields, + defaultLines: lines, + selection: { + actions: [ + { + id: "receive", + label: "Mark received", + canApply: (line) => line.receivedQty < line.quantity, + onClick: (rows) => { + lineItems.updateLines( + rows.map((l) => ({ lineRef: l.lineRef, patch: { receivedQty: l.quantity } })), + ); + }, + }, + { + id: "remove", + label: "Remove", + variant: "destructive", + onClick: (rows) => { + lineItems.removeLines(rows.map((l) => l.lineRef)); + }, + }, + ], + }, +}); +``` + +- **`canApply` scopes an action.** The button shows how many selected lines qualify — `Mark received (2)` — is disabled when none do, and `onClick` receives only those lines. +- **Give handlers a block body** when they call the hook's own return value, as above. An expression body makes TypeScript infer `lineItems` from itself and fail with "implicitly has type any". +- **Edits stay local.** Actions that change lines with `updateLines`, `addLines` or `removeLines` land in the change-set like any other edit, so Save and Discard cover them. A synchronous handler leaves the selection alone; a returned promise gets DataTable's pending state and clears the selection once it resolves, unless the action sets `keepSelection`. +- Up to three actions show inline; the rest collapse into a **More actions** menu. The bar is a `role="toolbar"` named "Bulk actions", its count is announced politely, and when it closes with focus inside, focus returns to the header checkbox. + +## Field schema + +| Property | Type | Description | +| ----------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | +| `key` | `string` | Column id, and the property name on `T` for editable fields. | +| `label` | `ReactNode` | Header content. | +| `render` | `(line: T) => ReactNode` | Cell content when no editor is shown. | +| `editable` | `LineItemsMode[]` | Modes that show an editor. Omit for a read-only or computed column. | +| `type` | `LineItemsFieldType` | Required when editable. Drives the editor and equality semantics. | +| `sort` | `{ comparator }` | Makes the header sortable (asc, desc, unsorted). Ignored under manual ordering. | +| `search` | `(line: T, query: string) => boolean` | Opts the column into `LineItems.Search`. | +| `align` | `"left" \| "center" \| "right"` | Defaults to right for numbers, center for booleans. | +| `className` | `string \| ((line: T) => string \| undefined)` | Per-cell classes — useful for flagging discrepancies. | +| `normalize` | `(value, row) => unknown` | Coerce before comparison and before entering the change-set. | +| `equals` | `(a, b, row) => boolean` | Custom dirty-equality. | +| `width` | `number` | Resting width in pixels. | +| `pin` | `"left" \| "right"` | Freezes the column during horizontal scroll. | +| `flex` | `boolean` | Absorbs leftover horizontal space. | + +### Field types + +```tsx +{ kind: "text" } +{ kind: "number", decimals?: number } +{ kind: "select", options: { value, label, description? }[], placeholder?: string } +{ kind: "boolean" } +{ kind: "date", min?: string, max?: string } +{ kind: "custom", renderEditor: (ctx) => ReactNode, normalize?, equals? } +``` + +A `custom` editor receives `ariaLabel` (the column and line, e.g. "Qty, line 2") in its context — put it on your input so screen readers can name the cell, as the built-in editors do. + +Numeric fields normalize strings to numbers, round by `decimals`, and compare with a small epsilon, so `"1"` and `"1.00"` are not a change. Strings are trimmed. A field's own `normalize` and `equals` win over both. + +## LineItems.Table Props + +| Prop | Type | Default | Description | +| --------------------------------------- | ----------------------- | -------------------- | ---------------------------------------------------------------------------------- | +| `rowHeight` | `number` | `36` | Uniform row height, which the virtualizer's math depends on. | +| `maxBodyHeight` | `number \| string` | `"min(70vh, 600px)"` | Height the grid scrolls within. Ignored in fullscreen, where it fills the overlay. | +| `virtualized` | `boolean` | `true` | Set `false` to mount every row. | +| `renderRowActions` | `(row: T) => ReactNode` | – | Trailing pinned column of per-row controls. | +| `rowActionsWidth` | `number` | `56` | Width of the trailing row-actions column. | +| `emptyMessage` | `ReactNode` | `"No lines yet."` | Shown when the document has no lines. | +| `loading` | `boolean` | `false` | Swaps the body for skeleton rows while an initial fetch is in flight. | +| `skeletonRowCount` | `number` | `12` | How many skeleton rows `loading` shows. | +| `className` / `tableContainerClassName` | `string` | – | Merged onto the bordered wrapper / the scroll container. | + +`LineItems.Table` cannot infer `T` from `Root`, so annotate the callback parameters (`renderRowActions={(row: POLine) => …}`) or pass the type argument explicitly (` … />`). + +## Change-set + +`getChangeSet()` returns `{ isEmpty, lineChanges }`, where each change is one of: + +```tsx +{ action: "add", tempId: string, data: Record } +{ action: "update", lineId: string, patch: Record } +{ action: "remove", lineId: string } +{ action: "reorder", lineId: string, position: number } +``` + +`tempId` is client-only — the server mints the real id on insert. `patch` carries only the fields that actually changed. `reorder` is emitted for manual ordering only, and only for persisted lines whose index moved, so adding or removing a line does not read as a move of everything below it. Read-only and computed columns never appear. + +```tsx + { + if (changeSet.isEmpty) return; + await submitLines(changeSet.lineChanges); + lineItems.reset(); // baseline moves forward; the document is clean again + }} +/> +``` + +## Multiple collections: useLineItemsGroup + +Some documents own more than one list of lines — a Journal Entry with debits and credits, a Work Order with components and operations. Give each list its own `useLineItems` and `LineItems.Root`, then put them under one save boundary with `useLineItemsGroup`. Members can have different row types. + +```tsx +import { useLineItems, useLineItemsGroup } from "@tailor-platform/app-shell"; + +const debits = useLineItems({ fields, defaultLines: entry.debits }); +const credits = useLineItems({ fields, defaultLines: entry.credits }); +const group = useLineItemsGroup({ debits, credits }); + +const onSave = async () => { + const { isEmpty, changeSets } = group.getChangeSet(); + if (isEmpty) return; + await saveJournalEntry(entry.id, changeSets.debits.lineChanges, changeSets.credits.lineChanges); + group.reset(); +}; +``` + +| Return | Description | +| -------------- | ----------------------------------------------------------------------------------- | +| `members` | The hooks passed in, for forwarding into each `LineItems.Root` | +| `isDirty` | True when any member has uncommitted changes | +| `getChangeSet` | Returns `{ isEmpty, changeSets }`; `changeSets` is keyed by the names you passed in | +| `reset` | Snaps every member's baseline forward — call after a successful save | +| `revert` | Discards every member's changes — call on Discard | + +Each member keeps its own selection, search and dirty tracking. The group only adds the shared `isDirty` and the single Save / Discard. + +## Spreadsheet behavior + +Only editable columns take part in the grid's selection. + +| Interaction | Result | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| Click a cell | Selects it and opens its editor with the value selected | +| Shift-click, or click and drag | Selects the rectangle between the anchor and that cell | +| Arrow keys | Moves the active cell; hold Shift to extend the selection | +| Enter / Tab | Commits and moves down / right across editable cells (Shift reverses). Tab past the last editable cell leaves the grid | +| Cmd/Ctrl+D | Fills down: copies the top selected cell of each column into the selected cells below | +| Escape | Abandons the edit, keeping the previous value | +| Copy | Writes the selected rectangle as TSV | +| Paste | Writes a TSV block from the active cell; a single value broadcasts across the selection | + +Edits commit on blur or Enter, never per keystroke. + +## Sorting + +Under the default `ordering: "sort"`, a field with a `sort.comparator` gets a clickable header that cycles ascending, descending, then back to document order, and carries `aria-sort` throughout. Sorting only reorders what you see: `allLines` stays in document order, sorting never marks the document dirty, and it emits no `reorder` ops. Under `ordering: "manual"` the document owns row order outright, so sort affordances are not rendered. + +## Drag reordering + +Set `ordering: "manual"` to get a grip handle on each row. Reordering uses native HTML5 drag-and-drop, which works with virtualized rows. Because native drag has no keyboard equivalent, the grip also responds to the keyboard: Enter or Space lifts the row, Arrow Up/Down moves it, and Enter or Escape drops it. Touch drag is not supported. + +## Related + +- [DataTable](./data-table.md) — read-oriented collection table with sorting, filtering, and pagination +- [TreePicker](./tree-picker.md) — tree-select dialog that pairs with `addLines` diff --git a/docs/components/tree-picker.md b/docs/components/tree-picker.md index c00121f4e..6825bc04e 100644 --- a/docs/components/tree-picker.md +++ b/docs/components/tree-picker.md @@ -114,7 +114,32 @@ const { data, isLoading } = useCatalogQuery(); />; ``` +## Pairing with LineItems + +`onCommit` hands back leaf nodes; map them into rows and pass them to `addLines`, which inserts them in a single render and produces one `add` op per line. + +```tsx +const lineItems = useLineItems({ fields, defaultLines: lines }); + + node.data.name} + onCommit={(picked) => + lineItems.addLines( + picked.map((node) => ({ + sku: node.data.sku, + quantity: 1, + unitPrice: node.data.price, + })), + ) + } +/>; +``` + ## Related +- [LineItems](./line-items.md) — the editable document grid this commonly feeds - [Combobox](./combobox.md) — inline single or multi select for flat option lists - [Dialog](./dialog.md) — the modal shell this composes diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index 50458c4cc..a22856fe4 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -84,6 +84,8 @@ const AppInner = () => { + + diff --git a/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx b/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx new file mode 100644 index 000000000..6f57fcf96 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx @@ -0,0 +1,194 @@ +import { + Badge, + Button, + Card, + createLineItemHelper, + Layout, + LineItems, + useLineItems, + useLineItemsGroup, + type AppShellPageProps, + type LineItemsGroupChangeSet, + type UseLineItemsReturn, +} from "@tailor-platform/app-shell"; +import { useState } from "react"; + +// 🧪 Dummy Data: Replace with real data later +type JournalLine = { + lineRef: string; + account: string; + memo: string; + amount: number; +}; + +const ACCOUNT_OPTIONS = [ + { value: "1000", label: "1000 · Cash" }, + { value: "1200", label: "1200 · Accounts Receivable" }, + { value: "2000", label: "2000 · Accounts Payable" }, + { value: "4000", label: "4000 · Sales Revenue" }, + { value: "5000", label: "5000 · Cost of Goods Sold" }, + { value: "6100", label: "6100 · Freight Expense" }, +]; + +// 🧪 Dummy Data: Replace with real data later +const INITIAL_DEBITS: JournalLine[] = [ + { lineRef: "d1", account: "1000", memo: "Customer payment INV-1042", amount: 1250 }, + { lineRef: "d2", account: "6100", memo: "Inbound freight PO-2231", amount: 180 }, +]; + +// 🧪 Dummy Data: Replace with real data later +const INITIAL_CREDITS: JournalLine[] = [ + { lineRef: "c1", account: "1200", memo: "Clear receivable INV-1042", amount: 1250 }, + { lineRef: "c2", account: "2000", memo: "Carrier bill PO-2231", amount: 180 }, +]; + +const money = (n: number) => + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const sum = (lines: JournalLine[]) => lines.reduce((s, l) => s + (l.amount ?? 0), 0); + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "account", + label: "Account", + render: (l) => ACCOUNT_OPTIONS.find((o) => o.value === l.account)?.label ?? l.account, + editable: ["edit"], + type: { kind: "select", options: ACCOUNT_OPTIONS, placeholder: "Pick an account" }, + width: 260, + }), + f.field({ + key: "memo", + label: "Memo", + render: (l) => l.memo, + editable: ["edit"], + type: { kind: "text" }, + flex: true, + }), + f.field({ + key: "amount", + label: "Amount", + render: (l) => money(l.amount ?? 0), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + align: "right", + width: 140, + }), +]; + +const emptyLine = { account: "", memo: "", amount: 0 }; + +// ✅ Reusable Component: Can be moved to components/ui/ +const JournalSide = ({ + title, + value, +}: { + title: string; + value: UseLineItemsReturn; +}) => ( + + + +
+ +
+
+ + ( + + )} + /> + + {(lines: JournalLine[]) => ({ memo: "Total", amount: money(sum(lines)) })} + + +
+
+); + +const LineItemsJournalDemoPage = () => { + const [lastSaved, setLastSaved] = useState; + credits: UseLineItemsReturn; + }> | null>(null); + + const debits = useLineItems({ fields, defaultLines: INITIAL_DEBITS }); + const credits = useLineItems({ fields, defaultLines: INITIAL_CREDITS }); + const group = useLineItemsGroup({ debits, credits }); + + const difference = sum(debits.allLines) - sum(credits.allLines); + const balanced = Math.abs(difference) < 0.005; + + const onSave = () => { + const changeSet = group.getChangeSet(); + if (changeSet.isEmpty || !balanced) return; + setLastSaved(changeSet); + group.reset(); + }; + + return ( + + + {/* 🔽 Document header: one Save / Discard for both collections */} +
+
+ JE-2026-0187 + {balanced ? ( + Balanced + ) : ( + {`Out of balance by ${money(difference)}`} + )} + {group.isDirty && Unsaved changes} +
+
+ + +
+
+ + {/* 🔽 Debits */} + + + {/* 🔽 Credits */} + + + {/* 🔽 Last submitted change-set */} + {lastSaved && ( + + + +
{JSON.stringify(lastSaved, null, 2)}
+
+
+ )} +
+
+ ); +}; + +LineItemsJournalDemoPage.appShellPageProps = { + meta: { + title: "Line Items: Journal Entry", + }, +} satisfies AppShellPageProps; + +export default LineItemsJournalDemoPage; diff --git a/examples/vite-app/src/pages/showcase/line-items/page.tsx b/examples/vite-app/src/pages/showcase/line-items/page.tsx new file mode 100644 index 000000000..06880ef09 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/line-items/page.tsx @@ -0,0 +1,546 @@ +import { + TreePicker, + Button, + Card, + Combobox, + createLineItemHelper, + Layout, + LineItems, + useLineItems, + type AppShellPageProps, + type TreePickerNode, + type LineItemsChangeSet, + type LineItemsMode, +} from "@tailor-platform/app-shell"; +import { Copy, ExternalLink, PackageCheck, Trash2, Upload, X } from "lucide-react"; +import { useState } from "react"; + +// 🧪 Dummy Data: Replace with real data later +type PurchaseOrderLine = { + lineRef: string; + sku: string; + description: string; + uom: string; + quantity: number; + unitPrice: number; + receivedQty: number; + expected: string; + note: string; +}; + +// No descriptions here on purpose: the select cell's input shows label plus +// description (so SKU cells are searchable by product name), which would read +// as "Case 12 units per case" for a unit of measure. +const UOM_OPTIONS = [ + { value: "EA", label: "Each" }, + { value: "CS", label: "Case" }, + { value: "PLT", label: "Pallet" }, +]; + +// 🧪 Dummy Data: Replace with real data later — the catalog both pickers browse +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: CatalogItem[] = [ + { sku: "DT-TEE-WHT-S", name: "Cotton Wreath Tee, White, Small", price: 18.5 }, + { sku: "DT-TEE-WHT-M", name: "Cotton Wreath Tee, White, Medium", price: 18.5 }, + { sku: "DT-TEE-GRY-XL", name: "Cotton Wreath Tee, Grey, XL", price: 19.75 }, + { sku: "DT-HOOD-BLK-M", name: "Heavyweight Hoodie, Black, Medium", price: 62 }, + { sku: "DT-JKT-DNM-L", name: "Denim Chore Jacket, Indigo, Large", price: 148 }, + { sku: "DT-CAP-BLK-OS", name: "Embroidered Cap, Black", price: 14.25 }, + { sku: "DT-TOTE-NAT-OS", name: "Canvas Tote, Natural", price: 22 }, +]; + +const CATALOG_TREE: TreePickerNode[] = [ + { + id: "cat-tees", + data: { sku: "TEES", name: "T-Shirts", price: 0 }, + children: CATALOG.filter((c) => c.sku.includes("TEE")).map((c) => ({ id: c.sku, data: c })), + }, + { + id: "cat-outerwear", + data: { sku: "OUTER", name: "Outerwear", price: 0 }, + children: CATALOG.filter((c) => c.sku.includes("HOOD") || c.sku.includes("JKT")).map((c) => ({ + id: c.sku, + data: c, + })), + }, + { + id: "cat-accessories", + data: { sku: "ACC", name: "Accessories", price: 0 }, + children: CATALOG.filter((c) => c.sku.includes("CAP") || c.sku.includes("TOTE")).map((c) => ({ + id: c.sku, + data: c, + })), + }, +]; + +// 🧪 Dummy Data: Replace with real data later +const INITIAL_LINES: PurchaseOrderLine[] = [ + { + lineRef: "pol-1001", + sku: "DT-TEE-BLK-M", + description: "Cotton Wreath Tee, Black, Medium", + uom: "CS", + quantity: 24, + unitPrice: 18.5, + receivedQty: 24, + expected: "2026-04-30", + note: "", + }, + { + lineRef: "pol-1002", + sku: "DT-TEE-BLK-L", + description: "Cotton Wreath Tee, Black, Large", + uom: "CS", + quantity: 18, + unitPrice: 18.5, + receivedQty: 12, + expected: "2026-05-01", + note: "Short shipped", + }, + { + lineRef: "pol-1003", + sku: "DT-HOOD-CRM-L", + description: "Heavyweight Hoodie, Cream, Large", + uom: "EA", + quantity: 40, + unitPrice: 62, + receivedQty: 40, + expected: "2026-05-02", + note: "", + }, + { + lineRef: "pol-1004", + sku: "DT-CAP-NVY-OS", + description: "Embroidered Cap, Navy, One Size", + uom: "EA", + quantity: 120, + unitPrice: 14.25, + receivedQty: 96, + expected: "2026-05-03", + note: "", + }, + { + lineRef: "pol-1005", + sku: "DT-SOCK-WHT-OS", + description: "Crew Socks, White, One Size", + uom: "PLT", + quantity: 6, + unitPrice: 210, + receivedQty: 6, + expected: "2026-05-04", + note: "", + }, +]; + +const money = (n: number) => `$${(n ?? 0).toFixed(2)}`; +const lineTotal = (l: PurchaseOrderLine) => (l.quantity ?? 0) * (l.unitPrice ?? 0); + +// ✅ Reusable Component: Can be moved to components/ui/ +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => {l.sku}, + editable: ["edit"], + type: { + kind: "select", + options: CATALOG.map((c) => ({ value: c.sku, label: c.sku, description: c.name })), + placeholder: "Pick a product", + }, + width: 200, + pin: "left", + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + sort: { comparator: (a, b) => a.sku.localeCompare(b.sku) }, + }), + f.field({ + key: "description", + label: "Product", + render: (l) => l.description ?? "", + editable: ["edit"], + type: { kind: "text" }, + flex: true, + search: (l, q) => (l.description ?? "").toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "uom", + label: "UoM", + render: (l) => UOM_OPTIONS.find((o) => o.value === l.uom)?.label ?? l.uom ?? "", + editable: ["edit"], + type: { kind: "select", options: UOM_OPTIONS, placeholder: "Unit" }, + width: 120, + }), + f.field({ + key: "quantity", + label: "Ordered", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 100, + sort: { comparator: (a, b) => (a.quantity ?? 0) - (b.quantity ?? 0) }, + }), + f.field({ + key: "receivedQty", + label: "Received", + // Still correctable after the PO is confirmed, so it opts into amend. + editable: ["edit", "amend"], + render: (l) => l.receivedQty ?? 0, + type: { kind: "number", decimals: 0 }, + width: 100, + className: (l) => + (l.receivedQty ?? 0) !== (l.quantity ?? 0) ? "bg-destructive/10 text-destructive" : undefined, + }), + f.field({ + key: "unitPrice", + label: "Unit price", + render: (l) => money(l.unitPrice), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + width: 120, + }), + f.field({ + key: "expected", + label: "Expected", + render: (l) => l.expected ?? "", + editable: ["edit"], + type: { kind: "date" }, + width: 150, + }), + f.field({ + key: "note", + label: "Note", + // 🧪 Notes save out-of-band, so they never enter the document change-set. + render: (l) => l.note ?? "", + editable: ["edit", "amend"], + commit: "metadata", + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "lineTotal", + label: "Total", + render: (l) => {money(lineTotal(l))}, + width: 120, + align: "right", + pin: "right", + }), +]; + +const MODES: LineItemsMode[] = ["edit", "view", "amend"]; + +// 🧪 Dummy Data: Replace with real data later — a long PO for checking scroll, +// virtualization and pinned columns at volume. +const LARGE_LINE_COUNT = 1000; +const NOTES = [ + "", + "", + "", + "Short shipped", + "Backordered until next week", + "Damaged carton on arrival", +]; + +function generateLines(count: number): PurchaseOrderLine[] { + return Array.from({ length: count }, (_, i) => { + const item = CATALOG[i % CATALOG.length]!; + const quantity = 1 + ((i * 37) % 240); + const day = 1 + (i % 28); + return { + lineRef: `pol-${2000 + i}`, + sku: item.sku, + description: item.name, + uom: UOM_OPTIONS[i % UOM_OPTIONS.length]!.value, + quantity, + unitPrice: item.price, + receivedQty: i % 5 === 0 ? Math.floor(quantity * 0.75) : quantity, + expected: `2026-06-${String(day).padStart(2, "0")}`, + note: NOTES[i % NOTES.length]!, + }; + }); +} + +const LARGE_LINES = generateLines(LARGE_LINE_COUNT); + +const LineItemsDemoPage = () => { + const [large, setLarge] = useState(false); + // Uncontrolled `data` only seeds the hook, so swapping datasets remounts it. + return ( + setLarge((v) => !v)} + /> + ); +}; + +const PurchaseOrderDemo = ({ + initialLines, + large, + onToggleLarge, +}: { + initialLines: PurchaseOrderLine[]; + large: boolean; + onToggleLarge: () => void; +}) => { + const [mode, setMode] = useState("edit"); + const [ordering, setOrdering] = useState<"sort" | "manual">("manual"); + const [pickerOpen, setPickerOpen] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [metadataLog, setMetadataLog] = useState([]); + + const lineItems = useLineItems({ + fields, + defaultLines: initialLines, + mode, + ordering, + // 🔽 Bulk actions: the same footer bar DataTable shows while rows are selected. + // Block-bodied handlers keep TypeScript from inferring `lineItems` from itself. + selection: { + actions: [ + { + id: "receive", + label: "Mark received", + icon: , + // Only lines still short count — the button reads "Mark received (n)". + canApply: (line) => line.receivedQty < line.quantity, + onClick: (rows) => { + lineItems.updateLines( + rows.map((line) => ({ + lineRef: line.lineRef, + patch: { receivedQty: line.quantity }, + })), + ); + }, + }, + { + id: "duplicate", + label: "Duplicate", + icon: , + onClick: (rows) => { + lineItems.addLines(rows.map(({ lineRef: _lineRef, ...line }) => line)); + }, + }, + { + id: "remove", + label: "Remove", + icon: , + variant: "destructive", + onClick: (rows) => { + lineItems.removeLines(rows.map((line) => line.lineRef)); + }, + }, + ], + }, + onMetadataCommit: (event) => + setMetadataLog((log) => + [`${event.lineRef}: note → ${JSON.stringify(event.patch.note)}`, ...log].slice(0, 5), + ), + }); + + const addFromCatalog = (item: CatalogItem) => + lineItems.addLine({ + sku: item.sku, + description: item.name, + uom: "EA", + quantity: 1, + unitPrice: item.price, + receivedQty: 0, + expected: "", + note: "", + }); + + return ( + + + {/* 🔽 Demo controls */} +
+ {MODES.map((m) => ( + + ))} + + +
+ + + {/* 🔽 Line items section */} + + +
+ + + + +
+
+ + + ( + <> + + + + )} + /> + + + {(lines: PurchaseOrderLine[]) => ({ + description: `${lines.length} lines`, + quantity: lines.reduce((s, l) => s + (l.quantity ?? 0), 0), + receivedQty: lines.reduce((s, l) => s + (l.receivedQty ?? 0), 0), + lineTotal: money(lines.reduce((s, l) => s + lineTotal(l), 0)), + })} + + + {/* 🔽 Add a line from the bottom of the table; save sits beside it, in the card */} +
+ + ({ label: `${item.sku} ${item.name}`, key: item.sku })} + onValueChange={(item) => { + if (item) addFromCatalog(item); + }} + /> + + + { + setLastSaved(cs); + // A real save would submit first; the baseline moves forward on success. + lineItems.reset(); + }} + /> +
+
+
+
+ + {/* 🔽 Change-set inspector — what a real submit would send */} + {lastSaved ? ( + + + +
+                {JSON.stringify(lastSaved, null, 2)}
+              
+
+
+ ) : null} + + {/* 🔽 Out-of-band note commits, which bypass the change-set */} + {metadataLog.length > 0 ? ( + + + +
    + {metadataLog.map((entry, i) => ( +
  • {entry}
  • + ))} +
+
+
+ ) : null} + + + open={pickerOpen} + onOpenChange={setPickerOpen} + title="Bulk line item picker" + items={CATALOG_TREE} + rowLabel="Product name" + metricLabel="Unit price" + renderRow={({ node }) => ( + + {node.data.name} + {node.children ? null : ( + {node.data.sku} + )} + + )} + renderMetric={({ node }) => (node.children ? null : money(node.data.price))} + matchesSearch={({ node, query }) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(query.toLowerCase()) + } + ctaLabel={(n) => (n === 0 ? "Add items" : `Add ${n} item${n === 1 ? "" : "s"}`)} + onCommit={(picked) => { + lineItems.addLines( + picked.map((node) => ({ + sku: node.data.sku, + description: node.data.name, + uom: "EA", + quantity: 1, + unitPrice: node.data.price, + receivedQty: 0, + expected: "", + note: "", + })), + ); + }} + /> +
+
+ ); +}; + +LineItemsDemoPage.appShellPageProps = { + meta: { + title: "Line Items", + }, +} satisfies AppShellPageProps; + +export default LineItemsDemoPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index 970d2be35..017356460 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -40,6 +40,8 @@ export type GeneratedRouteParams = { "/showcase/forms/zod-rhf": {}; "/showcase/grid": {}; "/showcase/layouts": {}; + "/showcase/line-items": {}; + "/showcase/line-items-journal": {}; "/showcase/metric-card": {}; "/showcase/primitives": {}; "/showcase/spinner": {}; diff --git a/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap b/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap new file mode 100644 index 000000000..974f264c6 --- /dev/null +++ b/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap @@ -0,0 +1,9 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LineItems > snapshots > amend mode 1`] = `""`; + +exports[`LineItems > snapshots > edit mode with selection and row actions 1`] = `""`; + +exports[`LineItems > snapshots > manual ordering renders drag handles 1`] = `""`; + +exports[`LineItems > snapshots > view mode 1`] = `""`; diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index c6755d4b9..f32956004 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -51,6 +51,10 @@ import { hasSelectionActions, isSelectionBarOpen, } from "./selection-bar"; +import { + selectionBarSurfaceClasses, + selectionBarTokenClasses, +} from "@/components/internals/selection-actions/selection-actions-bar"; export type { DataTablePaginationProps } from "./pagination"; // Fallback row count when no pageSize is configured (static / uncontrolled tables) @@ -1905,15 +1909,10 @@ function DataTableFooter({ children, className }: { children?: ReactNode; classN offersBulkActions && "astw:transition-colors astw:motion-reduce:transition-none", selecting && [ "astw:flex-wrap astw:gap-x-3 astw:gap-y-2", - "astw:bg-accent astw:text-accent-foreground", "astw:rounded-b-[calc(var(--radius-md)-1px)]", - // Sticky while the bar is up: on a page-scrolling table it rides the - // bottom of the viewport until the table's own end scrolls into view. - // A no-op in , where the footer is already pinned. - "astw:sticky astw:bottom-0 astw:z-20", - // The bar's surface, captured for the wrapper below: a token can't be - // re-pointed in terms of itself on a single element. - "astw:[--data-table-selection-surface:var(--accent)]", + // Tinted and sticky while the bar is up; sticky is a no-op in + // , where the footer is already pinned. + selectionBarSurfaceClasses, ], className, )} @@ -1923,15 +1922,7 @@ function DataTableFooter({ children, className }: { children?: ReactNode; classN // secondary-text tokens for everything on the tinted bar — the bar's own // buttons and the consumer's Pagination alike — which would otherwise // hover to the bar's colour and lose contrast on it. -
+
{selecting && } {children} diff --git a/packages/core/src/components/data-table/selection-bar.tsx b/packages/core/src/components/data-table/selection-bar.tsx index 415e86202..df254b746 100644 --- a/packages/core/src/components/data-table/selection-bar.tsx +++ b/packages/core/src/components/data-table/selection-bar.tsx @@ -1,16 +1,8 @@ -import { useLayoutEffect, useRef } from "react"; -import { Ellipsis } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { Button } from "@/components/button"; -import { Menu } from "@/components/menu"; -import { Spinner } from "@/components/spinner"; -import { Toolbar } from "@/components/toolbar"; +import { + SelectionActionsAnnouncer, + SelectionActionsBar, +} from "@/components/internals/selection-actions/selection-actions-bar"; import { useDataTableContext, type DataTableContextValue } from "./data-table-context"; -import { useDataTableT } from "./i18n"; -import type { SelectionAction, SelectionActionHelpers } from "./types"; - -/** Actions past this many collapse into the "More actions" menu. */ -const MAX_INLINE_ACTIONS = 3; /** * Whether the table offers bulk actions at all: selection is on and the hook @@ -29,25 +21,6 @@ export function isSelectionBarOpen>( return hasSelectionActions(ctx) && (ctx?.selectedIds.length ?? 0) > 0; } -/** "3 of 240 selected", or "3 selected" when the backend returns no total. */ -function useSelectionCountText(): string { - const { selectedIds, total } = useDataTableContext(); - const t = useDataTableT(); - return t("selectionCount", { selected: selectedIds.length, total }); -} - -type ResolvedAction> = { - action: SelectionAction; - /** The selected rows this action can act on — what `onClick` receives. */ - rows: TRow[]; - /** Shown as "(n)" only when the action narrows the selection. */ - count: number | null; -}; - -function isPromiseLike(value: unknown): value is PromiseLike { - return typeof (value as PromiseLike | null | undefined)?.then === "function"; -} - /** * The bulk-action bar `DataTable.Footer` renders while rows are selected: * count · actions · overflow menu · Clear. @@ -56,154 +29,33 @@ export function DataTableSelectionBar>() { const { selectionActions = [], selectedRows = [], + total, clearSelection, pendingActionId = null, runSelectionAction, } = useDataTableContext(); - const t = useDataTableT(); - const countText = useSelectionCountText(); - const rowRef = useRef(null); - - // The bar unmounts as soon as the selection empties, usually from its own - // Clear button or an action that clears. Hand focus to the header checkbox - // instead of letting it drop to . Layout-effect cleanup runs while the - // bar is still in the document, so `contains` sees the focused button. - useLayoutEffect(() => { - const bar = rowRef.current; - return () => { - if (!bar?.contains(document.activeElement)) return; - bar - .closest('[data-slot="data-table"]') - ?.querySelector('[data-slot="data-table-header"] [data-slot="checkbox"]') - ?.focus(); - }; - }, []); - - // The in-flight action is tracked by the table, not here, so the bar's - // disabled state survives it unmounting and remounting mid-request. While - // set, every action — and Clear — is disabled, so a slow request can't be - // fired twice or overlapped by another action against the same selection. - const pendingId = pendingActionId; - const busy = pendingId !== null; - - const clear = () => clearSelection?.(); - const resolved: ResolvedAction[] = selectionActions.map((action) => { - const { canApply } = action; - const rows = canApply ? selectedRows.filter((row) => canApply(row)) : selectedRows; - return { action, rows, count: canApply ? rows.length : null }; - }); - const inline = resolved.slice(0, MAX_INLINE_ACTIONS); - const overflow = resolved.slice(MAX_INLINE_ACTIONS); - - const run = ({ action, rows }: ResolvedAction) => { - if (busy || rows.length === 0) return; - // `run` lets a handler that only opens a confirm dialog hand the request - // back later, from the dialog's confirm button, and still get the pending - // state and clear-on-success. - const helpers: SelectionActionHelpers = { - clearSelection: clear, - run: (request) => { - runSelectionAction?.(action, request); - return request; - }, - }; - const result = action.onClick(rows, helpers); - // A synchronous handler that never calls `run` owns the selection. - if (isPromiseLike(result)) runSelectionAction?.(action, result); - }; return ( - // max-w-full + shrink-0: next to Pagination in the footer's wrapping row, - // the bar keeps its actions on one line and lets Pagination drop below it, - // and only wraps its own buttons once it is wider than the whole footer. - - - - {countText} - - - {inline.map((resolvedAction) => { - const { action, rows, count } = resolvedAction; - return ( - - ); - })} - {overflow.length > 0 && ( - - - {overflow.some(({ action }) => action.id === pendingId) ? ( - - ) : ( - - )} - - } - /> - {/* Opens upward: the bar sits at the bottom of the table, and often - at the bottom of the viewport while it is stuck there. */} - - {overflow.map((resolvedAction) => { - const { action, rows, count } = resolvedAction; - return ( - run(resolvedAction)} - className={cn(action.variant === "destructive" && "astw:text-destructive")} - > - {action.icon} - {action.label} - {count !== null && ({count})} - - ); - })} - - - )} - - - - + . + returnFocusTo={(bar) => + bar + .closest('[data-slot="data-table"]') + ?.querySelector('[data-slot="data-table-header"] [data-slot="checkbox"]') + } + /> ); } DataTableSelectionBar.displayName = "DataTable.SelectionBar"; -/** - * Polite live region for the selection count. It stays mounted while the table - * offers bulk actions, so the first tick (0 → 1, when the bar itself mounts) is - * announced too — a region that mounts together with its text is not. - */ +/** Polite live region for the selection count. See `SelectionActionsAnnouncer`. */ export function DataTableSelectionAnnouncer({ open }: { open: boolean }) { - const countText = useSelectionCountText(); - return ( - - {open ? countText : ""} - - ); + const { selectedIds, total } = useDataTableContext(); + return ; } diff --git a/packages/core/src/components/data-table/use-data-table.ts b/packages/core/src/components/data-table/use-data-table.ts index 05d854515..2c8919d3a 100644 --- a/packages/core/src/components/data-table/use-data-table.ts +++ b/packages/core/src/components/data-table/use-data-table.ts @@ -1,5 +1,6 @@ import { useCallback, useMemo, useRef, useState } from "react"; import type { CollectionControl, Filter, PageInfo, SortState } from "@/types/collection"; +import { useSelectionActionRunner } from "@/components/internals/selection-actions/use-selection-action-runner"; import { usePageCounter } from "./use-page-counter"; import { usePersistentColumnState, type PersistedColumnState } from "./use-persistent-column-state"; import type { Column, UseDataTableOptions, UseDataTableReturn } from "./types"; @@ -368,42 +369,11 @@ export function useDataTable< } : undefined; - // Id of the selection action whose request is in flight. It lives here rather - // than in the footer bar so it survives the bar unmounting and remounting - // (the selection emptying and refilling mid-request), and so a request handed - // back later — from a confirm dialog, via `run` — is tracked the same way. - const [pendingActionId, setPendingActionId] = useState(null); - const pendingActionIdRef = useRef(pendingActionId); - pendingActionIdRef.current = pendingActionId; - - const runSelectionAction = selectionEnabled - ? (action: { id: string; keepSelection?: boolean }, request: PromiseLike) => { - // One request at a time: the bar disables its actions while one runs, - // so a second call here only comes from app code racing itself. - if (pendingActionIdRef.current !== null) return; - pendingActionIdRef.current = action.id; - setPendingActionId(action.id); - const settle = () => { - pendingActionIdRef.current = null; - setPendingActionId(null); - }; - request.then( - () => { - settle(); - // The rows it acted on may have changed, and copies remembered from - // other pages can't refresh — clear rather than keep stale rows. - if (!action.keepSelection) clearSelection?.(); - }, - (reason: unknown) => { - // Keep the selection so the action can be retried. Report rather - // than rethrow: a rethrow from this chain would surface as an - // unhandled rejection even when the app already handled the error. - settle(); - console.error(`[DataTable] Selection action "${action.id}" failed:`, reason); - }, - ); - } - : undefined; + const { pendingActionId, runSelectionAction } = useSelectionActionRunner({ + enabled: selectionEnabled, + clearSelection, + owner: "DataTable", + }); const selectedIds = useMemo(() => [...selection.keys()], [selection]); diff --git a/packages/core/src/components/internals/selection-actions/selection-actions-bar.tsx b/packages/core/src/components/internals/selection-actions/selection-actions-bar.tsx new file mode 100644 index 000000000..3ba215f39 --- /dev/null +++ b/packages/core/src/components/internals/selection-actions/selection-actions-bar.tsx @@ -0,0 +1,243 @@ +import { useLayoutEffect, useRef } from "react"; +import { Ellipsis } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/button"; +import { Menu } from "@/components/menu"; +import { Spinner } from "@/components/spinner"; +import { Toolbar } from "@/components/toolbar"; +// Loaded before the labels on purpose: `@/hooks/i18n` reaches back to itself +// through this context, so a module graph entered via the labels first sees +// `defineI18nLabels` as undefined. Entering via the context is safe. +import "@/contexts/appshell-context"; +import { useDataTableT } from "@/components/data-table/i18n"; +import type { SelectionAction, SelectionActionHelpers } from "@/components/data-table/types"; + +/** Actions past this many collapse into the "More actions" menu. */ +const MAX_INLINE_ACTIONS = 3; + +/** + * The tinted surface a bulk-action bar sits on while a selection is open. + * Sticky, so on a page-scrolling table it rides the bottom of the viewport + * until the table's own end scrolls into view. + */ +export const selectionBarSurfaceClasses = [ + "astw:bg-accent astw:text-accent-foreground", + "astw:sticky astw:bottom-0 astw:z-20", + // The bar's surface, captured for the token wrapper below: a token can't be + // re-pointed in terms of itself on a single element. + "astw:[--selection-bar-surface:var(--accent)]", +]; + +/** + * Re-points the hover and secondary-text tokens for everything on the tinted + * bar, which would otherwise hover to the bar's colour and lose contrast on it. + * Put it on a `contents` wrapper inside the surface. + */ +export const selectionBarTokenClasses = [ + "astw:[--accent:color-mix(in_srgb,var(--accent-foreground)_8%,var(--selection-bar-surface))]", + "astw:[--muted-foreground:color-mix(in_srgb,var(--accent-foreground)_70%,var(--selection-bar-surface))]", +]; + +export type SelectionActionsBarProps> = { + actions: SelectionAction[]; + selectedRows: TRow[]; + /** Shown as "3 of 240 selected"; `null` shows "3 selected". */ + total: number | null; + clearSelection: (() => void) | undefined; + pendingActionId: string | null; + runSelectionAction: + | ((action: { id: string; keepSelection?: boolean }, request: PromiseLike) => void) + | undefined; + /** + * Where focus goes when the bar closes while focus is inside it — after + * Clear, or an action that clears — so it doesn't drop to ``. + */ + returnFocusTo?: (bar: HTMLElement) => HTMLElement | null | undefined; + className?: string; +}; + +type ResolvedAction> = { + action: SelectionAction; + /** The selected rows this action can act on — what `onClick` receives. */ + rows: TRow[]; + /** Shown as "(n)" only when the action narrows the selection. */ + count: number | null; +}; + +function isPromiseLike(value: unknown): value is PromiseLike { + return typeof (value as PromiseLike | null | undefined)?.then === "function"; +} + +/** "3 of 240 selected", or "3 selected" when there is no total. */ +export function useSelectionCountText(selected: number, total: number | null): string { + const t = useDataTableT(); + return t("selectionCount", { selected, total }); +} + +/** + * The bulk-action bar shown while rows are selected: + * count · actions · overflow menu · Clear. + * + * Shared by `DataTable.Footer` and `LineItems.Table`, so selection reads the + * same in both. + */ +export function SelectionActionsBar>({ + actions, + selectedRows, + total, + clearSelection, + pendingActionId, + runSelectionAction, + returnFocusTo, + className, +}: SelectionActionsBarProps) { + const t = useDataTableT(); + const countText = useSelectionCountText(selectedRows.length, total); + const rowRef = useRef(null); + const returnFocusToRef = useRef(returnFocusTo); + returnFocusToRef.current = returnFocusTo; + + // The bar unmounts as soon as the selection empties, usually from its own + // Clear button or an action that clears. Layout-effect cleanup runs while the + // bar is still in the document, so `contains` sees the focused button. + useLayoutEffect(() => { + const bar = rowRef.current; + return () => { + if (!bar?.contains(document.activeElement)) return; + returnFocusToRef.current?.(bar)?.focus(); + }; + }, []); + + // While an action is pending, every action — and Clear — is disabled, so a + // slow request can't be fired twice or overlapped by another action against + // the same selection. + const busy = pendingActionId !== null; + + const clear = () => clearSelection?.(); + const resolved: ResolvedAction[] = actions.map((action) => { + const { canApply } = action; + const rows = canApply ? selectedRows.filter((row) => canApply(row)) : selectedRows; + return { action, rows, count: canApply ? rows.length : null }; + }); + const inline = resolved.slice(0, MAX_INLINE_ACTIONS); + const overflow = resolved.slice(MAX_INLINE_ACTIONS); + + const run = ({ action, rows }: ResolvedAction) => { + if (busy || rows.length === 0) return; + // `run` lets a handler that only opens a confirm dialog hand the request + // back later, from the dialog's confirm button, and still get the pending + // state and clear-on-success. + const helpers: SelectionActionHelpers = { + clearSelection: clear, + run: (request) => { + runSelectionAction?.(action, request); + return request; + }, + }; + const result = action.onClick(rows, helpers); + // A synchronous handler that never calls `run` owns the selection. + if (isPromiseLike(result)) runSelectionAction?.(action, result); + }; + + return ( + // max-w-full + shrink-0: next to other footer content in a wrapping row, + // the bar keeps its actions on one line and lets that content drop below + // it, and only wraps its own buttons once it is wider than the whole row. + + + + {countText} + + + {inline.map((resolvedAction) => { + const { action, rows, count } = resolvedAction; + return ( + + ); + })} + {overflow.length > 0 && ( + + + {overflow.some(({ action }) => action.id === pendingActionId) ? ( + + ) : ( + + )} + + } + /> + {/* Opens upward: the bar sits at the bottom of the table, and often + at the bottom of the viewport while it is stuck there. */} + + {overflow.map((resolvedAction) => { + const { action, rows, count } = resolvedAction; + return ( + run(resolvedAction)} + className={cn(action.variant === "destructive" && "astw:text-destructive")} + > + {action.icon} + {action.label} + {count !== null && ({count})} + + ); + })} + + + )} + + + + + ); +} + +/** + * Polite live region for the selection count. Keep it mounted while the table + * offers bulk actions, so the first tick (0 → 1, when the bar itself mounts) is + * announced too — a region that mounts together with its text is not. + */ +export function SelectionActionsAnnouncer({ + open, + selected, + total, +}: { + open: boolean; + selected: number; + total: number | null; +}) { + const countText = useSelectionCountText(selected, total); + return ( + + {open ? countText : ""} + + ); +} diff --git a/packages/core/src/components/internals/selection-actions/use-selection-action-runner.ts b/packages/core/src/components/internals/selection-actions/use-selection-action-runner.ts new file mode 100644 index 000000000..2044c7552 --- /dev/null +++ b/packages/core/src/components/internals/selection-actions/use-selection-action-runner.ts @@ -0,0 +1,55 @@ +import { useRef, useState } from "react"; + +/** + * Tracks the one selection action whose request is in flight, for a bulk-action + * bar. It lives in the owning hook rather than the bar so it survives the bar + * unmounting and remounting (the selection emptying and refilling mid-request), + * and so a request handed back later — from a confirm dialog, via `run` — is + * tracked the same way. + * + * Shared by `useDataTable` and `useLineItems`; `owner` prefixes the error log. + */ +export function useSelectionActionRunner({ + enabled, + clearSelection, + owner, +}: { + enabled: boolean; + clearSelection: (() => void) | undefined; + owner: string; +}) { + const [pendingActionId, setPendingActionId] = useState(null); + const pendingActionIdRef = useRef(pendingActionId); + pendingActionIdRef.current = pendingActionId; + + const runSelectionAction = enabled + ? (action: { id: string; keepSelection?: boolean }, request: PromiseLike) => { + // One request at a time: the bar disables its actions while one runs, + // so a second call here only comes from app code racing itself. + if (pendingActionIdRef.current !== null) return; + pendingActionIdRef.current = action.id; + setPendingActionId(action.id); + const settle = () => { + pendingActionIdRef.current = null; + setPendingActionId(null); + }; + request.then( + () => { + settle(); + // The rows it acted on may have changed — clear rather than keep + // stale rows. + if (!action.keepSelection) clearSelection?.(); + }, + (reason: unknown) => { + // Keep the selection so the action can be retried. Report rather + // than rethrow: a rethrow from this chain would surface as an + // unhandled rejection even when the app already handled the error. + settle(); + console.error(`[${owner}] Selection action "${action.id}" failed:`, reason); + }, + ); + } + : undefined; + + return { pendingActionId, runSelectionAction }; +} diff --git a/packages/core/src/components/line-items/column-layout.test.ts b/packages/core/src/components/line-items/column-layout.test.ts new file mode 100644 index 000000000..022ea6ee0 --- /dev/null +++ b/packages/core/src/components/line-items/column-layout.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { + ACTIONS_COLUMN_WIDTH, + AUTO_COLUMN_MIN_WIDTH, + FLEX_COLUMN_MIN_WIDTH, + computeColumnLayout, + GRIP_COLUMN_WIDTH, + SELECTION_COLUMN_ID, + SELECTION_COLUMN_WIDTH, + pinStyle, +} from "./column-layout"; +import { createLineItemHelper } from "./field"; + +type Line = { lineRef: string; sku: string; name: string; qty: number; total: number }; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ key: "sku", label: "SKU", render: (l) => l.sku, width: 120, pin: "left" }), + f.field({ key: "name", label: "Name", render: (l) => l.name, flex: true }), + f.field({ key: "qty", label: "Qty", render: (l) => l.qty, width: 80, type: { kind: "number" } }), + f.field({ key: "total", label: "Total", render: (l) => l.total, width: 100, pin: "right" }), +]; + +const noExtras = { hasGrip: false, hasSelection: false, hasActions: false }; + +describe("computeColumnLayout", () => { + it("orders columns left-pinned, unpinned, then right-pinned", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.map((c) => c.id)).toEqual(["sku", "name", "qty", "total"]); + expect(layout.gridColumnIds).toEqual(["sku", "name", "qty", "total"]); + }); + + it("keeps unpinned columns unsticky", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.find((c) => c.id === "name")?.placement).toBeUndefined(); + expect(layout.columns.find((c) => c.id === "qty")?.placement).toBeUndefined(); + }); + + it("stacks left-pinned offsets after the selection and grip columns", () => { + const layout = computeColumnLayout(fields, { + widths: {}, + hasGrip: true, + hasSelection: true, + hasActions: false, + }); + + expect(layout.selection).toEqual({ side: "left", offset: 0, isBoundary: false }); + expect(layout.grip).toEqual({ + side: "left", + offset: SELECTION_COLUMN_WIDTH, + isBoundary: false, + }); + expect(layout.columns.find((c) => c.id === "sku")?.placement).toEqual({ + side: "left", + offset: SELECTION_COLUMN_WIDTH + GRIP_COLUMN_WIDTH, + isBoundary: true, + }); + }); + + it("prefers measured widths over declared ones for offsets", () => { + const layout = computeColumnLayout(fields, { + widths: { [SELECTION_COLUMN_ID]: 60 }, + hasGrip: true, + hasSelection: true, + hasActions: false, + }); + expect(layout.grip?.offset).toBe(60); + }); + + it("lets a pinned column with no declared width still stack correctly once measured", () => { + const unsized = [ + f.field({ key: "a", label: "A", render: () => null, pin: "left" }), + f.field({ key: "b", label: "B", render: () => null, pin: "left" }), + ]; + const layout = computeColumnLayout(unsized, { widths: { a: 90 }, ...noExtras }); + + expect(layout.columns[0]?.placement?.offset).toBe(0); + expect(layout.columns[1]?.placement?.offset).toBe(90); + }); + + it("accumulates right-pinned offsets inward from the actions column", () => { + const twoRight = [ + ...fields, + f.field({ key: "extra", label: "Extra", render: () => null, width: 70, pin: "right" }), + ]; + const layout = computeColumnLayout(twoRight, { + widths: {}, + hasGrip: false, + hasSelection: false, + hasActions: true, + }); + + expect(layout.actions).toEqual({ side: "right", offset: 0, isBoundary: false }); + expect(layout.columns.find((c) => c.id === "extra")?.placement).toEqual({ + side: "right", + offset: ACTIONS_COLUMN_WIDTH, + isBoundary: false, + }); + expect(layout.columns.find((c) => c.id === "total")?.placement).toEqual({ + side: "right", + offset: ACTIONS_COLUMN_WIDTH + 70, + isBoundary: true, + }); + }); + + it("marks the outermost pinned column of each side as the boundary", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.find((c) => c.id === "sku")?.placement?.isBoundary).toBe(true); + expect(layout.columns.find((c) => c.id === "total")?.placement?.isBoundary).toBe(true); + }); + + it("derives alignment from the field type when not set", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.find((c) => c.id === "qty")?.align).toBe("right"); + expect(layout.columns.find((c) => c.id === "name")?.align).toBe("left"); + }); + + describe("spacer", () => { + it("is not needed when a column claims flex", () => { + expect(computeColumnLayout(fields, { widths: {}, ...noExtras }).needsSpacer).toBe(false); + }); + + it("is needed when every column has a fixed width and none flexes", () => { + const fixed = [ + f.field({ key: "sku", label: "SKU", render: () => null, width: 100 }), + f.field({ key: "qty", label: "Qty", render: () => null, width: 80 }), + ]; + expect(computeColumnLayout(fixed, { widths: {}, ...noExtras }).needsSpacer).toBe(true); + }); + + it("is not needed when a column sizes naturally", () => { + const mixed = [ + f.field({ key: "sku", label: "SKU", render: () => null, width: 100 }), + f.field({ key: "note", label: "Note", render: () => null }), + ]; + expect(computeColumnLayout(mixed, { widths: {}, ...noExtras }).needsSpacer).toBe(false); + }); + + it("is not needed for an empty schema", () => { + expect(computeColumnLayout([], { widths: {}, ...noExtras }).needsSpacer).toBe(false); + }); + }); +}); + +describe("pinStyle", () => { + it("returns sticky positioning for the placement's side", () => { + expect(pinStyle({ side: "left", offset: 44, isBoundary: false })).toEqual({ + position: "sticky", + left: 44, + }); + expect(pinStyle({ side: "right", offset: 0, isBoundary: true })).toEqual({ + position: "sticky", + right: 0, + }); + }); + + it("returns nothing for an unpinned column", () => { + expect(pinStyle(undefined)).toBeUndefined(); + }); +}); + +describe("minTableWidth", () => { + it("sums declared widths plus the utility columns", () => { + const layout = computeColumnLayout(fields, { + widths: {}, + hasGrip: true, + hasSelection: true, + hasActions: true, + }); + // sku 120 + name (flex floor) + qty 80 + total 100, plus grip/selection/actions. + expect(layout.minTableWidth).toBe( + 120 + + FLEX_COLUMN_MIN_WIDTH + + 80 + + 100 + + GRIP_COLUMN_WIDTH + + SELECTION_COLUMN_WIDTH + + ACTIONS_COLUMN_WIDTH, + ); + }); + + it("gives an unsized non-flex column a floor so it cannot be crushed", () => { + const layout = computeColumnLayout( + [f.field({ key: "note", label: "Note", render: () => null })], + { widths: {}, ...noExtras }, + ); + expect(layout.minTableWidth).toBe(AUTO_COLUMN_MIN_WIDTH); + }); +}); diff --git a/packages/core/src/components/line-items/column-layout.ts b/packages/core/src/components/line-items/column-layout.ts new file mode 100644 index 000000000..504d79c5e --- /dev/null +++ b/packages/core/src/components/line-items/column-layout.ts @@ -0,0 +1,170 @@ +import type { CSSProperties } from "react"; + +import { defaultAlignForField } from "./field"; +import type { LineItemsColumnAlign, LineItemsField, LineItemsRowData } from "./types"; + +/** Widths measured from the rendered header, keyed by column id. */ +export type ColumnWidths = Record; + +export type PinSide = "left" | "right"; + +export type PinPlacement = { + side: PinSide; + offset: number; + /** The outermost cell of its pinned group — the one that draws the freeze seam. */ + isBoundary: boolean; +}; + +/** Fixed-width utility columns the grid owns rather than the field schema. */ +export const GRIP_COLUMN_ID = "__grip"; +export const SELECTION_COLUMN_ID = "__selection"; +export const ACTIONS_COLUMN_ID = "__actions"; +export const SPACER_COLUMN_ID = "__spacer"; + +export const GRIP_COLUMN_WIDTH = 36; +export const SELECTION_COLUMN_WIDTH = 44; +export const ACTIONS_COLUMN_WIDTH = 56; + +export type LayoutColumn = { + id: string; + field: LineItemsField; + align: LineItemsColumnAlign; + width: number | undefined; + flex: boolean; + placement: PinPlacement | undefined; +}; + +/** + * Floor applied to columns that declare no `width`, so `table-layout: fixed` + * scrolls the grid horizontally instead of crushing them to nothing. + */ +export const FLEX_COLUMN_MIN_WIDTH = 200; +export const AUTO_COLUMN_MIN_WIDTH = 120; + +export type ColumnLayout = { + /** Field columns in render order: left-pinned, unpinned, right-pinned. */ + columns: LayoutColumn[]; + /** + * Narrowest the table may render before it starts scrolling. Without it, a + * schema wider than its container squeezes unsized columns to a few pixels. + */ + minTableWidth: number; + /** Ids of the editable grid's columns in render order, for keyboard and range math. */ + gridColumnIds: string[]; + grip: PinPlacement | undefined; + selection: PinPlacement | undefined; + actions: PinPlacement | undefined; + /** + * True when no column claims `flex` and every column declares a width — the + * grid then renders a trailing spacer so declared widths stay pixel-exact + * instead of being stretched to fill the container. + */ + needsSpacer: boolean; +}; + +function resolveWidth(id: string, declared: number | undefined, widths: ColumnWidths): number { + return widths[id] ?? declared ?? 0; +} + +/** + * Groups columns by pin side and computes cumulative sticky offsets, mirroring + * `computePinLayout` in `data-table.tsx`. Offsets prefer measured widths over + * declared ones, so a pinned column does not need an explicit `width` to stack + * correctly. + */ +export function computeColumnLayout( + fields: LineItemsField[], + opts: { + widths: ColumnWidths; + hasGrip: boolean; + hasSelection: boolean; + hasActions: boolean; + actionsWidth?: number; + }, +): ColumnLayout { + const { widths, hasGrip, hasSelection, hasActions, actionsWidth = ACTIONS_COLUMN_WIDTH } = opts; + + const left: LineItemsField[] = []; + const middle: LineItemsField[] = []; + const right: LineItemsField[] = []; + + for (const field of fields) { + if (field.pin === "left") left.push(field); + else if (field.pin === "right") right.push(field); + else middle.push(field); + } + + const placements = new Map, PinPlacement>(); + + let leftOffset = 0; + let selection: PinPlacement | undefined; + let grip: PinPlacement | undefined; + + if (hasSelection) { + selection = { side: "left", offset: 0, isBoundary: !hasGrip && left.length === 0 }; + leftOffset += resolveWidth(SELECTION_COLUMN_ID, SELECTION_COLUMN_WIDTH, widths); + } + if (hasGrip) { + grip = { side: "left", offset: leftOffset, isBoundary: left.length === 0 }; + leftOffset += resolveWidth(GRIP_COLUMN_ID, GRIP_COLUMN_WIDTH, widths); + } + left.forEach((field, i) => { + placements.set(field, { + side: "left", + offset: leftOffset, + isBoundary: i === left.length - 1, + }); + leftOffset += resolveWidth(field.key, field.width, widths); + }); + + let rightOffset = 0; + let actions: PinPlacement | undefined; + + if (hasActions) { + actions = { side: "right", offset: 0, isBoundary: right.length === 0 }; + rightOffset += resolveWidth(ACTIONS_COLUMN_ID, actionsWidth, widths); + } + for (let i = right.length - 1; i >= 0; i--) { + const field = right[i]!; + placements.set(field, { side: "right", offset: rightOffset, isBoundary: i === 0 }); + rightOffset += resolveWidth(field.key, field.width, widths); + } + + const ordered = [...left, ...middle, ...right]; + const columns: LayoutColumn[] = ordered.map((field) => ({ + id: field.key, + field, + align: defaultAlignForField(field), + width: field.width, + flex: field.flex === true, + placement: placements.get(field), + })); + + const minTableWidth = + (hasSelection ? SELECTION_COLUMN_WIDTH : 0) + + (hasGrip ? GRIP_COLUMN_WIDTH : 0) + + (hasActions ? actionsWidth : 0) + + columns.reduce((sum, c) => { + if (typeof c.width === "number") return sum + c.width; + return sum + (c.flex ? FLEX_COLUMN_MIN_WIDTH : AUTO_COLUMN_MIN_WIDTH); + }, 0); + + return { + columns, + gridColumnIds: columns.map((c) => c.id), + grip, + selection, + actions, + minTableWidth, + needsSpacer: + columns.length > 0 && + !columns.some((c) => c.flex) && + columns.every((c) => typeof c.width === "number"), + }; +} + +/** Sticky positioning for a pinned cell, matching DataTable's `pinCellProps`. */ +export function pinStyle(placement: PinPlacement | undefined): CSSProperties | undefined { + if (!placement) return undefined; + return { position: "sticky", [placement.side]: placement.offset }; +} diff --git a/packages/core/src/components/line-items/field.test.ts b/packages/core/src/components/line-items/field.test.ts new file mode 100644 index 000000000..2ea294c20 --- /dev/null +++ b/packages/core/src/components/line-items/field.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { createLineItemHelper, fieldToColumnDef, parseLooseDate, parseLooseNumber } from "./field"; + +describe("parseLooseNumber", () => { + it.each([ + ["42", 42], + [" -3.5 ", -3.5], + ["1,234", 1234], + ["1,234,567.89", 1234567.89], + ["1.234,5", 1234.5], + ["1,5", 1.5], + ["$1,200", 1200], + ["¥ 3 000", 3000], + ["123", 123], + [".5", 0.5], + ])("reads %j as %d", (input, expected) => { + expect(parseLooseNumber(input)).toBe(expected); + }); + + it.each(["", "N/A", "12abc", "1-2", "--"])("rejects %j", (input) => { + expect(parseLooseNumber(input)).toBeNull(); + }); +}); + +describe("parseLooseDate", () => { + it.each([ + ["2026-06-01", "2026-06-01"], + ["2026/6/1", "2026-06-01"], + ["2026.12.31", "2026-12-31"], + ])("reads %j as %s", (input, expected) => { + expect(parseLooseDate(input)).toBe(expected); + }); + + it.each(["2026-02-31", "01/06/2026", "tomorrow", ""])("rejects %j", (input) => { + expect(parseLooseDate(input)).toBeNull(); + }); +}); + +type Line = { lineRef: string; qty: number | null; due: string | null }; +const f = createLineItemHelper(); +const row: Line = { lineRef: "1", qty: 5, due: "2026-06-10" }; + +describe("fieldToColumnDef normalizers", () => { + const qty = fieldToColumnDef( + f.field({ + key: "qty", + label: "Qty", + render: (l) => l.qty, + type: { kind: "number", decimals: 2 }, + }), + ); + const due = fieldToColumnDef( + f.field({ + key: "due", + label: "Due", + render: (l) => l.due, + type: { kind: "date", min: "2026-06-01", max: "2026-06-30" }, + }), + ); + + it("parses pasted numbers and rounds by decimals", () => { + expect(qty.normalize?.("1,234.567", row)).toBe(1234.57); + }); + + it("keeps the current value when a number can't be read", () => { + expect(qty.normalize?.("N/A", row)).toBe(5); + }); + + it("keeps the current value for an out-of-range or invalid date", () => { + expect(due.normalize?.("2026-07-15", row)).toBe("2026-06-10"); + expect(due.normalize?.("2026-02-31", row)).toBe("2026-06-10"); + expect(due.normalize?.("2026/6/20", row)).toBe("2026-06-20"); + }); +}); diff --git a/packages/core/src/components/line-items/field.ts b/packages/core/src/components/line-items/field.ts new file mode 100644 index 000000000..7f3747a1e --- /dev/null +++ b/packages/core/src/components/line-items/field.ts @@ -0,0 +1,202 @@ +import type { LineItemsColumnDef, LineItemsMutationScope } from "./internals"; +import type { + LineItemsColumnAlign, + LineItemsField, + LineItemsFieldCommit, + LineItemsMode, + LineItemsRowData, +} from "./types"; + +/** + * Field schema builder. Gives full inference of `key`, `render(line)`, and + * `sort.comparator` for a row shape `T`. + * + * @example + * type POLine = { lineRef: string; sku: string; quantity: number; total: number }; + * const f = createLineItemHelper(); + * const fields = [ + * f.field({ key: "sku", label: "SKU", render: (l) => l.sku, editable: ["edit"], type: { kind: "text" } }), + * f.field({ key: "quantity", label: "Qty", render: (l) => l.quantity, editable: ["edit"], type: { kind: "number", decimals: 0 } }), + * f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2) }), + * ]; + */ +export function createLineItemHelper() { + return { + field: (f: LineItemsField): LineItemsField => f, + }; +} + +/** + * Whether the field renders an editor (vs its read-only `render` output) in + * `mode`. A field opts into amend by listing it, e.g. `editable: ["edit", "amend"]`. + */ +export function fieldIsEditableInMode( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + if (mode === "view") return false; + if (!field.editable || field.editable.length === 0) return false; + return field.editable.includes(mode); +} + +/** Cells locked by amend's partial lock get a tint, so what is still editable reads clearly. */ +export function fieldIsAmendLocked( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + return mode === "amend" && !fieldIsEditableInMode(field, mode); +} + +/** Resolves where a field's edits go. Non-editable columns never reach the change-set. */ +export function fieldCommitScope( + field: LineItemsField, +): LineItemsFieldCommit { + return field.commit ?? "document"; +} + +/** + * Whether the field participates in the document change-set. Computed and + * read-only columns are excluded, so a derived total never submits as an edit. + */ +export function fieldMutationScope( + field: LineItemsField, +): LineItemsMutationScope { + if ((field.editable?.length ?? 0) === 0) return "excluded"; + return fieldCommitScope(field) === "metadata" ? "excluded" : "document"; +} + +/** Whether the column accepts TSV paste in `mode`. */ +export function fieldAllowsPaste( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + return fieldIsEditableInMode(field, mode); +} + +/** Whether a fill handle should be offered for the column in `mode`. */ +export function fieldAllowsFill( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + if (mode !== "edit") return false; + return fieldIsEditableInMode(field, mode) && fieldMutationScope(field) === "document"; +} + +/** + * Translates a field into the internal column descriptor the change-set engine + * consumes. + * + * Numeric fields get a normalizer that coerces strings to numbers and rounds by + * `decimals`, plus a tolerance-aware `equals`, so cosmetic input differences + * like `"1"` vs `"1.00"` do not register as document changes. Precedence for + * overrides runs field-level, then type-level, then these built-in defaults. + */ +export function fieldToColumnDef( + field: LineItemsField, +): LineItemsColumnDef { + const def: LineItemsColumnDef = { + id: field.key, + accessorKey: field.key as keyof T & string, + mutationScope: fieldMutationScope(field), + }; + + if (field.type?.kind === "number") { + const decimals = field.type.decimals; + def.normalize = (value: unknown, row: T): unknown => { + if (value === "" || value === null || value === undefined) return null; + const n = typeof value === "number" ? value : parseLooseNumber(String(value)); + // Unreadable input (a pasted word, "N/A") keeps the cell's current value + // rather than storing text in a numeric field. + if (n === null || Number.isNaN(n)) return (row as Record)[field.key]; + if (typeof decimals === "number") { + const factor = 10 ** decimals; + return Math.round(n * factor) / factor; + } + return n; + }; + def.equals = (a: unknown, b: unknown): boolean => { + if (a === b) return true; + if (a == null || b == null) return a == null && b == null; + const an = Number(a); + const bn = Number(b); + if (Number.isNaN(an) || Number.isNaN(bn)) return Object.is(a, b); + return Math.abs(an - bn) < 1e-9; + }; + } + + if (field.type?.kind === "date") { + const { min, max } = field.type; + def.normalize = (value: unknown, row: T): unknown => { + if (value === "" || value === null || value === undefined) return null; + const iso = parseLooseDate(String(value)); + // An unreadable or out-of-range date keeps the current value: the + // editor's min/max only constrain its picker, not a paste or fill. + if (!iso || (min && iso < min) || (max && iso > max)) { + return (row as Record)[field.key]; + } + return iso; + }; + } + + if (field.type?.kind === "custom") { + if (field.type.normalize) def.normalize = field.type.normalize; + if (field.type.equals) def.equals = field.type.equals; + } + + if (field.normalize) def.normalize = field.normalize; + if (field.equals) def.equals = field.equals; + + return def; +} + +export function fieldsToColumnDefs( + fields: LineItemsField[], +): LineItemsColumnDef[] { + return fields.map((f) => fieldToColumnDef(f)); +} + +/** Numbers right-align, booleans center, everything else left. */ +export function defaultAlignForField( + field: LineItemsField, +): LineItemsColumnAlign { + if (field.align) return field.align; + if (field.type?.kind === "number") return "right"; + if (field.type?.kind === "boolean") return "center"; + return "left"; +} + +/** + * Reads a number the way people paste them from spreadsheets: thousands + * separators ("1,234"), a decimal comma ("1,5"), currency symbols, and + * full-width digits. Returns `null` when nothing numeric is left. + */ +export function parseLooseNumber(input: string): number | null { + let s = input + .trim() + // Full-width digits and signs (common in Japanese input) to ASCII. + .replace(/[0-9.,-]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 0xfee0)) + .replace(/[\s\u00a0\u202f_$€£¥¥]/g, ""); + if (s === "") return null; + const lastComma = s.lastIndexOf(","); + const lastDot = s.lastIndexOf("."); + if (lastComma !== -1 && lastDot !== -1) { + // Both present: whichever comes last is the decimal separator. + s = lastComma > lastDot ? s.replace(/\./g, "").replace(",", ".") : s.replace(/,/g, ""); + } else if (lastComma !== -1) { + // Comma only: grouped thousands ("1,234,567") or a decimal comma ("1,5"). + s = /^-?\d{1,3}(,\d{3})+$/.test(s) ? s.replace(/,/g, "") : s.replace(",", "."); + } + if (!/^-?(\d+\.?\d*|\.\d+)$/.test(s)) return null; + return Number(s); +} + +/** Accepts YYYY-MM-DD, YYYY/MM/DD or YYYY.MM.DD; returns ISO YYYY-MM-DD or `null`. */ +export function parseLooseDate(input: string): string | null { + const m = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(input.trim()); + if (!m) return null; + const [, y, mo, d] = m; + const iso = `${y}-${mo!.padStart(2, "0")}-${d!.padStart(2, "0")}`; + const date = new Date(`${iso}T00:00:00Z`); + // Rejects impossible dates such as 2026-02-31, which Date would roll over. + return Number.isNaN(date.getTime()) || date.toISOString().slice(0, 10) !== iso ? null : iso; +} diff --git a/packages/core/src/components/line-items/i18n.ts b/packages/core/src/components/line-items/i18n.ts new file mode 100644 index 000000000..e90304f6b --- /dev/null +++ b/packages/core/src/components/line-items/i18n.ts @@ -0,0 +1,74 @@ +// Loaded first on purpose: `@/hooks/i18n` reaches back to itself through this +// context, so a module graph entered via the labels sees `defineI18nLabels` +// as undefined. Entering via the context is safe. +import "@/contexts/appshell-context"; +import { defineI18nLabels } from "@/hooks/i18n"; + +export const lineItemsLabels = defineI18nLabels({ + en: { + // Root (fullscreen overlay's accessible name) + fullscreenLabel: "Line items", + + // Search / SearchToggle + searchPlaceholder: "Search lines", + searchLabel: "Search lines", + + // FullscreenToggle + enterFullscreen: "Expand to fullscreen", + exitFullscreen: "Exit fullscreen", + + // SaveActions / DirtyBar + saveChanges: "Save changes", + save: "Save", + discard: "Discard", + unsavedChanges: (props: { count: number }) => + `${props.count} unsaved ${props.count === 1 ? "change" : "changes"}`, + // Announced when a link is blocked because the document has unsaved edits. + unsavedNavBlocked: "Unsaved changes. Save or discard them before leaving.", + + // Table + emptyMessage: "No lines yet.", + selectAllLines: "Select all lines", + // Screen-reader names for the utility column headers. + reorderColumn: "Reorder", + actionsColumn: "Actions", + selectLine: (props: { line: number }) => `Select line ${props.line}`, + reorderLine: (props: { line: number }) => `Drag to reorder line ${props.line}`, + lineMoved: (props: { line: number; position: number }) => + `Line ${props.line} moved to position ${props.position}`, + // Accessible name for a cell editor: column, then line. + cellLabel: (props: { column: string; line: number }) => `${props.column}, line ${props.line}`, + + // Select cell + noMatches: "No matches.", + }, + ja: { + fullscreenLabel: "明細", + + searchPlaceholder: "明細を検索", + searchLabel: "明細を検索", + + enterFullscreen: "全画面で表示", + exitFullscreen: "全画面を終了", + + saveChanges: "変更を保存", + save: "保存", + discard: "破棄", + unsavedChanges: (props: { count: number }) => `未保存の変更 ${props.count} 件`, + unsavedNavBlocked: "未保存の変更があります。移動する前に保存または破棄してください。", + + emptyMessage: "明細はまだありません。", + selectAllLines: "すべての明細を選択", + reorderColumn: "並べ替え", + actionsColumn: "操作", + selectLine: (props: { line: number }) => `${props.line} 行目を選択`, + reorderLine: (props: { line: number }) => `${props.line} 行目をドラッグして並べ替え`, + lineMoved: (props: { line: number; position: number }) => + `${props.line} 行目を ${props.position} 番目に移動しました`, + cellLabel: (props: { column: string; line: number }) => `${props.column}、${props.line} 行目`, + + noMatches: "一致する項目がありません。", + }, +}); + +export const useLineItemsT = lineItemsLabels.useT; diff --git a/packages/core/src/components/line-items/index.ts b/packages/core/src/components/line-items/index.ts new file mode 100644 index 000000000..679c2779b --- /dev/null +++ b/packages/core/src/components/line-items/index.ts @@ -0,0 +1,29 @@ +export { LineItems, type LineItemsRootProps } from "./line-items"; +export { useLineItems } from "./use-line-items"; +export { + useLineItemsGroup, + type LineItemsGroupChangeSet, + type LineItemsGroupMembers, + type UseLineItemsGroupReturn, +} from "./use-line-items-group"; +export { createLineItemHelper } from "./field"; +export type { LineItemsTableProps } from "./line-items-table"; +export type { + LineItemsChangeSet, + LineItemsColumnAlign, + LineItemsCustomEditorContext, + LineItemsField, + LineItemsFieldCommit, + LineItemsFieldType, + LineItemsLineChange, + LineItemsMetadataCommit, + LineItemsMode, + LineItemsOrderingMode, + LineItemsRowData, + LineItemsRowPatch, + LineItemsSelectOption, + LineItemsSortDirection, + LineItemsSortState, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; diff --git a/packages/core/src/components/line-items/internals.test.ts b/packages/core/src/components/line-items/internals.test.ts new file mode 100644 index 000000000..14dba8368 --- /dev/null +++ b/packages/core/src/components/line-items/internals.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; + +import { createLineItemHelper, fieldsToColumnDefs } from "./field"; +import { buildChangeSet, cloneBaseline, type LineItemsBaseline } from "./internals"; +import type { LineItemsOrderingMode } from "./types"; + +type Line = { + lineRef: string; + sku: string; + quantity: number; + note: string; + total: number; +}; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + }), + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit"], + type: { kind: "text" }, + }), + // Computed column — read-only, so it must never reach the change-set. + f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2) }), +]; + +const columns = fieldsToColumnDefs(fields); + +function line(over: Partial & { lineRef: string }): Line { + return { sku: "SKU-1", quantity: 1, note: "", total: 0, ...over }; +} + +function pack(rows: Line[]) { + const byRef: Record = {}; + const order: string[] = []; + for (const r of rows) { + byRef[r.lineRef] = r; + order.push(r.lineRef); + } + return { byRef, order }; +} + +function changeSet( + baseline: LineItemsBaseline, + rows: Line[], + opts?: { + inserted?: string[]; + removed?: string[]; + ordering?: LineItemsOrderingMode; + }, +) { + const { byRef, order } = pack(rows); + return buildChangeSet( + columns, + baseline, + order, + byRef, + new Set(opts?.removed ?? []), + new Set(opts?.inserted ?? []), + opts?.ordering ?? "sort", + ); +} + +function baselineOf(rows: Line[]): LineItemsBaseline { + const { byRef, order } = pack(rows); + return cloneBaseline(order, byRef); +} + +describe("buildChangeSet", () => { + it("is empty when nothing changed", () => { + const rows = [line({ lineRef: "1" }), line({ lineRef: "2" })]; + const cs = changeSet(baselineOf(rows), rows); + expect(cs.isEmpty).toBe(true); + expect(cs.lineChanges).toEqual([]); + }); + + it("emits an add with a tempId for an inserted line", () => { + const base = baselineOf([line({ lineRef: "1" })]); + const cs = changeSet(base, [line({ lineRef: "1" }), line({ lineRef: "tmp", sku: "SKU-9" })], { + inserted: ["tmp"], + }); + + expect(cs.isEmpty).toBe(false); + expect(cs.lineChanges).toEqual([ + { action: "add", tempId: "tmp", data: { sku: "SKU-9", quantity: 1, note: "" } }, + ]); + }); + + it("emits an update carrying only the changed fields", () => { + const base = baselineOf([line({ lineRef: "1", sku: "SKU-1", quantity: 1 })]); + const cs = changeSet(base, [line({ lineRef: "1", sku: "SKU-1", quantity: 5 })]); + + expect(cs.lineChanges).toEqual([{ action: "update", lineId: "1", patch: { quantity: 5 } }]); + }); + + it("emits a remove for a line dropped from the document", () => { + const base = baselineOf([line({ lineRef: "1" }), line({ lineRef: "2" })]); + const cs = changeSet(base, [line({ lineRef: "1" })], { removed: ["2"] }); + + expect(cs.lineChanges).toEqual([{ action: "remove", lineId: "2" }]); + }); + + it("never includes lineRef or read-only computed columns in a patch", () => { + const base = baselineOf([line({ lineRef: "1", total: 0 })]); + const cs = changeSet(base, [line({ lineRef: "1", total: 999, quantity: 2 })]); + + expect(cs.lineChanges).toEqual([{ action: "update", lineId: "1", patch: { quantity: 2 } }]); + }); + + describe("normalization", () => { + it("treats cosmetic numeric input as unchanged", () => { + const base = baselineOf([line({ lineRef: "1", quantity: 1 })]); + const cs = changeSet(base, [line({ lineRef: "1", quantity: "1.00" as unknown as number })]); + expect(cs.isEmpty).toBe(true); + }); + + it("rounds numeric values by the field's decimals", () => { + const base = baselineOf([line({ lineRef: "1", quantity: 1 })]); + const cs = changeSet(base, [line({ lineRef: "1", quantity: 1.239 })]); + expect(cs.lineChanges).toEqual([ + { action: "update", lineId: "1", patch: { quantity: 1.24 } }, + ]); + }); + + it("trims strings before comparing", () => { + const base = baselineOf([line({ lineRef: "1", note: "hello" })]); + expect(changeSet(base, [line({ lineRef: "1", note: " hello " })]).isEmpty).toBe(true); + expect(changeSet(base, [line({ lineRef: "1", note: " bye " })]).lineChanges).toEqual([ + { action: "update", lineId: "1", patch: { note: "bye" } }, + ]); + }); + + it("honors a field-level equals override", () => { + const custom = fieldsToColumnDefs([ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + equals: (a, b) => String(a).toLowerCase() === String(b).toLowerCase(), + }), + ]); + const rows = [line({ lineRef: "1", sku: "sku-1" })]; + const cs = buildChangeSet( + custom, + baselineOf([line({ lineRef: "1", sku: "SKU-1" })]), + ["1"], + pack(rows).byRef, + new Set(), + new Set(), + "sort", + ); + expect(cs.isEmpty).toBe(true); + }); + }); + + describe("reorder", () => { + const base = baselineOf([ + line({ lineRef: "1" }), + line({ lineRef: "2" }), + line({ lineRef: "3" }), + ]); + const swapped = [line({ lineRef: "2" }), line({ lineRef: "1" }), line({ lineRef: "3" })]; + + it("emits reorder ops with final positions under manual ordering", () => { + const cs = changeSet(base, swapped, { ordering: "manual" }); + expect(cs.lineChanges).toEqual([ + { action: "reorder", lineId: "2", position: 0 }, + { action: "reorder", lineId: "1", position: 1 }, + ]); + }); + + it("stays silent under sort ordering", () => { + expect(changeSet(base, swapped, { ordering: "sort" }).isEmpty).toBe(true); + }); + + it("emits nothing when a line is dragged back to its original slot", () => { + const original = [line({ lineRef: "1" }), line({ lineRef: "2" }), line({ lineRef: "3" })]; + expect(changeSet(base, original, { ordering: "manual" }).isEmpty).toBe(true); + }); + + it("does not read an insert as a move of everything below it", () => { + const withInsert = [ + line({ lineRef: "1" }), + line({ lineRef: "tmp" }), + line({ lineRef: "2" }), + line({ lineRef: "3" }), + ]; + const cs = changeSet(base, withInsert, { inserted: ["tmp"], ordering: "manual" }); + expect(cs.lineChanges.filter((c) => c.action === "reorder")).toEqual([]); + }); + + it("does not read a removal as a move of everything below it", () => { + const withRemoval = [line({ lineRef: "1" }), line({ lineRef: "3" })]; + const cs = changeSet(base, withRemoval, { removed: ["2"], ordering: "manual" }); + expect(cs.lineChanges.filter((c) => c.action === "reorder")).toEqual([]); + }); + }); + + it("reports adds, updates, removes and reorders together", () => { + const base = baselineOf([ + line({ lineRef: "1" }), + line({ lineRef: "2" }), + line({ lineRef: "3" }), + ]); + const cs = changeSet( + base, + [line({ lineRef: "3" }), line({ lineRef: "1", quantity: 7 }), line({ lineRef: "tmp" })], + { inserted: ["tmp"], removed: ["2"], ordering: "manual" }, + ); + + expect(cs.lineChanges).toContainEqual({ action: "remove", lineId: "2" }); + expect(cs.lineChanges).toContainEqual({ + action: "update", + lineId: "1", + patch: { quantity: 7 }, + }); + expect(cs.lineChanges.some((c) => c.action === "add" && c.tempId === "tmp")).toBe(true); + }); +}); + +describe("cloneBaseline", () => { + it("snapshots rows so later mutation of the source does not leak in", () => { + const rows = [line({ lineRef: "1", note: "before" })]; + const { byRef, order } = pack(rows); + const baseline = cloneBaseline(order, byRef); + + rows[0]!.note = "after"; + + expect(baseline.rows["1"]?.note).toBe("before"); + }); +}); diff --git a/packages/core/src/components/line-items/internals.ts b/packages/core/src/components/line-items/internals.ts new file mode 100644 index 000000000..8aef7bf9c --- /dev/null +++ b/packages/core/src/components/line-items/internals.ts @@ -0,0 +1,237 @@ +import type { + LineItemsChangeSet, + LineItemsLineChange, + LineItemsOrderingMode, + LineItemsRowData, + LineItemsRowPatch, +} from "./types"; + +/** + * `"excluded"` columns never reach the change-set — computed and read-only + * columns are flagged this way so a derived total is not submitted as an edit. + */ +export type LineItemsMutationScope = "document" | "excluded"; + +/** + * Internal column descriptor consumed by the change-set engine. Consumers + * describe columns with `LineItemsField`; `fieldToColumnDef` translates each + * into this shape. + */ +export type LineItemsColumnDef = { + id: string; + accessorKey?: keyof TRow & string; + mutationScope?: LineItemsMutationScope; + normalize?: (value: unknown, row: TRow) => unknown; + equals?: (a: unknown, b: unknown, row: TRow) => boolean; +}; + +/** Baseline captures document order plus row snapshots keyed by `lineRef`. */ +export type LineItemsBaseline = { + order: string[]; + rows: Record; +}; + +function defaultNormalizeValue(value: unknown): unknown { + if (typeof value === "string") return value.trim(); + return value; +} + +/** + * Indexes columns by both `accessorKey` and `id` so per-key lookups during a + * diff are O(1) — `getChangeSet` runs on every render to derive `isDirty`, over + * every row and column. + */ +function indexColumns( + cols: LineItemsColumnDef[], +): Map> { + const index = new Map>(); + for (const col of cols) { + if (col.accessorKey && !index.has(col.accessorKey)) index.set(col.accessorKey, col); + if (!index.has(col.id)) index.set(col.id, col); + } + return index; +} + +/** + * Resolves a single value through its column's normalizer. Used by paste and + * fill, which coerce incoming text before writing it into a row. + */ +export function normalizeField( + cols: LineItemsColumnDef[], + key: string, + value: unknown, + row: TRow, +): unknown { + const col = cols.find((c) => c.accessorKey === key || c.id === key); + if (col?.normalize) return col.normalize(value, row); + return defaultNormalizeValue(value); +} + +export function computeDocumentPatches( + cols: LineItemsColumnDef[], + baseline: LineItemsBaseline, + currentOrder: readonly string[], + currentByRef: Record, + removedRefs: ReadonlySet, + insertedRefs: ReadonlySet, + orderingMode: LineItemsOrderingMode, +): LineItemsLineChange[] { + const columnIndex = indexColumns(cols); + const documentKeys = columnsByScope(cols, "document"); + const currentIds = new Set(currentOrder); + const lines: LineItemsLineChange[] = []; + + const normalize = (key: string, value: unknown, row: TRow): unknown => { + const col = columnIndex.get(key); + if (col?.normalize) return col.normalize(value, row); + return defaultNormalizeValue(value); + }; + + const equals = (key: string, a: unknown, b: unknown, row: TRow): boolean => { + const col = columnIndex.get(key); + if (col?.equals) return col.equals(a, b, row); + return Object.is(a, b); + }; + + for (const id of baseline.order) { + if (removedRefs.has(id) || !currentIds.has(id)) { + if (baseline.rows[id]) lines.push({ action: "remove", lineId: id }); + } + } + + for (const id of currentOrder) { + if (!insertedRefs.has(id) || baseline.rows[id]) continue; + const row = currentByRef[id]; + if (!row) continue; + const data: LineItemsRowPatch = {}; + for (const key of documentKeys) { + data[key] = normalize(key, row[key as keyof TRow], row); + } + lines.push({ action: "add", tempId: id, data }); + } + + for (const id of currentOrder) { + if (removedRefs.has(id)) continue; + const base = baseline.rows[id]; + const row = currentByRef[id]; + if (!row || !base) continue; + + const patch: LineItemsRowPatch = {}; + for (const key of documentKeys) { + const nCur = normalize(key, row[key as keyof TRow], row); + const nBase = normalize(key, base[key as keyof TRow], base); + if (!equals(key, nCur, nBase, row)) patch[key] = nCur; + } + if (Object.keys(patch).length > 0) lines.push({ action: "update", lineId: id, patch }); + } + + if (orderingMode === "manual") { + lines.push( + ...diffPersistedMoves( + baseline.order, + currentOrder, + baseline.rows, + insertedRefs, + removedRefs, + currentByRef, + ), + ); + } + + return lines; +} + +/** + * Emits a `reorder` op for each persisted line whose index moved relative to + * baseline. Restricted to lines present in both baseline and current state, so + * an add or remove shifting everything below it does not read as N moves. + */ +function diffPersistedMoves( + baseOrderFull: readonly string[], + curOrderFull: readonly string[], + baselineRows: Record, + insertedRefs: ReadonlySet, + removedRefs: ReadonlySet, + currentByRef: Record, +): LineItemsLineChange[] { + const persisted = (ids: readonly string[]) => + ids.filter( + (id) => baselineRows[id] && currentByRef[id] && !removedRefs.has(id) && !insertedRefs.has(id), + ); + + const baseIds = persisted(baseOrderFull); + const curIds = persisted(curOrderFull); + if (baseIds.length !== curIds.length || !setsEqual(new Set(baseIds), new Set(curIds))) return []; + + const baseIndex = new Map(); + for (let i = 0; i < baseIds.length; i++) baseIndex.set(baseIds[i]!, i); + + const reorders: LineItemsLineChange[] = []; + for (let i = 0; i < curIds.length; i++) { + const id = curIds[i]!; + if (baseIndex.get(id) !== i) { + reorders.push({ action: "reorder", lineId: id, position: i }); + } + } + return reorders; +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const x of a) if (!b.has(x)) return false; + return true; +} + +function columnsByScope( + cols: LineItemsColumnDef[], + scope: LineItemsMutationScope, +): string[] { + const keys: string[] = []; + for (const c of cols) { + if ((c.mutationScope ?? "document") !== scope) continue; + const key = (c.accessorKey as string | undefined) ?? c.id; + if (key && key !== "lineRef") keys.push(key); + } + return keys; +} + +export function buildChangeSet( + cols: LineItemsColumnDef[], + baseline: LineItemsBaseline, + currentOrder: readonly string[], + currentByRef: Record, + removedRefs: ReadonlySet, + insertedRefs: ReadonlySet, + orderingMode: LineItemsOrderingMode, +): LineItemsChangeSet { + const lineChanges = computeDocumentPatches( + cols, + baseline, + currentOrder, + currentByRef, + removedRefs, + insertedRefs, + orderingMode, + ); + return { isEmpty: lineChanges.length === 0, lineChanges }; +} + +export function cloneRow(row: TRow): TRow { + try { + return structuredClone(row) as TRow; + } catch { + return { ...row } as TRow; + } +} + +export function cloneBaseline( + order: readonly string[], + byRef: Record, +): LineItemsBaseline { + const rows: Record = {}; + for (const id of order) { + const r = byRef[id]; + if (r) rows[id] = cloneRow(r); + } + return { order: [...order], rows }; +} 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..234b4e0fb --- /dev/null +++ b/packages/core/src/components/line-items/line-items-cell.tsx @@ -0,0 +1,462 @@ +import * as React from "react"; + +import { + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxList, + ComboboxRoot, + ComboboxTrigger, +} from "@/components/combobox/combobox"; +import { cn } from "@/lib/utils"; +import { useLineItemsT } from "./i18n"; +import type { + LineItemsField, + LineItemsMode, + LineItemsRowData, + LineItemsSelectOption, +} from "./types"; + +/** Where a commit hands focus next. */ +export type CellNavigation = "enter-down" | "enter-up" | "tab" | "shift-tab"; + +export type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + +/** + * Editors are borderless and fill the cell, unlike the standalone `Input` + * component (a bordered 36px control), which would break the grid's rhythm. + * Every editable cell mounts its editor, so the grid reads as a spreadsheet at + * rest — a text caret in text cells, a caret button in select cells. + */ +const editorClasses = cn( + // `dark:bg-transparent` is not redundant: the wrapped input ships + // `dark:bg-input/30`, and tailwind-merge only dedupes within one variant, so a + // bare `bg-transparent` leaves the dark fill painted over the cell. + "astw:h-full astw:min-h-0 astw:w-full astw:min-w-0 astw:flex-1 astw:border-0 astw:bg-transparent astw:dark:bg-transparent astw:px-2 astw:text-sm astw:leading-none astw:text-foreground", + // Clipped values end in an ellipsis until focused, then scroll as usual. + "astw:text-ellipsis", + "astw:rounded-none astw:shadow-none astw:outline-none astw:ring-0", + "astw:focus:outline-none astw:focus:shadow-none astw:focus:ring-0 astw:focus:border-transparent", + "astw:focus-visible:outline-none astw:focus-visible:shadow-none astw:focus-visible:ring-0 astw:focus-visible:border-transparent", +); + +/** The native number spinner has no room in a grid cell. */ +const numberEditorClasses = + "astw:[appearance:textfield] astw:[&::-webkit-outer-spin-button]:appearance-none astw:[&::-webkit-inner-spin-button]:appearance-none"; + +/** Native input type per field kind; anything else is plain text. */ +const inputTypes: Record = { number: "number", date: "date" }; + +const alignClasses = { + left: "astw:text-left", + center: "astw:text-center", + right: "astw:text-right astw:tabular-nums", +} as const; + +export type LineItemsCellProps = { + row: T; + field: LineItemsField; + mode: LineItemsMode; + align: "left" | "center" | "right"; + editable: boolean; + /** Accessible name for the editor — column and line, e.g. "Qty, line 2". */ + ariaLabel: string; + /** Renders the fill handle — the active cell of a fillable column only. */ + showFillGrip: boolean; + /** Shared by every cell; stable for the grid's lifetime, so cells can memoize. */ + handlers: LineItemsCellHandlers; +}; + +const useIsomorphicLayoutEffect = + typeof document !== "undefined" ? React.useLayoutEffect : React.useEffect; + +type CellCoord = { lineRef: string; columnId: string }; + +/** + * The grid's cell callbacks, as one stable object that always calls the grid's + * latest logic. Each cell binds them to its own coordinate, so a cell only + * re-renders when its own row, field or state changes — not on every keypress. + */ +export type LineItemsCellHandlers = { + commit: (coord: CellCoord, value: unknown) => void; + /** Syncs the grid's active coordinate when an editor takes focus by any route. */ + focus: (coord: CellCoord) => void; + /** + * Moves the active cell. Returns `false` when there is nowhere to go (Tab + * past the grid's last editable cell), so the browser's own Tab can take + * focus out of the grid instead of trapping it. + */ + navigate: (coord: CellCoord, to: CellNavigation) => boolean; + /** Alt+Arrow moves the active cell without leaving the editor. */ + arrow: (key: ArrowKey, shift: boolean) => void; + fillGripDown: (coord: CellCoord, event: React.PointerEvent) => void; +}; + +function toEditorValue(value: unknown): string { + if (value === null || value === undefined) return ""; + return String(value); +} + +const optionLabel = (o: LineItemsSelectOption): string => + o.description ? `${o.label} ${o.description}` : o.label; +const optionValue = (o: LineItemsSelectOption): string => o.value; + +/** 6px square on the cell's bottom-right corner; drag it to fill down a column. */ +function FillGrip({ onPointerDown }: { onPointerDown: (e: React.PointerEvent) => void }) { + return ( + + ); +} + +/** + * A searchable select cell. Keeps the current value selectable even when it is + * absent from `options`, so a value the catalogue no longer offers is never + * silently dropped. + */ +function SelectCell({ + field, + value, + ariaLabel, + onCommit, + onFocusCell, + onNavigate, + onArrowFromEditor, +}: Pick, "field" | "ariaLabel"> & { + value: unknown; + onCommit: (value: unknown) => void; + onFocusCell: () => void; + onNavigate: (to: CellNavigation) => boolean; + onArrowFromEditor: (key: ArrowKey, shift: boolean) => void; +}) { + const t = useLineItemsT(); + const type = field.type; + const current = toEditorValue(value); + + const items = React.useMemo((): LineItemsSelectOption[] => { + if (!type || type.kind !== "select") return []; + const base = [...type.options]; + if (current && !base.some((o) => o.value === current)) { + base.push({ value: current, label: current }); + } + return base; + }, [type, current]); + + if (!type || type.kind !== "select") return null; + const selected = items.find((o) => o.value === current) ?? null; + + return ( + + items={items} + value={selected} + onValueChange={(next) => onCommit(next?.value ?? "")} + itemToStringLabel={optionLabel} + itemToStringValue={optionValue} + > + + { + onFocusCell(); + e.currentTarget.select(); + }} + onKeyDown={(e) => { + if (e.altKey && isArrowKey(e.key)) { + e.preventDefault(); + onArrowFromEditor(e.key, e.shiftKey); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + onNavigate("enter-down"); + return; + } + if (e.key === "Tab") { + if (!onNavigate(e.shiftKey ? "shift-tab" : "tab")) return; + e.preventDefault(); + e.stopPropagation(); + return; + } + // With the popup closed, Base UI clears the selection on Escape. In a + // grid Escape means "cancel", never "erase the cell", so skip that + // handler and treat it like the other editors do. An open popup + // still closes on Escape as usual. + if (e.key === "Escape" && e.currentTarget.getAttribute("aria-expanded") !== "true") { + (e as unknown as { preventBaseUIHandler?: () => void }).preventBaseUIHandler?.(); + e.preventDefault(); + e.stopPropagation(); + } + }} + /> + + + + {t("noMatches")} + + {(item: LineItemsSelectOption) => ( + +
+ {item.label} + {item.description ? ( + + {item.description} + + ) : null} +
+
+ )} +
+
+ + ); +} + +function isArrowKey(key: string): key is ArrowKey { + return key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight"; +} + +/** + * One grid cell's content. + * + * Edits are held locally and committed on blur or Enter, never per keystroke: + * committing every character re-renders the grid and costs the input its focus + * mid-word. + */ +function LineItemsCellInner({ + row, + field, + mode, + align, + editable, + ariaLabel, + showFillGrip, + handlers, +}: LineItemsCellProps) { + const lineRef = row.lineRef; + const columnId = field.key; + const onCommit = React.useCallback( + (value: unknown) => handlers.commit({ lineRef, columnId }, value), + [handlers, lineRef, columnId], + ); + const onFocusCell = React.useCallback( + () => handlers.focus({ lineRef, columnId }), + [handlers, lineRef, columnId], + ); + const onNavigate = React.useCallback( + (to: CellNavigation) => handlers.navigate({ lineRef, columnId }, to), + [handlers, lineRef, columnId], + ); + const onArrowFromEditor = handlers.arrow; + const onFillGripPointerDown = React.useCallback( + (event: React.PointerEvent) => handlers.fillGripDown({ lineRef, columnId }, event), + [handlers, lineRef, columnId], + ); + const rawValue = (row as Record)[field.key]; + const [draft, setDraft] = React.useState(() => toEditorValue(rawValue)); + const inputRef = React.useRef(null); + + // Whether the user has typed since the draft last matched the stored value. + // Until they do, the editor mirrors the store — so a paste, fill or revert + // into the focused cell shows at once — and there is nothing to commit, so a + // blur can't write a stale draft back over it, and Enter/Tab followed by the + // blur they cause commits once, not twice. + const typedRef = React.useRef(false); + + // The stored value only changes underneath a cell from outside it — a paste, + // fill or revert — and that write wins over any half-typed draft. A layout + // effect, so the corrected value is painted in the same frame as the commit. + useIsomorphicLayoutEffect(() => { + typedRef.current = false; + setDraft(toEditorValue(rawValue)); + }, [rawValue]); + + const commit = React.useCallback( + (next: string) => { + if (!typedRef.current) return; + typedRef.current = false; + if (next !== toEditorValue(rawValue)) onCommit(next); + // Show what is stored, not what was typed: an edit that normalizes back + // to the same value ("1.501" at two decimals) leaves the store unchanged, + // so the effect above would never correct the display. A real change + // re-renders with the new value and the effect settles it. + setDraft(toEditorValue(rawValue)); + }, + [onCommit, rawValue], + ); + + // A virtualized row scrolled out of the window unmounts without a blur, so + // commit any typing still in flight rather than drop it. + const latest = React.useRef({ draft, commit }); + latest.current = { draft, commit }; + React.useEffect(() => () => latest.current.commit(latest.current.draft), []); + // Same when the cell stops being editable (a mode switch) while holding typing. + React.useEffect(() => { + if (!editable) latest.current.commit(latest.current.draft); + }, [editable]); + + const grip = showFillGrip ? : null; + + if (!editable) { + return ( + <> + + {field.render(row)} + + {grip} + + ); + } + + if (field.type?.kind === "custom") { + return ( + <> +
+ {field.type.renderEditor({ + value: rawValue, + onCommit, + onCancel: () => setDraft(toEditorValue(rawValue)), + row, + mode, + field, + ariaLabel, + })} +
+ {grip} + + ); + } + + if (field.type?.kind === "select") { + return ( + <> +
+ +
+ {grip} + + ); + } + + if (field.type?.kind === "boolean") { + return ( + <> + + {grip} + + ); + } + + const kind = field.type?.kind ?? "text"; + const isNumber = kind === "number"; + + return ( + <> + { + typedRef.current = true; + setDraft(e.target.value); + }} + onFocus={(e) => { + onFocusCell(); + try { + e.currentTarget.select(); + } catch { + // Safari throws on select() for some input types; harmless. + } + }} + onBlur={() => commit(draft)} + onKeyDown={(e) => { + if (e.altKey && isArrowKey(e.key)) { + e.preventDefault(); + onArrowFromEditor(e.key, e.shiftKey); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commit(draft); + onNavigate(e.shiftKey ? "enter-up" : "enter-down"); + return; + } + if (e.key === "Tab") { + commit(draft); + if (onNavigate(e.shiftKey ? "shift-tab" : "tab")) e.preventDefault(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + typedRef.current = false; + setDraft(toEditorValue(rawValue)); + } + }} + className={cn( + editorClasses, + alignClasses[align], + isNumber && numberEditorClasses, + typeof field.className === "function" ? field.className(row) : field.className, + )} + /> + {grip} + + ); +} + +export const LineItemsCell = React.memo(LineItemsCellInner) as typeof LineItemsCellInner; diff --git a/packages/core/src/components/line-items/line-items-context.tsx b/packages/core/src/components/line-items/line-items-context.tsx new file mode 100644 index 000000000..127c00321 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-context.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +import type { LineItemsRowData, UseLineItemsReturn } from "./types"; + +/** Cell contents for the totals row, keyed by column id. */ +export type LineItemsTotalsRenderer = ( + lines: LineItemsRowData[], +) => Record; + +export type LineItemsContextValue = { + lineItems: UseLineItemsReturn; + fullscreen: boolean; + setFullscreen: (next: boolean) => void; + /** + * Registered by `` so the table can render it inside its + * own ``, where the cells stay aligned with the columns above. + */ + totalsRenderer: LineItemsTotalsRenderer | undefined; + setTotalsRenderer: (renderer: LineItemsTotalsRenderer | undefined) => void; +}; + +const LineItemsContext = React.createContext(null); + +export const LineItemsContextProvider = LineItemsContext.Provider; + +/** + * Reads the enclosing `` value. + * + * Grid interaction state (active cell, selected range, drag) deliberately does + * not live here — it stays local to `` and reaches cells as + * props, so a pointer drag across the grid cannot re-render every sibling part. + */ +export function useLineItemsContext( + part: string, +): LineItemsContextValue { + const ctx = React.useContext(LineItemsContext); + if (!ctx) { + throw new Error(` must be used within `); + } + return ctx as LineItemsContextValue; +} diff --git a/packages/core/src/components/line-items/line-items-parts.tsx b/packages/core/src/components/line-items/line-items-parts.tsx new file mode 100644 index 000000000..d0ad8ff16 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-parts.tsx @@ -0,0 +1,465 @@ +import * as React from "react"; +import { Maximize2, Minimize2, Search as SearchIcon } from "lucide-react"; + +import { useBeforeUnload, useBlocker } from "react-router"; + +import { Button } from "@/components/button"; +import { cn } from "@/lib/utils"; +import { useLineItemsT } from "./i18n"; +import { useLineItemsContext, type LineItemsTotalsRenderer } from "./line-items-context"; +import type { LineItemsChangeSet, LineItemsRowData } from "./types"; + +/* ------------------------------------------------------------------ Search */ + +export type LineItemsSearchProps = { + placeholder?: string; + /** Set `false` to drop the leading magnifier. */ + showIcon?: boolean; + className?: string; +}; + +const searchInputClasses = + "astw:border-input astw:h-9 astw:w-full astw:rounded-md astw:border astw:bg-transparent astw:text-sm astw:shadow-xs astw:outline-none astw:placeholder:text-muted-foreground astw:focus-visible:border-ring astw:focus-visible:ring-[3px] astw:focus-visible:ring-ring/50"; + +/** Always-visible search box bound to the filter. */ +function LineItemsSearch({ placeholder, showIcon = true, className }: LineItemsSearchProps) { + const { lineItems } = useLineItemsContext("Search"); + const t = useLineItemsT(); + return ( +
+ {showIcon ? ( +
+ ); +} +LineItemsSearch.displayName = "LineItems.Search"; + +export type LineItemsSearchToggleProps = { + placeholder?: string; + expandedWidth?: number; + variant?: React.ComponentProps["variant"]; + className?: string; +}; + +/** + * Icon button that expands into a search input, then collapses again on blur + * when empty. Fits a card header where the full-width `Search` box does not. + */ +function LineItemsSearchToggle({ + placeholder: placeholderProp, + expandedWidth = 240, + variant = "outline", + className, +}: LineItemsSearchToggleProps) { + const { lineItems } = useLineItemsContext("SearchToggle"); + const t = useLineItemsT(); + const placeholder = placeholderProp ?? t("searchPlaceholder"); + const [expanded, setExpanded] = React.useState(false); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (expanded) inputRef.current?.focus(); + }, [expanded]); + + if (!expanded) { + return ( + + ); + } + + return ( +
+
+ ); +} +LineItemsSearchToggle.displayName = "LineItems.SearchToggle"; + +/* ----------------------------------------------------------------- Add row */ + +export type LineItemsAddRowProps = { + /** Host content — typically a catalogue combobox plus a bulk-add button. */ + children?: React.ReactNode; + className?: string; +}; + +/** + * Slot pinned below the table for adding lines. Give it whatever the document + * needs — a searchable product combobox, a bulk-add button — and drive + * `addLine`/`addLines` from it. Renders nothing in view mode. + */ +function LineItemsAddRow({ children, className }: LineItemsAddRowProps) { + const { lineItems } = useLineItemsContext("AddRow"); + if (lineItems.mode === "view") return null; + + return ( +
+ {children} +
+ ); +} +LineItemsAddRow.displayName = "LineItems.AddRow"; + +/* ------------------------------------------------------- Fullscreen toggle */ + +export type LineItemsFullscreenToggleProps = { + variant?: React.ComponentProps["variant"]; + className?: string; +}; + +/** Expands the hosting card to a fullscreen overlay, and back. */ +function LineItemsFullscreenToggle({ + variant = "outline", + className, +}: LineItemsFullscreenToggleProps) { + const { fullscreen, setFullscreen } = useLineItemsContext("FullscreenToggle"); + const t = useLineItemsT(); + const Icon = fullscreen ? Minimize2 : Maximize2; + + return ( + + ); +} +LineItemsFullscreenToggle.displayName = "LineItems.FullscreenToggle"; + +/* ------------------------------------------------------------ Save actions */ + +export type LineItemsSaveActionsProps = { + onSave: (changeSet: LineItemsChangeSet) => void; + /** Defaults to reverting to the baseline. */ + onDiscard?: () => void; + saveLabel?: React.ReactNode; + discardLabel?: React.ReactNode; + saving?: boolean; + className?: string; +}; + +function LineItemsSaveActions({ + onSave, + onDiscard, + saveLabel, + discardLabel, + saving = false, + className, +}: LineItemsSaveActionsProps) { + const { lineItems } = useLineItemsContext("SaveActions"); + const t = useLineItemsT(); + if (lineItems.mode === "view") return null; + + return ( +
+ + +
+ ); +} +LineItemsSaveActions.displayName = "LineItems.SaveActions"; + +/* -------------------------------------------------------------- Totals row */ + +export type LineItemsTotalsRowProps = { + /** Cell contents keyed by column id. Unlisted columns render empty. */ + children: (lines: T[]) => Record; +}; + +/** + * Registers a totals row with the table, which renders it in its own `` + * so the cells inherit each column's width, alignment and pinning. Renders no + * DOM of its own — place it anywhere inside ``. + */ +function LineItemsTotalsRow({ + children, +}: LineItemsTotalsRowProps) { + const { setTotalsRenderer } = useLineItemsContext("TotalsRow"); + + // Held in a ref so an inline arrow does not re-register every render. + const rendererRef = React.useRef(children); + rendererRef.current = children; + + React.useEffect(() => { + const renderer: LineItemsTotalsRenderer = (lines) => + rendererRef.current(lines as unknown as T[]); + setTotalsRenderer(renderer); + return () => setTotalsRenderer(undefined); + }, [setTotalsRenderer]); + + return null; +} +LineItemsTotalsRow.displayName = "LineItems.TotalsRow"; + +/* ------------------------------------------------------------ Floating dock */ + +export type LineItemsFloatingDockProps = { + children?: React.ReactNode; + className?: string; +}; + +/** + * Fixed bottom-center of the viewport: above page content, but one step below + * the overlay and popup layer, so a confirm dialog or dropdown opened from the + * page is never covered by the bar. + */ +function LineItemsFloatingDock({ children, className }: LineItemsFloatingDockProps) { + return ( +
+ {children} +
+ ); +} +LineItemsFloatingDock.displayName = "LineItems.FloatingDock"; + +const pillClasses = + "astw:pointer-events-auto astw:flex astw:items-center astw:gap-3 astw:rounded-lg astw:bg-foreground astw:px-3 astw:py-2 astw:text-xs astw:text-background astw:shadow-lg"; + +const pillButtonClasses = + "astw:rounded-md astw:px-2.5 astw:py-1 astw:text-xs astw:font-medium astw:outline-none astw:focus-visible:ring-[3px] astw:focus-visible:ring-ring/50"; + +/** A short side-to-side shake, played with the Web Animations API. */ +const SHAKE_KEYFRAMES: Keyframe[] = [ + { transform: "translateX(0)" }, + { transform: "translateX(-8px)", offset: 0.15 }, + { transform: "translateX(8px)", offset: 0.3 }, + { transform: "translateX(-6px)", offset: 0.45 }, + { transform: "translateX(6px)", offset: 0.6 }, + { transform: "translateX(-3px)", offset: 0.75 }, + { transform: "translateX(0)" }, +]; + +/** + * Holds the user on the page while the document has unsaved edits. Mounted by + * DirtyBar only while dirty and `warnOnNav` is set, so a table without the + * guard never needs a router. React Router's blocker covers every in-app + * navigation — links, `navigate()`, back/forward — and needs the data router + * AppShell provides; the browser's own prompt covers closing or reloading. + */ +function LineItemsNavGuard({ onBlocked }: { onBlocked: () => void }) { + // Only leaving the page counts: a search-param update on the same route is + // not navigation away from the document. + const blocker = useBlocker( + ({ currentLocation, nextLocation }) => currentLocation.pathname !== nextLocation.pathname, + ); + const onBlockedRef = React.useRef(onBlocked); + onBlockedRef.current = onBlocked; + + React.useEffect(() => { + if (blocker.state !== "blocked") return; + // Stay put: the user resolves it with Save or Discard, then leaves. + blocker.reset(); + onBlockedRef.current(); + }, [blocker]); + + useBeforeUnload( + React.useCallback((event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + }, []), + ); + + return null; +} + +export type LineItemsDirtyBarProps = { + onSave?: (changeSet: LineItemsChangeSet) => void; + onDiscard?: () => void; + label?: (pendingCount: number) => React.ReactNode; + saveLabel?: React.ReactNode; + discardLabel?: React.ReactNode; + /** + * While the document is dirty, keeps the user on the page: any in-app + * navigation to another route (links, `navigate()`, back/forward) is held and + * the bar shakes and takes focus instead; closing or reloading the tab shows + * the browser's prompt. Uses React Router's `useBlocker`, so it needs + * AppShell's router. + */ + warnOnNav?: boolean; + children?: (pendingCount: number) => React.ReactNode; + className?: string; +}; + +/** Appears while there are uncommitted changes. Renders nothing when clean. */ +function LineItemsDirtyBar({ + onSave, + onDiscard, + label, + saveLabel, + discardLabel, + warnOnNav = false, + children, + className, +}: LineItemsDirtyBarProps) { + const { lineItems } = useLineItemsContext("DirtyBar"); + const t = useLineItemsT(); + const barRef = React.useRef(null); + const [blockedNotice, setBlockedNotice] = React.useState(""); + const isDirty = lineItems.isDirty; + + const blockedText = t("unsavedNavBlocked"); + const blockedTextRef = React.useRef(blockedText); + blockedTextRef.current = blockedText; + + /** Shakes the bar, announces why, and hands it focus so it can be resolved. */ + const signalBlocked = React.useCallback(() => { + const bar = barRef.current; + if (!bar) return; + if (!globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + bar.animate(SHAKE_KEYFRAMES, { + duration: 500, + easing: "cubic-bezier(0.36, 0.07, 0.19, 0.97)", + }); + } + // Toggle through "" so repeating the same notice is announced again. + setBlockedNotice(""); + requestAnimationFrame(() => setBlockedNotice(blockedTextRef.current)); + // Save, never Discard: a reflexive second Enter must not throw the edits + // away. Without a Save button, the bar itself takes focus. + (bar.querySelector("[data-action=save]") ?? bar).focus(); + }, []); + + if (!isDirty) return null; + + const pending = lineItems.getChangeSet().lineChanges.length; + + return ( +
+ {/* is a status region: the count is announced as it appears and changes. */} + + {label ? label(pending) : t("unsavedChanges", { count: pending })} + + + {blockedNotice} + + {warnOnNav ? : null} + {children ? ( + children(pending) + ) : ( + <> +
+ ); +} +LineItemsDirtyBar.displayName = "LineItems.DirtyBar"; + +export { + LineItemsAddRow, + LineItemsDirtyBar, + LineItemsFloatingDock, + LineItemsFullscreenToggle, + LineItemsSaveActions, + LineItemsSearch, + LineItemsSearchToggle, + LineItemsTotalsRow, +}; diff --git a/packages/core/src/components/line-items/line-items-skeleton-row.tsx b/packages/core/src/components/line-items/line-items-skeleton-row.tsx new file mode 100644 index 000000000..354be4ea9 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-skeleton-row.tsx @@ -0,0 +1,37 @@ +import { cn } from "@/lib/utils"; + +export type LineItemsSkeletonRowProps = { + /** Seeds the per-cell bar width so stacked rows do not align into columns. */ + rowIndex: number; + columnCount: number; + rowHeight: number; +}; + +/** One placeholder row shown while an initial fetch is in flight. */ +export function LineItemsSkeletonRow({ + rowIndex, + columnCount, + rowHeight, +}: LineItemsSkeletonRowProps) { + return ( + + {Array.from({ length: columnCount }, (_, col) => ( + + + + ))} + + ); +} + +LineItemsSkeletonRow.displayName = "LineItems.SkeletonRow"; diff --git a/packages/core/src/components/line-items/line-items-table.tsx b/packages/core/src/components/line-items/line-items-table.tsx new file mode 100644 index 000000000..e6097014b --- /dev/null +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -0,0 +1,1168 @@ +import * as React from "react"; +import { ChevronDown, ChevronsUpDown, ChevronUp, GripVertical } from "lucide-react"; + +import { Checkbox } from "@/components/checkbox"; +import { + SelectionActionsAnnouncer, + SelectionActionsBar, + selectionBarSurfaceClasses, + selectionBarTokenClasses, +} from "@/components/internals/selection-actions/selection-actions-bar"; +import { cn } from "@/lib/utils"; +import { + ACTIONS_COLUMN_ID, + ACTIONS_COLUMN_WIDTH, + computeColumnLayout, + GRIP_COLUMN_ID, + GRIP_COLUMN_WIDTH, + pinStyle, + SELECTION_COLUMN_ID, + SELECTION_COLUMN_WIDTH, + SPACER_COLUMN_ID, + type ColumnWidths, + type PinPlacement, +} from "./column-layout"; +import { + fieldAllowsFill, + fieldIsAmendLocked, + fieldIsEditableInMode, + fieldsToColumnDefs, +} from "./field"; +import { useLineItemsT } from "./i18n"; +import { normalizeField } from "./internals"; +import { + LineItemsCell, + type ArrowKey, + type CellNavigation, + type LineItemsCellHandlers, +} from "./line-items-cell"; +import { useLineItemsContext } from "./line-items-context"; +import { LineItemsSkeletonRow } from "./line-items-skeleton-row"; +import { + coordsToRowsMatrix, + parseClipboardTsv, + serializeMatrixTsv, + type GridCoord, +} from "./spreadsheet-logic"; +import { coordKey, useGridSelection } from "./use-grid-selection"; +import { useRowVirtualizer } from "./use-row-virtualizer"; +import type { LineItemsRowData } from "./types"; + +export type LineItemsTableProps = { + /** Uniform row height in pixels, which the virtualizer's math depends on. */ + rowHeight?: number; + /** Height of the scroll viewport. Ignored in fullscreen, which fills the overlay. */ + maxBodyHeight?: React.CSSProperties["maxHeight"]; + /** Set `false` to mount every row (short documents, print, tests). */ + virtualized?: boolean; + /** Trailing pinned column of per-row controls. */ + renderRowActions?: (row: T) => React.ReactNode; + rowActionsWidth?: number; + emptyMessage?: React.ReactNode; + /** Swaps the body for skeleton rows while an initial fetch is in flight. */ + loading?: boolean; + skeletonRowCount?: number; + className?: string; + tableContainerClassName?: string; +}; + +const headCellClasses = + "astw:h-10 astw:bg-card astw:px-2 astw:text-left astw:align-middle astw:text-xs astw:font-medium astw:text-muted-foreground astw:whitespace-nowrap astw:overflow-hidden astw:text-ellipsis astw:border-b astw:border-r astw:border-border"; + +const bodyCellClasses = + "astw:relative astw:h-9 astw:p-0 astw:align-middle astw:text-sm astw:whitespace-nowrap astw:overflow-hidden astw:text-ellipsis astw:border-b astw:border-r astw:border-border"; + +const alignClasses = { + left: "astw:text-left", + center: "astw:text-center", + right: "astw:text-right", +} as const; + +const ariaSortValues = { asc: "ascending", desc: "descending" } as const; +const sortIcons = { asc: ChevronUp, desc: ChevronDown } as const; + +/** + * Pinned cells stay opaque so scrolled content never shows through them, which + * means they cannot inherit the row's background — they have to repaint it, or + * a selected row goes un-tinted under the frozen columns. + */ +function pinClasses( + placement: PinPlacement | undefined, + opts?: { selected?: boolean }, +): string | undefined { + if (!placement) return undefined; + return cn( + "astw:z-[1] astw:transition-colors", + opts?.selected + ? "astw:bg-muted" + : // Opaque, so scrolled cells never show through; on a hovered body row + // it takes the row's hover tint (muted at 50% over the card), which the + // see-through cells get from the row itself. + "astw:bg-card astw:group-hover/row:bg-[color-mix(in_srgb,var(--muted)_50%,var(--card))]", + placement.isBoundary && + (placement.side === "left" + ? "astw:shadow-[inset_-1px_0_0_0_var(--border)]" + : "astw:shadow-[inset_1px_0_0_0_var(--border)]"), + ); +} + +/** A flex column takes whatever width is left, so it gets no fixed width. */ +function colWidthStyle(c: { flex?: boolean; width?: number }): React.CSSProperties | undefined { + return c.flex ? undefined : { width: c.width }; +} + +/** + * The editable grid. + * + * Rows are windowed by a hand-rolled virtualizer (spacer rows above and below + * stand in for unmounted ones), so a thousand-line document mounts only the + * visible slice. Every editable cell in that slice mounts its own editor, which + * is what makes the grid read as a spreadsheet at rest. + */ +export function LineItemsTable({ + rowHeight = 36, + maxBodyHeight = "min(70vh, 600px)", + virtualized = true, + renderRowActions, + rowActionsWidth = ACTIONS_COLUMN_WIDTH, + emptyMessage, + loading = false, + skeletonRowCount = 12, + className, + tableContainerClassName, +}: LineItemsTableProps) { + const { lineItems, fullscreen, totalsRenderer } = useLineItemsContext("Table"); + const t = useLineItemsT(); + const { lines, fields, mode, ordering, selectionEnabled, selectedIds, sortState } = lineItems; + + const scrollRef = React.useRef(null); + const hasTotals = Boolean(totalsRenderer); + + // Whether lines are hidden above the sticky header or beneath the sticky + // totals row. While they are, that edge casts a shadow over the scrolling + // lines; at the start or end of the list it sits flush. + const [moreAbove, setMoreAbove] = React.useState(false); + const [moreBelow, setMoreBelow] = React.useState(false); + React.useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const check = () => { + setMoreAbove(el.scrollTop > 0); + setMoreBelow(el.scrollTop + el.clientHeight < el.scrollHeight - 1); + }; + check(); + el.addEventListener("scroll", check, { passive: true }); + const observer = new ResizeObserver(check); + observer.observe(el); + return () => { + el.removeEventListener("scroll", check); + observer.disconnect(); + }; + }, [lines.length, loading, fullscreen]); + const [widths, setWidths] = React.useState({}); + + const canReorder = ordering === "manual" && mode === "edit"; + const showSelection = selectionEnabled && mode !== "view"; + + const layout = React.useMemo( + () => + computeColumnLayout(fields, { + widths, + hasGrip: canReorder, + hasSelection: showSelection, + hasActions: Boolean(renderRowActions), + actionsWidth: rowActionsWidth, + }), + [canReorder, fields, renderRowActions, rowActionsWidth, showSelection, widths], + ); + + const columnDefs = React.useMemo(() => fieldsToColumnDefs(fields), [fields]); + const orderedLineRefs = React.useMemo(() => lines.map((l) => l.lineRef), [lines]); + + /** + * Every schema column takes part in selection and copy, so a computed column + * can be read out; paste and fill still refuse to write read-only ones. + */ + const gridColumnIds = React.useMemo(() => layout.columns.map((c) => c.id), [layout.columns]); + + const grid = useGridSelection(orderedLineRefs, gridColumnIds); + + const virtualWindow = useRowVirtualizer(scrollRef, { + rowCount: lines.length, + rowHeight, + enabled: virtualized && !loading, + }); + + const visibleLines = lines.slice(virtualWindow.startIndex, virtualWindow.endIndex); + const headRefs = React.useRef(new Map()); + + /** + * Measures rendered header widths so pinned columns stack without every one + * declaring a `width`. + */ + React.useEffect(() => { + const measured: ColumnWidths = {}; + let changed = false; + for (const [id, el] of headRefs.current) { + if (!el) continue; + const w = Math.round(el.getBoundingClientRect().width); + if (w <= 0) continue; + measured[id] = w; + if (widths[id] !== w) changed = true; + } + if (changed) setWidths((prev) => ({ ...prev, ...measured })); + }, [layout.columns, lines.length, widths]); + + const lineByRef = React.useMemo(() => { + const map = new Map(); + for (const l of lines) map.set(l.lineRef, l); + return map; + }, [lines]); + + const fieldById = React.useMemo(() => { + const map = new Map(); + for (const f of fields) map.set(f.key, f); + return map; + }, [fields]); + + const displayValueOf = React.useCallback( + (lineRef: string, columnId: string): string => { + const row = lineByRef.get(lineRef); + if (!row) return ""; + const raw = (row as Record)[columnId]; + return raw === null || raw === undefined ? "" : String(raw); + }, + [lineByRef], + ); + + const commitCell = React.useCallback( + (coord: GridCoord, value: unknown) => { + // Looked up across the whole document, not just the visible lines: a + // cell filtered out mid-edit still commits its typing as it unmounts. + const row = lineItems.allLines.find((l) => l.lineRef === coord.lineRef); + if (!row) return; + const normalized = normalizeField(columnDefs, coord.columnId, value, row); + lineItems.updateLines([ + { lineRef: coord.lineRef, patch: { [coord.columnId]: normalized } as Partial }, + ]); + }, + [columnDefs, lineItems], + ); + + /** Keeps the active cell in view without stealing focus or animating. */ + const scrollActiveIntoView = React.useCallback( + (coord: GridCoord) => { + const container = scrollRef.current; + if (!container) return; + const index = orderedLineRefs.indexOf(coord.lineRef); + if (index < 0) return; + // The sticky header and totals row overlay the scroll area, so the row + // must clear them, not just the container's edges. + const headerHeight = container.querySelector("thead")?.offsetHeight ?? 0; + const footerHeight = container.querySelector("tfoot")?.offsetHeight ?? 0; + const top = headerHeight + index * rowHeight; + const bottom = top + rowHeight; + if (top - headerHeight < container.scrollTop) container.scrollTop = top - headerHeight; + else if (bottom > container.scrollTop + container.clientHeight - footerHeight) { + container.scrollTop = bottom - container.clientHeight + footerHeight; + } + }, + [orderedLineRefs, rowHeight], + ); + + /** + * Moves DOM focus to a cell's editor, so navigation lands in the next input. + * A row just scrolled into view mounts a frame or two later, so retry briefly. + */ + const focusCellEditor = React.useCallback((coord: GridCoord, attempts = 3) => { + const container = scrollRef.current; + if (!container) return; + const cell = container.querySelector( + `[data-line-ref="${CSS.escape(coord.lineRef)}"][data-column-id="${CSS.escape(coord.columnId)}"]`, + ); + // Built-in editors are tagged; a custom editor is whatever it renders, so + // fall back to the cell's first focusable element. + const editor = + cell?.querySelector("[data-slot=line-items-cell-editor]") ?? + cell?.querySelector( + 'input:not([disabled]),select:not([disabled]),textarea:not([disabled]),button:not([disabled]),[tabindex]:not([tabindex="-1"])', + ); + if (editor) editor.focus(); + else if (attempts > 0) requestAnimationFrame(() => focusCellEditor(coord, attempts - 1)); + }, []); + + const moveActive = React.useCallback( + (key: ArrowKey, shift?: boolean) => { + const next = grid.moveActive(key, { shift }); + if (!next) return; + scrollActiveIntoView(next); + if (!shift) focusCellEditor(next); + }, + [focusCellEditor, grid, scrollActiveIntoView], + ); + + /** Columns with an editor in the current mode — the only ones Tab can land on. */ + const editableColumnIds = React.useMemo( + () => layout.columns.filter((c) => fieldIsEditableInMode(c.field, mode)).map((c) => c.id), + [layout.columns, mode], + ); + + /** + * Enter moves within the column. Tab steps across editable cells only — a + * read-only cell has no editor to land on — and wraps at the row edges, so + * tabbing straight through a document never dead-ends. Past the grid's last + * editable cell (or before its first, with Shift) it returns `false` and the + * browser's own Tab takes focus out of the grid. + */ + const navigateFromEdit = React.useCallback( + (to: CellNavigation, from: GridCoord): boolean => { + const rowIndex = orderedLineRefs.indexOf(from.lineRef); + if (rowIndex < 0) return false; + + let nextRow = rowIndex; + let columnId: string | undefined = from.columnId; + + if (to === "enter-down") nextRow = Math.min(orderedLineRefs.length - 1, rowIndex + 1); + else if (to === "enter-up") nextRow = Math.max(0, rowIndex - 1); + else { + const cols = editableColumnIds; + const colIndex = cols.indexOf(from.columnId); + if (colIndex < 0 || cols.length === 0) return false; + if (to === "tab") { + if (colIndex === cols.length - 1) { + if (rowIndex === orderedLineRefs.length - 1) return false; + nextRow = rowIndex + 1; + columnId = cols[0]; + } else columnId = cols[colIndex + 1]; + } else if (colIndex === 0) { + if (rowIndex === 0) return false; + nextRow = rowIndex - 1; + columnId = cols[cols.length - 1]; + } else columnId = cols[colIndex - 1]; + } + + const lineRef = orderedLineRefs[nextRow]; + if (!lineRef || !columnId) return false; + + const coord = { lineRef, columnId }; + grid.setActive(coord); + scrollActiveIntoView(coord); + focusCellEditor(coord); + return true; + }, + [editableColumnIds, focusCellEditor, grid, orderedLineRefs, scrollActiveIntoView], + ); + + /* ---- Fill gesture ----------------------------------------------------- */ + + const [fillSource, setFillSource] = React.useState(null); + const [fillHoverLineRef, setFillHoverLineRef] = React.useState(null); + + const isInFillPreview = React.useCallback( + (coord: GridCoord): boolean => { + if (!fillSource || !fillHoverLineRef) return false; + if (coord.columnId !== fillSource.columnId) return false; + const a = orderedLineRefs.indexOf(fillSource.lineRef); + const b = orderedLineRefs.indexOf(fillHoverLineRef); + const i = orderedLineRefs.indexOf(coord.lineRef); + if (a < 0 || b < 0 || i < 0) return false; + return i >= Math.min(a, b) && i <= Math.max(a, b); + }, + [fillHoverLineRef, fillSource, orderedLineRefs], + ); + + /** + * Tracks the pointer against rendered cells while a fill is in progress and + * broadcasts the source value across the painted range on release. + */ + React.useEffect(() => { + if (!fillSource) return; + + const onMove = (e: PointerEvent) => { + const el = document.elementFromPoint(e.clientX, e.clientY); + const cell = el?.closest("[data-slot=line-items-grid-cell]"); + const lineRef = cell?.dataset.lineRef; + if (lineRef) setFillHoverLineRef(lineRef); + }; + + const onUp = () => { + const targetRef = fillHoverLineRef; + const source = fillSource; + setFillSource(null); + setFillHoverLineRef(null); + if (!targetRef || targetRef === source.lineRef) return; + + const field = fieldById.get(source.columnId); + const sourceRow = lineByRef.get(source.lineRef); + if (!field || !sourceRow || !fieldAllowsFill(field, mode)) return; + + const a = orderedLineRefs.indexOf(source.lineRef); + const b = orderedLineRefs.indexOf(targetRef); + if (a < 0 || b < 0) return; + + const value = (sourceRow as Record)[source.columnId]; + const patches: { lineRef: string; patch: Partial }[] = []; + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) { + const ref = orderedLineRefs[i]; + if (!ref || ref === source.lineRef) continue; + const row = lineByRef.get(ref); + if (!row) continue; + patches.push({ + lineRef: ref, + patch: { + [source.columnId]: normalizeField(columnDefs, source.columnId, value, row), + } as Partial, + }); + } + if (patches.length > 0) lineItems.updateLines(patches); + }; + + // A cancelled pointer (a touch turned into a scroll, a lost window) drops + // the fill without writing anything. + const onCancel = () => { + setFillSource(null); + setFillHoverLineRef(null); + }; + + globalThis.addEventListener("pointermove", onMove); + globalThis.addEventListener("pointerup", onUp); + globalThis.addEventListener("pointercancel", onCancel); + return () => { + globalThis.removeEventListener("pointermove", onMove); + globalThis.removeEventListener("pointerup", onUp); + globalThis.removeEventListener("pointercancel", onCancel); + }; + }, [ + columnDefs, + fieldById, + fillHoverLineRef, + fillSource, + lineByRef, + lineItems, + mode, + orderedLineRefs, + ]); + + /* ---- Clipboard -------------------------------------------------------- */ + + const handleCopy = React.useCallback( + (event: React.ClipboardEvent) => { + if (grid.selected.size <= 1) return; + const coords: GridCoord[] = []; + for (const lineRef of orderedLineRefs) { + for (const columnId of gridColumnIds) { + if (grid.selected.has(coordKey(lineRef, columnId))) coords.push({ lineRef, columnId }); + } + } + if (coords.length === 0) return; + const matrix = coordsToRowsMatrix(orderedLineRefs, gridColumnIds, coords, displayValueOf); + if (matrix.length === 0) return; + event.preventDefault(); + event.clipboardData.setData("text/plain", serializeMatrixTsv(matrix)); + }, + [displayValueOf, grid.selected, gridColumnIds, orderedLineRefs], + ); + + /** + * Pastes a TSV block anchored at the active cell. A single copied value is + * broadcast across the whole selection (Excel's behavior); a larger block is + * written as a rectangle. Read-only columns in range are skipped. + */ + const handlePaste = React.useCallback( + (event: React.ClipboardEvent) => { + if (!grid.active || mode === "view") return; + const text = event.clipboardData.getData("text/plain"); + const matrix = parseClipboardTsv(text); + if (matrix.length === 0) return; + event.preventDefault(); + + const patches = new Map>(); + const put = (lineRef: string, columnId: string, raw: string) => { + const field = fieldById.get(columnId); + const row = lineByRef.get(lineRef); + if (!field || !row || !fieldIsEditableInMode(field, mode)) return; + const patch = patches.get(lineRef) ?? {}; + patch[columnId] = normalizeField(columnDefs, columnId, raw, row); + patches.set(lineRef, patch); + }; + + const single = matrix.length === 1 && matrix[0]?.length === 1; + if (single && grid.selected.size > 1) { + const raw = matrix[0]![0]!; + for (const lineRef of orderedLineRefs) { + for (const columnId of gridColumnIds) { + if (grid.selected.has(coordKey(lineRef, columnId))) put(lineRef, columnId, raw); + } + } + } else { + const startRow = orderedLineRefs.indexOf(grid.active.lineRef); + const startCol = gridColumnIds.indexOf(grid.active.columnId); + if (startRow < 0 || startCol < 0) return; + matrix.forEach((cells, r) => { + const lineRef = orderedLineRefs[startRow + r]; + if (!lineRef) return; + cells.forEach((raw, c) => { + const columnId = gridColumnIds[startCol + c]; + if (columnId) put(lineRef, columnId, raw); + }); + }); + } + + if (patches.size === 0) return; + lineItems.updateLines( + [...patches].map(([lineRef, patch]) => ({ lineRef, patch: patch as Partial })), + ); + }, + [ + columnDefs, + fieldById, + grid.active, + grid.selected, + gridColumnIds, + lineByRef, + lineItems, + mode, + orderedLineRefs, + ], + ); + + /** + * Fill down (Cmd/Ctrl+D, as in spreadsheets): the keyboard equivalent of + * dragging the fill handle. In each selected column, the top selected cell's + * value is copied into the selected cells below it. Returns whether anything + * was filled, so an unhandled press keeps the browser's own shortcut. + */ + const fillDown = React.useCallback((): boolean => { + if (grid.selected.size < 2) return false; + const patches = new Map>(); + for (const columnId of gridColumnIds) { + const field = fieldById.get(columnId); + if (!field || !fieldAllowsFill(field, mode)) continue; + let source: unknown; + let haveSource = false; + for (const lineRef of orderedLineRefs) { + if (!grid.selected.has(coordKey(lineRef, columnId))) continue; + const row = lineByRef.get(lineRef); + if (!row) continue; + if (!haveSource) { + source = (row as Record)[columnId]; + haveSource = true; + continue; + } + const patch = patches.get(lineRef) ?? {}; + patch[columnId] = normalizeField(columnDefs, columnId, source, row); + patches.set(lineRef, patch); + } + } + if (patches.size === 0) return false; + lineItems.updateLines( + [...patches].map(([lineRef, patch]) => ({ lineRef, patch: patch as Partial })), + ); + return true; + }, [ + columnDefs, + fieldById, + grid.selected, + gridColumnIds, + lineByRef, + lineItems, + mode, + orderedLineRefs, + ]); + + const handleGridKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if ( + (event.ctrlKey || event.metaKey) && + !event.altKey && + !event.shiftKey && + event.key.toLowerCase() === "d" + ) { + if (fillDown()) event.preventDefault(); + return; + } + if (event.altKey || event.ctrlKey || event.metaKey) return; + const target = event.target as HTMLElement; + // An open editor owns its own keys; it forwards navigation explicitly. + if (target.dataset?.slot === "line-items-cell-editor") return; + if (!grid.active) return; + + const key = event.key; + if (key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight") { + event.preventDefault(); + moveActive(key, event.shiftKey); + return; + } + if (key === "Escape") grid.clearSelection(); + }, + [fillDown, grid, moveActive], + ); + + /* ---- Row reordering --------------------------------------------------- */ + + const [dragRef, setDragRef] = React.useState(null); + const [dropBefore, setDropBefore] = React.useState(null); + const [liftedRef, setLiftedRef] = React.useState(null); + /** Announces keyboard reorders, which have no other non-visual feedback. */ + const [reorderNotice, setReorderNotice] = React.useState(""); + + const moveLine = React.useCallback( + (lineRef: string, beforeLineRef: string | null) => { + const others = orderedLineRefs.filter((id) => id !== lineRef); + const at = beforeLineRef ? others.indexOf(beforeLineRef) : others.length; + lineItems.reorderLine(lineRef, at <= 0 ? null : (others[at - 1] ?? null)); + }, + [lineItems, orderedLineRefs], + ); + + const endDrag = React.useCallback(() => { + setDragRef(null); + setDropBefore(null); + }, []); + + const nudge = React.useCallback( + (lineRef: string, delta: -1 | 1) => { + const index = orderedLineRefs.indexOf(lineRef); + const target = index + delta; + if (index < 0 || target < 0 || target >= orderedLineRefs.length) return; + const others = orderedLineRefs.filter((id) => id !== lineRef); + lineItems.reorderLine(lineRef, target === 0 ? null : (others[target - 1] ?? null)); + setReorderNotice(t("lineMoved", { line: index + 1, position: target + 1 })); + }, + [lineItems, orderedLineRefs, t], + ); + + const utilityColumnCount = + (showSelection ? 1 : 0) + (canReorder ? 1 : 0) + (renderRowActions ? 1 : 0); + const columnCount = layout.columns.length + utilityColumnCount; + // A Set, so per-row checks stay O(1) on long documents. + const selectedRefSet = React.useMemo(() => new Set(selectedIds), [selectedIds]); + const allVisibleSelected = lines.length > 0 && lines.every((l) => selectedRefSet.has(l.lineRef)); + + // One stable handlers object for every cell, reading the latest grid logic + // through a ref, so memoized cells skip re-rendering on unrelated changes. + const latestCellLogic = React.useRef({ + commitCell, + setActive: grid.setActive, + navigateFromEdit, + moveActive, + }); + latestCellLogic.current = { commitCell, setActive: grid.setActive, navigateFromEdit, moveActive }; + const cellHandlers = React.useMemo( + () => ({ + commit: (coord, value) => latestCellLogic.current.commitCell(coord, value), + focus: (coord) => latestCellLogic.current.setActive(coord), + navigate: (coord, to) => latestCellLogic.current.navigateFromEdit(to, coord), + arrow: (key, shift) => latestCellLogic.current.moveActive(key, shift), + fillGripDown: (coord, event) => { + event.preventDefault(); + event.stopPropagation(); + setFillSource(coord); + setFillHoverLineRef(coord.lineRef); + }, + }), + [], + ); + + const offersBulkActions = lineItems.selectionEnabled && lineItems.selectionActions.length > 0; + const selectionBarOpen = offersBulkActions && lineItems.selectedIds.length > 0; + + return ( +
+
+ + + {showSelection ? : null} + {canReorder ? : null} + {layout.columns.map((c) => ( + + ))} + {renderRowActions ? : null} + {layout.needsSpacer ? : null} + + + + + {showSelection ? ( + + ) : null} + + {canReorder ? ( + + ) : null} + + {layout.columns.map((c) => { + const sortable = Boolean(c.field.sort) && ordering === "sort"; + const sorted = sortState?.columnId === c.id ? sortState.direction : undefined; + const SortIcon = sorted ? sortIcons[sorted] : ChevronsUpDown; + const sortedState = sorted ? ariaSortValues[sorted] : "none"; + + return ( + + ); + })} + + {renderRowActions ? ( + + ) : null} + + {layout.needsSpacer ? ( + + + + + {loading + ? Array.from({ length: skeletonRowCount }, (_, i) => ( + + )) + : null} + + {!loading && lines.length === 0 ? ( + + + + ) : null} + + {!loading && virtualWindow.paddingTop > 0 ? ( + + ) : null} + + {!loading && + visibleLines.map((row, i) => { + const rowIndex = virtualWindow.startIndex + i; + const isDragging = dragRef === row.lineRef; + const isLifted = liftedRef === row.lineRef; + const rowSelected = selectedRefSet.has(row.lineRef); + // Drawn per cell rather than on the row, so it crosses the + // opaque pinned columns too. Listed last in each `cn` so it + // wins over a cell's selection ring while a drag is in flight. + const dropLine = + dropBefore === row.lineRef + ? "astw:shadow-[inset_0_2px_0_0_var(--color-primary)]" + : undefined; + + return ( + td]:border-b-0", + )} + onDragOver={ + canReorder + ? (e) => { + if (!dragRef) return; + e.preventDefault(); + setDropBefore(row.lineRef); + } + : undefined + } + onDrop={ + canReorder + ? (e) => { + if (!dragRef) return; + e.preventDefault(); + moveLine(dragRef, row.lineRef); + endDrag(); + } + : undefined + } + > + {showSelection ? ( + + ) : null} + + {canReorder ? ( + + ) : null} + + {layout.columns.map((c) => { + const coord = { lineRef: row.lineRef, columnId: c.id }; + const editable = fieldIsEditableInMode(c.field, mode); + const isActive = + grid.active?.lineRef === row.lineRef && grid.active?.columnId === c.id; + const isSelected = grid.isSelected(row.lineRef, c.id); + const inFill = isInFillPreview(coord); + const amendLocked = fieldIsAmendLocked(c.field, mode); + const extra = + typeof c.field.className === "function" + ? c.field.className(row) + : c.field.className; + + return ( + + ); + })} + + {renderRowActions ? ( + + ) : null} + + {layout.needsSpacer ? + ); + })} + + {!loading && virtualWindow.paddingBottom > 0 ? ( + + ) : null} + + + {totalsRenderer ? ( + + + {showSelection ? ( + + ))} + {renderRowActions ? ( + + + ) : null} +
void headRefs.current.set(SELECTION_COLUMN_ID, el)} + scope="col" + className={cn(headCellClasses, "astw:px-0", pinClasses(layout.selection))} + style={pinStyle(layout.selection)} + > + + 0 && !allVisibleSelected} + onCheckedChange={() => + allVisibleSelected + ? lineItems.clearSelection() + : lineItems.selectAllVisible() + } + /> + + void headRefs.current.set(GRIP_COLUMN_ID, el)} + scope="col" + className={cn(headCellClasses, "astw:px-0", pinClasses(layout.grip))} + style={pinStyle(layout.grip)} + > + {t("reorderColumn")} + void headRefs.current.set(c.id, el)} + scope="col" + aria-sort={sortable ? sortedState : undefined} + className={cn(headCellClasses, alignClasses[c.align], pinClasses(c.placement))} + style={pinStyle(c.placement)} + > + {sortable ? ( + + ) : ( + c.field.label + )} + void headRefs.current.set(ACTIONS_COLUMN_ID, el)} + scope="col" + className={cn(headCellClasses, "astw:px-0", pinClasses(layout.actions))} + style={pinStyle(layout.actions)} + > + {t("actionsColumn")} + void headRefs.current.set(SPACER_COLUMN_ID, el)} + className={headCellClasses} + /> + ) : null} +
+ {emptyMessage ?? t("emptyMessage")} +
+ + lineItems.toggleSelect(row.lineRef)} + /> + + + + + + { + // A fill in progress tracks the row under the pointer. + // The window-level handler covers fast drags that + // outrun per-cell events; this covers the rest. + if (fillSource) setFillHoverLineRef(row.lineRef); + else grid.extendSelection(coord); + }} + onPointerDown={(e) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + const interactive = Boolean( + target.closest( + "input, select, button, textarea, [role=combobox], [data-slot=line-items-fill-grip]", + ), + ); + if (e.shiftKey) { + e.preventDefault(); + grid.beginSelection(coord, { shift: true }); + return; + } + // Clicking into an editor must place a caret, so only a + // press on inert cell chrome starts a range drag. + if (interactive) grid.setActive(coord); + else grid.beginSelection(coord); + }} + onPointerUp={grid.endSelection} + > + + + + {renderRowActions(row)} + + : null} +
+ ) : null} + {canReorder ? ( + + ) : null} + {layout.columns.map((c) => ( + + {totalsRenderer?.(lines as LineItemsRowData[])[c.id] ?? null} + + ) : null} + {layout.needsSpacer ? : null} +
+
+ + {canReorder ? ( + + {reorderNotice} + + ) : null} + + {/* Bulk actions: the same bar as DataTable.Footer, at the grid's foot. */} + {offersBulkActions ? ( + <> + {selectionBarOpen ? ( +
+
+ + bar + .closest('[data-slot="line-items-table"]') + ?.querySelector("thead [data-slot=checkbox]") + } + /> +
+
+ ) : null} + + + ) : null} +
+ ); +} + +LineItemsTable.displayName = "LineItems.Table"; diff --git a/packages/core/src/components/line-items/line-items.test.tsx b/packages/core/src/components/line-items/line-items.test.tsx new file mode 100644 index 000000000..f8c3de473 --- /dev/null +++ b/packages/core/src/components/line-items/line-items.test.tsx @@ -0,0 +1,1213 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { createMemoryRouter, Link, RouterProvider } from "react-router"; +import * as React from "react"; + +import type { SelectionAction } from "@/components/data-table/types"; +import { createLineItemHelper } from "./field"; +import { LineItems } from "./line-items"; +import type { + LineItemsChangeSet, + LineItemsField, + LineItemsMode, + LineItemsOrderingMode, +} from "./types"; +import { useLineItems } from "./use-line-items"; + +type Line = { + lineRef: string; + sku: string; + uom: string; + quantity: number; + note: string; + total: number; +}; + +const f = createLineItemHelper(); + +const UOM = [ + { value: "EA", label: "Each" }, + { value: "CS", label: "Case", description: "12 units" }, +]; + +const fields: LineItemsField[] = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 140, + pin: "left", + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "uom", + label: "UoM", + render: (l) => l.uom, + editable: ["edit"], + type: { kind: "select", options: UOM, placeholder: "Pick one" }, + width: 110, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 80, + sort: { comparator: (a, b) => (a.quantity ?? 0) - (b.quantity ?? 0) }, + }), + // Stays editable once the document is locked, which is what amend mode is for. + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit", "amend"], + type: { kind: "text" }, + width: 120, + }), + // Computed: read-only, and never submitted. + f.field({ key: "total", label: "Total", render: (l) => (l.total ?? 0).toFixed(2), width: 100 }), +]; + +const seed: Line[] = [ + { lineRef: "1", sku: "APPLE-01", uom: "EA", quantity: 2, note: "", total: 10 }, + { lineRef: "2", sku: "BANANA-02", uom: "CS", quantity: 5, note: "", total: 25 }, + { lineRef: "3", sku: "CHERRY-03", uom: "EA", quantity: 1, note: "", total: 4 }, +]; + +type HarnessProps = { + mode?: LineItemsMode; + ordering?: LineItemsOrderingMode; + selection?: boolean; + virtualized?: boolean; + renderRowActions?: boolean; + totals?: boolean; + addRow?: boolean; + onSave?: (cs: LineItemsChangeSet) => void; + selectionActions?: SelectionAction[]; + onReady?: (api: ReturnType>) => void; +}; + +function Harness({ + mode = "edit", + ordering = "sort", + selection = false, + virtualized = false, + renderRowActions = false, + totals = false, + addRow = false, + onSave, + selectionActions, + onReady, +}: HarnessProps) { + const lineItems = useLineItems({ + fields, + defaultLines: seed, + mode, + ordering, + selection: selectionActions ? { actions: selectionActions } : selection, + }); + onReady?.(lineItems); + + return ( + + + + + ( + + ) + : undefined + } + /> + {totals ? ( + + {(lines: Line[]) => ({ + quantity: lines.reduce((s, l) => s + (l.quantity ?? 0), 0), + total: lines.reduce((s, l) => s + (l.total ?? 0), 0).toFixed(2), + })} + + ) : null} + {addRow ? ( + + + + ) : null} + + + + {onSave ? : null} + + ); +} + +/** Cells are addressed by coordinate, since editable ones hold inputs not text. */ +function cell(lineRef: string, columnId: string): HTMLElement { + const el = document.querySelector( + `[data-line-ref="${lineRef}"][data-column-id="${columnId}"]`, + ); + if (!el) throw new Error(`cell ${lineRef}/${columnId} not found`); + return el; +} + +function editorIn(lineRef: string, columnId: string): HTMLInputElement { + const el = cell(lineRef, columnId).querySelector( + "[data-slot=line-items-cell-editor]", + ); + if (!el) throw new Error(`no editor in ${lineRef}/${columnId}`); + return el; +} + +const allEditors = () => document.querySelectorAll("[data-slot=line-items-cell-editor]"); + +afterEach(() => { + cleanup(); +}); + +describe("LineItems", () => { + describe("snapshots", () => { + it("view mode", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("edit mode with selection and row actions", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("amend mode", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("manual ordering renders drag handles", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + }); + + it("renders a row per line and the column headers", () => { + render(); + expect(screen.getByRole("columnheader", { name: "SKU" })).toBeDefined(); + expect(editorIn("1", "sku").value).toBe("APPLE-01"); + expect(editorIn("3", "sku").value).toBe("CHERRY-03"); + expect(cell("1", "total").textContent).toBe("10.00"); + }); + + it("reports a row count that includes the header and totals rows", () => { + render(); + const table = screen.getByRole("table"); + // 3 lines + header + totals. + expect(table.getAttribute("aria-rowcount")).toBe("5"); + const rows = screen.getAllByRole("row"); + expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1"); + expect(rows[1]?.getAttribute("aria-rowindex")).toBe("2"); + expect(rows.at(-1)?.getAttribute("aria-rowindex")).toBe("5"); + }); + + it("names every cell editor by its column and line", () => { + render(); + expect(screen.getByRole("textbox", { name: "Note, line 2" })).toBeDefined(); + expect(screen.getByRole("combobox", { name: "UoM, line 3" })).toBeDefined(); + }); + + it("renders an empty state with no lines", () => { + function Empty() { + const lineItems = useLineItems({ fields, defaultLines: [] }); + return ( + + + + ); + } + render(); + expect(screen.getByText("Nothing here")).toBeDefined(); + }); + + it("renders skeleton rows while loading", () => { + function Loading() { + const lineItems = useLineItems({ fields, defaultLines: seed }); + return ( + + + + ); + } + render(); + expect(document.querySelectorAll("[data-slot=line-items-skeleton-row]")).toHaveLength(4); + expect(allEditors()).toHaveLength(0); + }); + + it("throws when a part is used outside Root", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/must be used within /); + spy.mockRestore(); + }); + + describe("editing", () => { + it("mounts an editor in every editable cell, so the grid reads as a spreadsheet at rest", () => { + render(); + // 4 editable columns (sku, uom, quantity, note) across 3 rows. + expect(allEditors()).toHaveLength(12); + expect(cell("1", "total").querySelector("[data-slot=line-items-cell-editor]")).toBeNull(); + }); + + it("commits an edit on blur", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "sku"); + await user.clear(editor); + await user.type(editor, "PEAR-99"); + await user.tab(); + + expect(editorIn("1", "sku").value).toBe("PEAR-99"); + }); + + it("does not commit per keystroke", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "sku"); + await user.clear(editor); + await user.type(editor, "PEAR"); + + expect(api?.isDirty).toBe(false); + }); + + it("commits on Enter", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "quantity"); + await user.clear(editor); + await user.type(editor, "42{Enter}"); + + expect(api?.allLines[0]?.quantity).toBe(42); + }); + + it("commits on Enter in the last row, where focus has nowhere to move", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("3", "quantity"); + await user.clear(editor); + await user.type(editor, "42{Enter}"); + + expect(api?.allLines[2]?.quantity).toBe(42); + expect(document.activeElement).toBe(editor); + }); + + it("commits on Tab", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "quantity"); + await user.clear(editor); + await user.type(editor, "7{Tab}"); + + expect(api?.allLines[0]?.quantity).toBe(7); + }); + + it("Tab skips read-only columns, wrapping to the next line's first editable cell", async () => { + const user = userEvent.setup(); + render(); + + // Note is the last editable column; Total after it is computed and read-only. + await user.click(editorIn("1", "note")); + await user.keyboard("{Tab}"); + + expect(document.activeElement).toBe(editorIn("2", "sku")); + }); + + it("Tab past the last editable cell lets focus leave the grid", async () => { + const user = userEvent.setup(); + render( + <> + + + , + ); + + await user.click(editorIn("3", "note")); + await user.keyboard("{Tab}"); + + expect(document.activeElement).toBe(screen.getByRole("button", { name: "After the grid" })); + }); + + it("lets a paste win over typing already in the cell", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( (api = x)} />); + + const editor = editorIn("1", "sku"); + await user.click(editor); + await user.keyboard("TYPED"); + fireEvent.paste(editor, { clipboardData: { getData: () => "PASTED" } }); + expect(editorIn("1", "sku").value).toBe("PASTED"); + + await user.click(editorIn("2", "note")); + expect(api?.allLines[0]?.sku).toBe("PASTED"); + }); + + it('reads a pasted "1,234" in a number column as a number', async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( (api = x)} />); + + await user.click(editorIn("1", "quantity")); + fireEvent.paste(editorIn("1", "quantity"), { clipboardData: { getData: () => "1,234" } }); + expect(api?.allLines[0]?.quantity).toBe(1234); + + fireEvent.paste(editorIn("1", "quantity"), { clipboardData: { getData: () => "N/A" } }); + expect(api?.allLines[0]?.quantity).toBe(1234); + }); + + it("Escape in a closed select cell cancels rather than clearing the value", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( (api = x)} />); + + const select = screen.getByRole("combobox", { name: "UoM, line 1" }); + select.focus(); + await user.keyboard("{Escape}{Escape}"); + + expect(api?.allLines[0]?.uom).toBe("EA"); + expect(api?.isDirty).toBe(false); + }); + + it("Tab moves into a custom editor's own input", async () => { + const user = userEvent.setup(); + const f2 = createLineItemHelper(); + const customFields: LineItemsField[] = [ + f2.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + }), + f2.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit"], + type: { + kind: "custom", + renderEditor: (ctx) => ( + + ), + }, + }), + ]; + function CustomHarness() { + const lineItems = useLineItems({ fields: customFields, defaultLines: seed }); + return ( + + + + ); + } + render(); + + await user.click(editorIn("1", "sku")); + await user.keyboard("{Tab}"); + + expect(document.activeElement).toBe(screen.getByRole("textbox", { name: "Note, line 1" })); + }); + + it("keeps a paste into the focused cell when it later blurs", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "quantity"); + await user.click(editor); + fireEvent.paste(editor, { clipboardData: { getData: () => "12" } }); + expect(editorIn("1", "quantity").value).toBe("12"); + + await user.click(editorIn("2", "note")); + expect(api?.allLines[0]?.quantity).toBe(12); + }); + + it("commits once when Enter both commits and moves focus", async () => { + const user = userEvent.setup(); + const onLinesChange = vi.fn(); + function Controlled() { + const [lines, setLines] = React.useState(seed); + const lineItems = useLineItems({ + fields, + lines, + onLinesChange: (next) => { + onLinesChange(next); + setLines(next); + }, + }); + return ( + + + + ); + } + render(); + + const editor = editorIn("1", "quantity"); + await user.clear(editor); + await user.type(editor, "42{Enter}"); + + expect(onLinesChange).toHaveBeenCalledTimes(1); + }); + + it("commits typing still in flight when its row unmounts", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "note"); + await user.click(editor); + await user.type(editor, "half-typed"); + // Filtering line 1 out unmounts it without a blur, as scrolling it out of + // the virtual window does. + act(() => api?.setFilter("BANANA")); + + expect(api?.allLines[0]?.note).toBe("half-typed"); + }); + + it("commits typing still in flight when the cell turns read-only", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + const { rerender } = render( (api = a)} />); + + const editor = editorIn("1", "note"); + await user.click(editor); + await user.type(editor, "half-typed"); + rerender( (api = a)} />); + + expect(api?.allLines[0]?.note).toBe("half-typed"); + }); + + it("abandons the edit on Escape", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "sku"); + await user.clear(editor); + await user.type(editor, "DISCARD-ME{Escape}"); + + expect(editorIn("1", "sku").value).toBe("APPLE-01"); + }); + + it("selects the whole value on focus so typing replaces rather than appends", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "sku"); + await user.click(editor); + + expect(editor.selectionStart).toBe(0); + expect(editor.selectionEnd).toBe("APPLE-01".length); + }); + + it("renders a searchable combobox for select fields", () => { + render(); + const uom = cell("2", "uom"); + // The caret is the component's own trigger, next to a typeable input. + expect(uom.querySelector("[data-slot=line-items-cell-editor]")).not.toBeNull(); + expect(within(uom).getByRole("button")).toBeDefined(); + }); + + it("renders no editors at all in view mode", () => { + render(); + expect(allEditors()).toHaveLength(0); + expect(cell("1", "sku").textContent).toBe("APPLE-01"); + }); + }); + + describe("amend mode", () => { + it("only fields listing amend stay editable", () => { + render(); + expect(cell("1", "note").querySelector("[data-slot=line-items-cell-editor]")).not.toBeNull(); + expect(cell("1", "sku").querySelector("[data-slot=line-items-cell-editor]")).toBeNull(); + expect(cell("1", "quantity").querySelector("[data-slot=line-items-cell-editor]")).toBeNull(); + }); + + it("tints the cells the lock closed", () => { + render(); + expect(cell("1", "sku").hasAttribute("data-locked")).toBe(true); + expect(cell("1", "note").hasAttribute("data-locked")).toBe(false); + }); + + it("still commits edits to the fields left open", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "note"); + await user.type(editor, "corrected{Enter}"); + + expect(api?.allLines[0]?.note).toBe("corrected"); + }); + }); + + describe("fill handle", () => { + it("fills down from the keyboard with Cmd/Ctrl+D", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( (api = x)} />); + + await user.click(editorIn("1", "quantity")); + await user.keyboard("{Alt>}{Shift>}{ArrowDown}{ArrowDown}{/Shift}{/Alt}"); + await user.keyboard("{Control>}d{/Control}"); + + expect(api?.allLines.map((l) => l.quantity)).toEqual([2, 2, 2]); + }); + + it("appears on the active cell of a fillable column", async () => { + const user = userEvent.setup(); + render(); + + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(0); + await user.click(editorIn("1", "quantity")); + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(1); + }); + + it("fills the dragged range with the source value in one commit", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + // Seed a distinctive value, then drag its grip down over the rows below. + const source = editorIn("1", "quantity"); + await user.clear(source); + await user.type(source, "99{Enter}"); + await user.click(editorIn("1", "quantity")); + + const grip = document.querySelector("[data-slot=line-items-fill-grip]"); + expect(grip).not.toBeNull(); + + await user.pointer([ + { keys: "[MouseLeft>]", target: grip! }, + { target: cell("2", "quantity") }, + { target: cell("3", "quantity") }, + { keys: "[/MouseLeft]" }, + ]); + + expect(api?.allLines.map((l) => l.quantity)).toEqual([99, 99, 99]); + }); + + it("leaves rows outside the dragged range alone", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const source = editorIn("1", "quantity"); + await user.clear(source); + await user.type(source, "50{Enter}"); + await user.click(editorIn("1", "quantity")); + + const grip = document.querySelector("[data-slot=line-items-fill-grip]"); + await user.pointer([ + { keys: "[MouseLeft>]", target: grip! }, + { target: cell("2", "quantity") }, + { keys: "[/MouseLeft]" }, + ]); + + // Row 3 was never dragged over, so it keeps its own value. + expect(api?.allLines.map((l) => l.quantity)).toEqual([50, 50, 1]); + }); + + it("is withheld from read-only columns", async () => { + const user = userEvent.setup(); + render(); + await user.click(cell("1", "total")); + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(0); + }); + + it("is withheld in amend mode, which must not be bulk-overwritten", async () => { + const user = userEvent.setup(); + render(); + await user.click(editorIn("1", "note")); + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(0); + }); + }); + + describe("selection", () => { + const remove: SelectionAction = { + id: "remove", + label: "Remove", + variant: "destructive", + onClick: () => {}, + }; + + it("shows no bulk-action bar for selection: true", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Select line 2" })); + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).toBeNull(); + }); + + it("opens the bulk-action bar with a count once a line is selected", async () => { + const user = userEvent.setup(); + render(); + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).toBeNull(); + + await user.click(screen.getByRole("checkbox", { name: "Select line 2" })); + const bar = screen.getByRole("toolbar", { name: "Bulk actions" }); + expect(within(bar).getByText("1 of 3 selected")).toBeDefined(); + }); + + it("select-all selects every line", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Select all lines" })); + const bar = screen.getByRole("toolbar", { name: "Bulk actions" }); + expect(within(bar).getByText("3 of 3 selected")).toBeDefined(); + }); + + it("counts and passes only the lines canApply accepts", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render( + (l.quantity ?? 0) > 1, onClick }, + ]} + />, + ); + + await user.click(screen.getByRole("checkbox", { name: "Select all lines" })); + await user.click(screen.getByRole("button", { name: /Bump/ })); + + expect(screen.getByRole("button", { name: /Bump/ }).textContent).toContain("(2)"); + expect(onClick.mock.calls[0]?.[0].map((l: Line) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("lets an action remove the selected lines in one update", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + api?.removeLines(rows.map((r) => r.lineRef)) }, + ]} + onReady={(a) => { + api = a; + }} + />, + ); + + await user.click(screen.getByRole("checkbox", { name: "Select line 1" })); + await user.click(screen.getByRole("checkbox", { name: "Select line 3" })); + await user.click(screen.getByRole("button", { name: "Remove" })); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["2"]); + expect(api?.getChangeSet().lineChanges).toEqual([ + { action: "remove", lineId: "1" }, + { action: "remove", lineId: "3" }, + ]); + }); + + it("Clear empties the selection and hands focus to the header checkbox", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Select line 2" })); + await user.click(screen.getByRole("button", { name: "Clear selection" })); + + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).toBeNull(); + expect(document.activeElement).toBe( + screen.getByRole("checkbox", { name: "Select all lines" }), + ); + }); + + it("clears the selection once a returned promise resolves", async () => { + const user = userEvent.setup(); + let resolve: (() => void) | undefined; + const request = new Promise((r) => { + resolve = r; + }); + render( + request }]} />, + ); + + await user.click(screen.getByRole("checkbox", { name: "Select line 2" })); + await user.click(screen.getByRole("button", { name: "Sync" })); + expect(screen.getByRole("toolbar", { name: "Bulk actions" }).getAttribute("aria-busy")).toBe( + "true", + ); + + await act(async () => resolve?.()); + expect(screen.queryByRole("toolbar", { name: "Bulk actions" })).toBeNull(); + }); + + it("hides the selection column in view mode", () => { + render(); + expect(screen.queryByRole("checkbox", { name: "Select all lines" })).toBeNull(); + }); + }); + + describe("drag reordering", () => { + it("renders a labelled grip per row under manual ordering", () => { + render(); + expect(screen.getByRole("button", { name: "Drag to reorder line 1" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Drag to reorder line 3" })).toBeDefined(); + }); + + it("renders no grips under sort ordering", () => { + render(); + expect(screen.queryByRole("button", { name: /drag to reorder/i })).toBeNull(); + }); + + it("renders no grips in view mode", () => { + render(); + expect(screen.queryByRole("button", { name: /drag to reorder/i })).toBeNull(); + }); + + it("marks the row being dragged over as the drop target", () => { + render(); + + // Simulate the row being dragged over by setting the state the drop + // handler sets, then assert the row is marked as the drop target. + const row = document.querySelectorAll("[data-slot=line-items-row]")[1] as HTMLElement; + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + + fireEvent.dragStart(grip); + fireEvent.dragOver(row); + + expect(row.hasAttribute("data-drop-before")).toBe(true); + }); + + it("marks a selected row so every cell, pinned ones included, tints with it", async () => { + const user = userEvent.setup(); + render(); + + const row = () => cell("1", "sku").closest("tr")!; + expect(row().getAttribute("data-state")).toBeNull(); + + await user.click(screen.getByRole("checkbox", { name: "Select line 1" })); + expect(row().getAttribute("data-state")).toBe("selected"); + }); + + it("moves a line down with the keyboard and reports a reorder op", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + grip.focus(); + await user.keyboard("{Enter}{ArrowDown}"); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["2", "1", "3"]); + expect(api?.getChangeSet().lineChanges.filter((c) => c.action === "reorder")).toEqual([ + { action: "reorder", lineId: "2", position: 0 }, + { action: "reorder", lineId: "1", position: 1 }, + ]); + }); + + it("moving a line back to its slot leaves the document clean", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + grip.focus(); + await user.keyboard("{Enter}{ArrowDown}{ArrowUp}"); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + expect(api?.getChangeSet().isEmpty).toBe(true); + }); + + it("ignores arrow keys until the row is lifted", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + screen.getByRole("button", { name: "Drag to reorder line 1" }).focus(); + await user.keyboard("{ArrowDown}"); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + }); + + it("marks the lifted row with aria-pressed", async () => { + const user = userEvent.setup(); + render(); + + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + expect(grip.getAttribute("aria-pressed")).toBe("false"); + + grip.focus(); + await user.keyboard("{Enter}"); + expect(grip.getAttribute("aria-pressed")).toBe("true"); + + await user.keyboard("{Escape}"); + expect(grip.getAttribute("aria-pressed")).toBe("false"); + }); + }); + + describe("sorting", () => { + it("exposes a sortable header as a button with aria-sort", () => { + render(); + const header = screen.getByRole("columnheader", { name: /qty/i }); + expect(header.getAttribute("aria-sort")).toBe("none"); + expect(within(header).getByRole("button")).toBeDefined(); + }); + + it("leaves a column without a comparator unsortable", () => { + render(); + const header = screen.getByRole("columnheader", { name: "Total" }); + expect(header.getAttribute("aria-sort")).toBeNull(); + }); + + it("cycles the rows through ascending, descending and document order", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + const sortButton = within(screen.getByRole("columnheader", { name: /qty/i })).getByRole( + "button", + ); + + await user.click(sortButton); + expect(api?.lines.map((l) => l.quantity)).toEqual([1, 2, 5]); + + await user.click(sortButton); + expect(api?.lines.map((l) => l.quantity)).toEqual([5, 2, 1]); + + await user.click(sortButton); + expect(api?.lines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + }); + + it("renders no sort affordance under manual ordering", () => { + render(); + const header = screen.getByRole("columnheader", { name: /qty/i }); + expect(header.getAttribute("aria-sort")).toBeNull(); + }); + }); + + describe("parts", () => { + describe("fullscreen", () => { + it("opens as a labelled modal dialog, locks page scroll, and closes on Escape", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Expand to fullscreen" })); + const dialog = screen.getByRole("dialog", { name: "Line items" }); + expect(dialog.getAttribute("aria-modal")).toBe("true"); + expect(document.body.style.overflow).toBe("hidden"); + + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(document.body.style.overflow).toBe(""); + }); + + it("keeps a half-typed cell when toggled, since the grid is not remounted", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "note"); + await user.click(editor); + await user.keyboard("draft"); + fireEvent.click(screen.getByRole("button", { name: "Expand to fullscreen" })); + + expect(editorIn("1", "note")).toBe(editor); + expect(editor.value).toBe("draft"); + }); + }); + + describe("DirtyBar warnOnNav", () => { + function GuardPage({ + onReady, + }: { + onReady?: (a: ReturnType>) => void; + }) { + const lineItems = useLineItems({ fields, defaultLines: seed }); + onReady?.(lineItems); + return ( + + Elsewhere + Same page + + + {}} /> + + + ); + } + + function renderInRouter() { + let api: ReturnType> | undefined; + const router = createMemoryRouter( + [ + { path: "/", element: (api = a)} /> }, + { path: "/elsewhere", element:

Elsewhere page

}, + ], + { initialEntries: ["/"] }, + ); + render(); + return { router, api: () => api }; + } + + it("holds the user on the page and hands focus to Save while dirty", async () => { + const user = userEvent.setup(); + const { router, api } = renderInRouter(); + act(() => api()?.updateField("1", "quantity", 9)); + + await user.click(screen.getByRole("link", { name: "Elsewhere" })); + expect(router.state.location.pathname).toBe("/"); + expect(document.activeElement?.textContent).toBe("Save"); + }); + + it("also holds programmatic navigation", async () => { + const { router, api } = renderInRouter(); + act(() => api()?.updateField("1", "quantity", 9)); + + await act(() => router.navigate("/elsewhere")); + expect(router.state.location.pathname).toBe("/"); + }); + + it("lets a same-page search-param change through", async () => { + const user = userEvent.setup(); + const { router, api } = renderInRouter(); + act(() => api()?.updateField("1", "quantity", 9)); + + await user.click(screen.getByRole("link", { name: "Same page" })); + expect(router.state.location.search).toBe("?tab=2"); + }); + + it("lets navigation through once the document is clean", async () => { + const user = userEvent.setup(); + const { router } = renderInRouter(); + + await user.click(screen.getByRole("link", { name: "Elsewhere" })); + expect(router.state.location.pathname).toBe("/elsewhere"); + }); + }); + + it("filters rows from the search box", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("searchbox", { name: /search lines/i }), "banana"); + + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(1); + expect(editorIn("2", "sku").value).toBe("BANANA-02"); + }); + + it("SearchToggle expands into an input", async () => { + const user = userEvent.setup(); + render(); + + // Collapsed, the toggle *is* the button; expanded, it wraps an input. + const toggle = document.querySelector("[data-slot=line-items-search-toggle]"); + expect(toggle?.tagName.toLowerCase()).toBe("button"); + await user.click(toggle!); + + const expanded = document.querySelector("[data-slot=line-items-search-toggle]"); + expect(expanded?.querySelector("input")).not.toBeNull(); + }); + + it("FullscreenToggle reports its pressed state", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("button", { name: "Expand to fullscreen" }); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + + await user.click(toggle); + expect(screen.getByRole("button", { name: "Exit fullscreen" })).toBeDefined(); + }); + + it("AddRow hosts consumer controls below the table", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add product" })); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(4); + }); + + it("hides AddRow in view mode", () => { + render(); + expect(screen.queryByRole("button", { name: "Add product" })).toBeNull(); + }); + + it("surfaces the dirty bar once something changes", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText(/unsaved/)).toBeNull(); + await user.click(screen.getByRole("button", { name: "Add product" })); + expect(screen.getByText("1 unsaved change")).toBeDefined(); + }); + + it("discards changes from the dirty bar", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add product" })); + await user.click(screen.getByRole("button", { name: "Discard" })); + + expect(screen.queryByText(/unsaved/)).toBeNull(); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(3); + }); + + it("hands the change-set to onSave", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Add product" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const cs = onSave.mock.calls[0]![0] as LineItemsChangeSet; + expect(cs.lineChanges[0]?.action).toBe("add"); + }); + + it("TotalsRow renders column-aligned cells in a tfoot", () => { + render(); + const totals = document.querySelector("[data-slot=line-items-totals]"); + expect(totals?.tagName.toLowerCase()).toBe("tfoot"); + expect(within(totals as HTMLElement).getByText("8")).toBeDefined(); + expect(within(totals as HTMLElement).getByText("39.00")).toBeDefined(); + }); + + it("runs a row action", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Remove BANANA-02" })); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(2); + }); + }); + + describe("virtualization", () => { + it("mounts only a slice of a long document", () => { + const many: Line[] = Array.from({ length: 500 }, (_, i) => ({ + lineRef: `L${i}`, + sku: `SKU-${i}`, + uom: "EA", + quantity: i, + note: "", + total: i, + })); + + function Big() { + const lineItems = useLineItems({ fields, defaultLines: many }); + return ( + + + + ); + } + render(); + + expect(document.querySelectorAll("[data-slot=line-items-row]").length).toBeLessThan( + many.length, + ); + }); + + it("mounts every row when virtualization is off", () => { + render(); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(3); + }); + }); +}); diff --git a/packages/core/src/components/line-items/line-items.tsx b/packages/core/src/components/line-items/line-items.tsx new file mode 100644 index 000000000..9b0ee648f --- /dev/null +++ b/packages/core/src/components/line-items/line-items.tsx @@ -0,0 +1,227 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { + LineItemsContextProvider, + type LineItemsContextValue, + type LineItemsTotalsRenderer, +} from "./line-items-context"; +import { + LineItemsAddRow, + LineItemsDirtyBar, + LineItemsFloatingDock, + LineItemsFullscreenToggle, + LineItemsSaveActions, + LineItemsSearch, + LineItemsSearchToggle, + LineItemsTotalsRow, +} from "./line-items-parts"; +import { useLineItemsT } from "./i18n"; +import { LineItemsTable } from "./line-items-table"; +import type { LineItemsRowData, UseLineItemsReturn } from "./types"; + +export type LineItemsRootProps = { + /** The `useLineItems()` return value this subtree renders. */ + value: UseLineItemsReturn; + children?: React.ReactNode; + /** Controlled fullscreen state. Omit to let Root own it. */ + fullscreen?: boolean; + onFullscreenChange?: (next: boolean) => void; + className?: string; +}; + +/** Elements Tab can land on, for keeping focus inside the fullscreen overlay. */ +const FOCUSABLE = + 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; + +/** + * Provides the line-items document to the parts beneath it. Root holds no row + * data of its own — `useLineItems()` owns all of it. + * + * @example + * const lineItems = useLineItems({ fields, defaultLines: lines }); + * + * + * + * + * + * + * + * } /> + * + * } /> + * {(lines) => ({ total: sum(lines) })} + * + * lineItems.addLine(item)} /> + * + * + * + * + * + * + * + */ +function LineItemsRoot({ + value, + children, + fullscreen: fullscreenProp, + onFullscreenChange, + className, +}: LineItemsRootProps) { + const [uncontrolledFullscreen, setUncontrolledFullscreen] = React.useState(false); + const fullscreen = fullscreenProp ?? uncontrolledFullscreen; + + const setFullscreen = React.useCallback( + (next: boolean) => { + if (fullscreenProp === undefined) setUncontrolledFullscreen(next); + onFullscreenChange?.(next); + }, + [fullscreenProp, onFullscreenChange], + ); + + const [totalsRenderer, setTotalsRenderer] = React.useState( + undefined, + ); + + // Stored as a thunk: a plain setState would call the renderer as an updater. + const registerTotals = React.useCallback((renderer: LineItemsTotalsRenderer | undefined) => { + setTotalsRenderer(() => renderer); + }, []); + + const ctx = React.useMemo>( + () => ({ + lineItems: value, + fullscreen, + setFullscreen, + totalsRenderer, + setTotalsRenderer: registerTotals, + }), + [fullscreen, registerTotals, setFullscreen, totalsRenderer, value], + ); + + const t = useLineItemsT(); + + // The page behind a modal overlay must not scroll under it. + React.useEffect(() => { + if (!fullscreen) return; + const { body } = document; + const previous = body.style.overflow; + body.style.overflow = "hidden"; + return () => { + body.style.overflow = previous; + }; + }, [fullscreen]); + + /** + * Escape closes, unless something inside already handled it (a cell editor + * abandoning its edit, a combobox closing its popup). Tab wraps within the + * overlay so focus can't wander to the page hidden behind it. + */ + const onOverlayKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + if (e.defaultPrevented) return; + e.preventDefault(); + setFullscreen(false); + return; + } + if (e.key !== "Tab") return; + const focusable = Array.from(e.currentTarget.querySelectorAll(FOCUSABLE)).filter( + (el) => el.offsetParent !== null || el === document.activeElement, + ); + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }; + + const body = ( +
[data-slot=card]]:flex astw:[&>[data-slot=card]]:h-full astw:[&>[data-slot=card]]:min-h-0 astw:[&>[data-slot=card]]:flex-col astw:[&>[data-slot=card]]:overflow-hidden", + "astw:[&_[data-slot=card-content]]:flex astw:[&_[data-slot=card-content]]:min-h-0 astw:[&_[data-slot=card-content]]:flex-1 astw:[&_[data-slot=card-content]]:flex-col", + ), + className, + )} + > + {children} +
+ ); + + // The same elements render in both states — `contents` wrappers while + // inline — so toggling fullscreen never remounts the document: focus stays on + // the toggle and half-typed cells keep their drafts. + return ( + + {/* oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- the role is computed: "dialog" whenever the handlers are attached */} +
{ + if (e.target === e.currentTarget) setFullscreen(false); + } + : undefined + } + className={cn( + fullscreen + ? [ + "astw:fixed astw:inset-0 astw:z-(--z-overlay) astw:flex astw:flex-col astw:bg-black/80 astw:p-6 astw:backdrop-blur-sm", + "astw:animate-in astw:fade-in-0 astw:duration-200 astw:motion-reduce:animate-none", + ] + : "astw:contents", + )} + > +
+ {body} +
+
+
+ ); +} +LineItemsRoot.displayName = "LineItems.Root"; + +export const LineItems = { + Root: LineItemsRoot, + Table: LineItemsTable, + Search: LineItemsSearch, + SearchToggle: LineItemsSearchToggle, + AddRow: LineItemsAddRow, + FullscreenToggle: LineItemsFullscreenToggle, + SaveActions: LineItemsSaveActions, + TotalsRow: LineItemsTotalsRow, + FloatingDock: LineItemsFloatingDock, + DirtyBar: LineItemsDirtyBar, +}; diff --git a/packages/core/src/components/line-items/spreadsheet-logic.test.ts b/packages/core/src/components/line-items/spreadsheet-logic.test.ts new file mode 100644 index 000000000..290eed902 --- /dev/null +++ b/packages/core/src/components/line-items/spreadsheet-logic.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { + coordsToRowsMatrix, + moveSelectionCoord, + parseClipboardTsv, + rectangularCells, + sameCoord, + serializeMatrixTsv, +} from "./spreadsheet-logic"; + +const refs = ["a", "b", "c"]; +const cols = ["sku", "qty", "price"]; + +describe("moveSelectionCoord", () => { + it("moves within bounds", () => { + expect(moveSelectionCoord("ArrowDown", { lineRef: "a", columnId: "qty" }, refs, cols)).toEqual({ + lineRef: "b", + columnId: "qty", + }); + expect(moveSelectionCoord("ArrowRight", { lineRef: "a", columnId: "sku" }, refs, cols)).toEqual( + { + lineRef: "a", + columnId: "qty", + }, + ); + }); + + it("clamps at the grid edges rather than wrapping", () => { + expect(moveSelectionCoord("ArrowUp", { lineRef: "a", columnId: "sku" }, refs, cols)).toEqual({ + lineRef: "a", + columnId: "sku", + }); + expect(moveSelectionCoord("ArrowDown", { lineRef: "c", columnId: "sku" }, refs, cols)).toEqual({ + lineRef: "c", + columnId: "sku", + }); + expect( + moveSelectionCoord("ArrowRight", { lineRef: "a", columnId: "price" }, refs, cols), + ).toEqual({ lineRef: "a", columnId: "price" }); + }); + + it("returns null for a coordinate outside the grid", () => { + expect( + moveSelectionCoord("ArrowDown", { lineRef: "zz", columnId: "qty" }, refs, cols), + ).toBeNull(); + expect( + moveSelectionCoord("ArrowDown", { lineRef: "a", columnId: "zz" }, refs, cols), + ).toBeNull(); + }); +}); + +describe("rectangularCells", () => { + it("fills the rectangle regardless of anchor direction", () => { + const forward = rectangularCells( + refs, + cols, + { lineRef: "a", columnId: "sku" }, + { + lineRef: "b", + columnId: "qty", + }, + ); + const reversed = rectangularCells( + refs, + cols, + { lineRef: "b", columnId: "qty" }, + { + lineRef: "a", + columnId: "sku", + }, + ); + + expect(forward).toHaveLength(4); + expect(forward).toEqual(reversed); + expect(forward).toEqual([ + { lineRef: "a", columnId: "sku" }, + { lineRef: "a", columnId: "qty" }, + { lineRef: "b", columnId: "sku" }, + { lineRef: "b", columnId: "qty" }, + ]); + }); + + it("returns a single cell when both anchors match", () => { + const cells = rectangularCells( + refs, + cols, + { lineRef: "b", columnId: "qty" }, + { + lineRef: "b", + columnId: "qty", + }, + ); + expect(cells).toEqual([{ lineRef: "b", columnId: "qty" }]); + }); + + it("returns nothing for an unknown coordinate", () => { + expect( + rectangularCells( + refs, + cols, + { lineRef: "zz", columnId: "qty" }, + { lineRef: "b", columnId: "qty" }, + ), + ).toEqual([]); + }); +}); + +const display = (lineRef: string, columnId: string) => `${lineRef}/${columnId}`; + +describe("coordsToRowsMatrix", () => { + it("reads out the bounding box in document and schema order", () => { + const matrix = coordsToRowsMatrix( + refs, + cols, + [ + { lineRef: "b", columnId: "price" }, + { lineRef: "a", columnId: "qty" }, + ], + display, + ); + + expect(matrix).toEqual([ + ["a/qty", "a/price"], + ["b/qty", "b/price"], + ]); + }); + + it("returns nothing for empty input", () => { + expect(coordsToRowsMatrix(refs, cols, [], display)).toEqual([]); + expect(coordsToRowsMatrix([], cols, [{ lineRef: "a", columnId: "qty" }], display)).toEqual([]); + }); +}); + +describe("TSV round-trip", () => { + it("serializes a matrix to tab and newline delimited text", () => { + expect( + serializeMatrixTsv([ + ["1", "2"], + ["3", "4"], + ]), + ).toBe("1\t2\n3\t4"); + }); + + it("parses back to the same matrix", () => { + expect(parseClipboardTsv("1\t2\n3\t4")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("normalizes CRLF and CR line endings", () => { + expect(parseClipboardTsv("1\t2\r\n3\t4")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + expect(parseClipboardTsv("1\t2\r3\t4")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("drops the trailing blank line spreadsheets append on copy", () => { + expect(parseClipboardTsv("1\t2\n3\t4\n")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("returns nothing for blank text", () => { + expect(parseClipboardTsv("")).toEqual([]); + expect(parseClipboardTsv(" \n ")).toEqual([]); + }); + + it("preserves empty cells inside a row", () => { + expect(parseClipboardTsv("1\t\t3")).toEqual([["1", "", "3"]]); + }); +}); + +describe("sameCoord", () => { + it("compares by value and treats null as unequal", () => { + expect(sameCoord({ lineRef: "a", columnId: "qty" }, { lineRef: "a", columnId: "qty" })).toBe( + true, + ); + expect(sameCoord({ lineRef: "a", columnId: "qty" }, { lineRef: "b", columnId: "qty" })).toBe( + false, + ); + expect(sameCoord(null, null)).toBe(false); + }); +}); diff --git a/packages/core/src/components/line-items/spreadsheet-logic.ts b/packages/core/src/components/line-items/spreadsheet-logic.ts new file mode 100644 index 000000000..f93b1f375 --- /dev/null +++ b/packages/core/src/components/line-items/spreadsheet-logic.ts @@ -0,0 +1,120 @@ +/** Logical cell coordinate within the editable grid area. */ +export type GridCoord = { lineRef: string; columnId: string }; + +export type GridDirection = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + +/** Moves one cell in `dir`, clamped to the grid bounds. */ +export function moveSelectionCoord( + dir: GridDirection, + active: GridCoord, + orderedLineRefs: readonly string[], + columnIds: readonly string[], +): GridCoord | null { + const ri = orderedLineRefs.indexOf(active.lineRef); + const ci = columnIds.indexOf(active.columnId); + if (ri < 0 || ci < 0) return null; + + let nr = ri; + let nc = ci; + if (dir === "ArrowUp") nr = Math.max(0, ri - 1); + if (dir === "ArrowDown") nr = Math.min(orderedLineRefs.length - 1, ri + 1); + if (dir === "ArrowLeft") nc = Math.max(0, ci - 1); + if (dir === "ArrowRight") nc = Math.min(columnIds.length - 1, ci + 1); + + const lineRef = orderedLineRefs[nr]; + const columnId = columnIds[nc]; + if (!lineRef || !columnId) return null; + return { lineRef, columnId }; +} + +/** Every cell in the rectangle bounded by `a` and `b` (drag-select, shift-click). */ +export function rectangularCells( + orderedLineRefs: readonly string[], + columnIds: readonly string[], + a: GridCoord, + b: GridCoord, +): GridCoord[] { + const ri1 = orderedLineRefs.indexOf(a.lineRef); + const ri2 = orderedLineRefs.indexOf(b.lineRef); + const ci1 = columnIds.indexOf(a.columnId); + const ci2 = columnIds.indexOf(b.columnId); + if (ri1 < 0 || ri2 < 0 || ci1 < 0 || ci2 < 0) return []; + + const r0 = Math.min(ri1, ri2); + const r1 = Math.max(ri1, ri2); + const c0 = Math.min(ci1, ci2); + const c1 = Math.max(ci1, ci2); + + const out: GridCoord[] = []; + for (let r = r0; r <= r1; r++) { + const lineRef = orderedLineRefs[r]; + if (!lineRef) continue; + for (let c = c0; c <= c1; c++) { + const columnId = columnIds[c]; + if (columnId) out.push({ lineRef, columnId }); + } + } + return out; +} + +/** + * Projects arbitrary coordinates onto their bounding box and reads it out in + * document and schema order, which is what a TSV copy of a selection needs. + */ +export function coordsToRowsMatrix( + orderedLineRefs: readonly string[], + columnIdsOrdered: readonly string[], + coords: readonly GridCoord[], + getDisplay: (lineRef: string, columnId: string) => string, +): string[][] { + if (!coords.length || !orderedLineRefs.length || !columnIdsOrdered.length) return []; + + let r0 = Number.POSITIVE_INFINITY; + let r1 = Number.NEGATIVE_INFINITY; + let c0 = Number.POSITIVE_INFINITY; + let c1 = Number.NEGATIVE_INFINITY; + let found = false; + + for (const { lineRef, columnId } of coords) { + const ri = orderedLineRefs.indexOf(lineRef); + const ci = columnIdsOrdered.indexOf(columnId); + if (ri < 0 || ci < 0) continue; + found = true; + r0 = Math.min(r0, ri); + r1 = Math.max(r1, ri); + c0 = Math.min(c0, ci); + c1 = Math.max(c1, ci); + } + if (!found) return []; + + const rowRefs: string[] = []; + for (let r = r0; r <= r1; r++) { + const ref = orderedLineRefs[r]; + if (ref) rowRefs.push(ref); + } + + const columnSpan: string[] = []; + for (let c = c0; c <= c1; c++) { + const id = columnIdsOrdered[c]; + if (id) columnSpan.push(id); + } + + return rowRefs.map((ref) => columnSpan.map((id) => getDisplay(ref, id))); +} + +export function serializeMatrixTsv(rows: readonly string[][]): string { + return rows.map((r) => r.join("\t")).join("\n"); +} + +export function parseClipboardTsv(text: string): string[][] { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.trim()) return []; + const lines = normalized.split("\n"); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines.map((line) => line.split("\t")); +} + +export function sameCoord(a: GridCoord | null, b: GridCoord | null): boolean { + if (!a || !b) return false; + return a.lineRef === b.lineRef && a.columnId === b.columnId; +} diff --git a/packages/core/src/components/line-items/types.ts b/packages/core/src/components/line-items/types.ts new file mode 100644 index 000000000..b22f669fe --- /dev/null +++ b/packages/core/src/components/line-items/types.ts @@ -0,0 +1,299 @@ +import type { SelectionAction } from "@/components/data-table/types"; +import type { ReactNode } from "react"; + +/** + * One typed line-change op produced by `getChangeSet()`. The shape is + * transport-agnostic — apps map it onto GraphQL mutations or REST calls. + * + * - `add` → `{ tempId, data }`; `tempId` is client-only, the server mints the real id. + * - `update` → `{ lineId, patch }`; only changed fields appear in `patch`. + * - `remove` → `{ lineId }`. + * - `reorder` → `{ lineId, position }`; zero-based final index (manual ordering only). + */ +export type LineItemsLineChange = + | { action: "add"; tempId: string; data: LineItemsRowPatch } + | { action: "update"; lineId: string; patch: LineItemsRowPatch } + | { action: "remove"; lineId: string } + | { action: "reorder"; lineId: string; position: number }; + +export type LineItemsRowPatch = Record; + +/** Result of `useLineItems().getChangeSet()`. `isEmpty` short-circuits no-op submits. */ +export type LineItemsChangeSet = { + isEmpty: boolean; + lineChanges: LineItemsLineChange[]; +}; + +/** + * `"view"` renders every cell read-only. `"edit"` and `"amend"` each render an + * editor for fields whose `editable` list includes that mode, so `"amend"` is a + * partial lock: a document past confirmation where only a few fields (a memo, a + * received quantity) may still be corrected. Cells locked in `"amend"` are + * tinted so it is obvious what is still touchable. + */ +export type LineItemsMode = "view" | "edit" | "amend"; + +/** + * `"sort"` (default) lets column headers drive row order and emits no reorder + * ops. `"manual"` preserves document order, enables drag-reordering, and emits + * `reorder` ops in the change-set. + */ +export type LineItemsOrderingMode = "sort" | "manual"; + +export type LineItemsColumnAlign = "left" | "center" | "right"; + +/** + * Where a field's edits go. + * + * - `"document"` (default) bundles them into `getChangeSet()`. + * - `"metadata"` keeps them out of the change-set and emits them through + * `onMetadataCommit` instead, for values that save out-of-band — a journal + * memo that stays editable while the rest of the document is locked. + */ +export type LineItemsFieldCommit = "document" | "metadata"; + +/** One out-of-band cell edit, emitted for `commit: "metadata"` fields. */ +export type LineItemsMetadataCommit = { + lineRef: string; + patch: LineItemsRowPatch; + previous: LineItemsRowPatch; + row: T; +}; + +export type LineItemsSortDirection = "asc" | "desc"; + +/** Which column the grid is sorted by, under `ordering: "sort"`. */ +export type LineItemsSortState = { + columnId: string; + direction: LineItemsSortDirection; +}; + +/** Every row exposes a stable `lineRef` — the server id when persisted, else a generated temp id. */ +export type LineItemsRowData = Record & { lineRef: string }; + +/** One option for a `kind: "select"` field (SKU pickers, unit of measure, etc.). */ +export type LineItemsSelectOption = { + value: string; + label: string; + description?: string; +}; + +/** + * Context handed to a `kind: "custom"` field's `renderEditor`. Mirrors the + * contract the built-in editors follow so keyboard nav, commit, and + * dirty-tracking keep working around a custom editor. + */ +export type LineItemsCustomEditorContext = { + /** Current cell value, already passed through the field's `normalize` when set. */ + value: unknown; + /** Commit a new value into the line. Call on blur or Enter, not per keystroke. */ + onCommit: (next: unknown) => void; + /** Abandon the in-flight edit (Escape). The previous value is kept. */ + onCancel: () => void; + row: T; + mode: LineItemsMode; + field: LineItemsField; + /** Accessible name for the editor — column and line. Put it on your input. */ + ariaLabel: string; +}; + +/** + * Drives the editor input, formatting, normalization, and equality for an + * editable field. Omit for read-only or computed columns. + */ +export type LineItemsFieldType = + | { kind: "text" } + | { kind: "number"; decimals?: number } + | { + kind: "select"; + options: ReadonlyArray; + placeholder?: string; + } + | { kind: "boolean" } + | { kind: "date"; min?: string; max?: string } + | { + kind: "custom"; + /** Renders the in-cell editor only — the grid owns the selection ring and fill handle. */ + renderEditor: (ctx: LineItemsCustomEditorContext) => ReactNode; + /** Value coercion before equality. Mirrors `LineItemsField.normalize`. */ + normalize?: (value: unknown, row: T) => unknown; + /** Dirty-equality. Mirrors `LineItemsField.equals`. */ + equals?: (a: unknown, b: unknown, row: T) => boolean; + }; + +/** + * One column in the line-items table. + * + * `key` doubles as the column id and, for editable fields, the property name on + * `T`. Computed columns (a derived "total", say) pick any unique string for + * `key` and omit `editable` and `type` — they are excluded from the change-set. + * + * Build these with `createLineItemHelper().field({ … })` to get full + * inference of `key`, `render`, and `sort.comparator`. + */ +export type LineItemsField = { + /** Stable column id. For editable fields, use a real key on `T`. */ + key: (keyof T & string) | (string & {}); + label: ReactNode; + /** Cell content in view mode, and in edit mode whenever no editor is shown. */ + render: (line: T) => ReactNode; + /** + * Modes in which an editor replaces `render`. Omitted or empty means + * read-only. Typical editable line columns pass `["edit"]`. + */ + editable?: LineItemsMode[]; + /** Required when the field is editable; drives the editor input and equality semantics. */ + type?: LineItemsFieldType; + /** + * `"document"` (default) bundles edits into the change-set; `"metadata"` + * routes them to `onMetadataCommit` and omits them from it. + */ + commit?: LineItemsFieldCommit; + /** + * Makes the column header sortable, cycling ascending, descending, then + * unsorted. Ignored when `ordering` is `"manual"`, which preserves document + * order. + */ + sort?: { comparator: (a: T, b: T) => number }; + /** Returns `true` when `line` matches the active search query. */ + search?: (line: T, query: string) => boolean; + /** + * Horizontal alignment. When omitted, `number` fields default to `"right"` + * and `boolean` fields to `"center"`; everything else to `"left"`. + */ + align?: LineItemsColumnAlign; + className?: string | ((line: T) => string | undefined); + /** + * Coerce raw values before the dirty-equality compare and before they are + * emitted into the change-set. Defaults to trimming strings; `number` fields + * with `decimals` also get rounding. Override for app rules such as money + * rounding per currency. + */ + normalize?: (value: unknown, row: T) => unknown; + /** + * Dirty-equality. Defaults to `Object.is`, with a 1e-9 epsilon for `number` + * fields so cosmetic input like `"1"` vs `"1.00"` is not a document change. + */ + equals?: (a: unknown, b: unknown, row: T) => boolean; + /** Resting column width in pixels. Auto-sized when omitted. */ + width?: number; + /** + * Freeze the column to the left or right edge so it stays visible during + * horizontal scroll. Stacked pinned columns have their sticky offsets + * measured from the rendered layout, so `width` is not required — though + * setting it keeps pinned columns from resizing as row content changes. + */ + pin?: "left" | "right"; + /** + * When `true`, this column absorbs leftover horizontal space. Set it on the + * column with the longest content (a description, typically). Without it, a + * trailing spacer column absorbs the leftover instead so declared widths + * stay pixel-exact. + */ + flex?: boolean; +}; + +/** + * Who owns the rows. Uncontrolled: the hook, seeded once from `defaultLines` + * (`onLinesChange` may still observe edits). Controlled: the parent, through + * `lines` + `onLinesChange` — required together, since `lines` without a + * handler would silently drop every edit. + */ +type LineItemsRowSource = + | { + /** Uncontrolled seed rows, also used as the dirty-tracking baseline. Read once on mount. */ + defaultLines?: readonly T[]; + lines?: never; + /** Observes each mutation with the new rows. */ + onLinesChange?: (next: T[]) => void; + } + | { + /** Controlled rows. */ + lines: readonly T[]; + /** Called once per mutation with the new rows; feed them back into `lines`. */ + onLinesChange: (next: T[]) => void; + defaultLines?: never; + }; + +export type UseLineItemsOptions = LineItemsRowSource & { + /** Column schema. Build with `createLineItemHelper().field({ … })`. */ + fields: LineItemsField[]; + /** Defaults to `"edit"`. */ + mode?: LineItemsMode; + /** Defaults to `"sort"`. Pass `"manual"` to enable drag-reordering. */ + ordering?: LineItemsOrderingMode; + /** + * `true` adds the row-selection checkbox column. `{ actions }` also shows a + * bulk-action bar at the foot of `LineItems.Table` while lines are selected — + * the same bar, and the same `SelectionAction` shape, as DataTable. + */ + selection?: boolean | { actions: SelectionAction[] }; + /** Receives edits to `commit: "metadata"` fields, which bypass the change-set. */ + onMetadataCommit?: (event: LineItemsMetadataCommit) => void; +}; + +export type UseLineItemsReturn = { + /** Document order with the active search filter applied. */ + lines: T[]; + /** Full document order, ignoring the search filter. */ + allLines: T[]; + fields: LineItemsField[]; + mode: LineItemsMode; + ordering: LineItemsOrderingMode; + /** `true` when current state diverges from the baseline. */ + isDirty: boolean; + filter: string; + selectionEnabled: boolean; + selectedIds: string[]; + /** The selected lines, in document order. */ + selectedRows: T[]; + selectionActions: SelectionAction[]; + /** Id of the selection action whose request is in flight, or `null`. */ + pendingActionId: string | null; + /** + * Tracks a selection action's request: the bar disables its actions while it + * is pending and clears the selection once it resolves, unless the action + * sets `keepSelection`. The bar calls this for you. + */ + runSelectionAction: + | ((action: { id: string; keepSelection?: boolean }, request: PromiseLike) => void) + | undefined; + /** Active sort, or `null` when in document order. Always `null` under manual ordering. */ + sortState: LineItemsSortState | null; + + setMode: (mode: LineItemsMode) => void; + setFilter: (query: string) => void; + /** Cycles the column through ascending, descending, then unsorted. */ + toggleSort: (columnId: string) => void; + /** Inserts one line and returns its new `lineRef`. */ + addLine: (data: Partial>, opts?: { afterLineRef?: string | null }) => string; + /** + * Inserts several lines in a single render, returning the new `lineRef`s in + * input order. Prefer this over looping `addLine` — a bulk picker adding N + * rows should produce one render and N `add` ops, not N renders. + */ + addLines: ( + items: ReadonlyArray>>, + opts?: { afterLineRef?: string | null }, + ) => string[]; + removeLine: (lineRef: string) => void; + /** Removes several lines in one update — for a bulk action's `rows`. */ + removeLines: (lineRefs: readonly string[]) => void; + updateField: (lineRef: string, key: K, value: T[K]) => void; + /** Batched updates — one render for a whole paste or fill-drag. */ + updateLines: (patches: { lineRef: string; patch: Partial }[]) => void; + /** Moves a line after `afterLineRef`, or to the head when `null`. Manual ordering only. */ + reorderLine: (lineRef: string, afterLineRef: string | null) => void; + toggleSelect: (lineRef: string) => void; + /** Selects every row currently passing the search filter. */ + selectAllVisible: () => void; + clearSelection: () => void; + bulkUpdate: (patch: Partial) => void; + bulkRemove: () => void; + duplicateLastLine: (derive?: (prev: T, newRef: string) => T) => string | undefined; + /** Snaps the baseline forward to current state. Call after a successful save. */ + reset: () => void; + /** Discards every uncommitted edit, insert, and removal back to the baseline. */ + revert: () => void; + getChangeSet: () => LineItemsChangeSet; +}; diff --git a/packages/core/src/components/line-items/use-grid-selection.ts b/packages/core/src/components/line-items/use-grid-selection.ts new file mode 100644 index 000000000..7c55f68e7 --- /dev/null +++ b/packages/core/src/components/line-items/use-grid-selection.ts @@ -0,0 +1,157 @@ +import * as React from "react"; + +import { + moveSelectionCoord, + rectangularCells, + sameCoord, + type GridCoord, + type GridDirection, +} from "./spreadsheet-logic"; + +/** Flattens a coordinate into a Set key so per-cell lookups stay O(1). */ +export function coordKey(lineRef: string, columnId: string): string { + return `${lineRef}::${columnId}`; +} + +export type GridSelection = { + /** The focused cell — the only one that mounts an editor. */ + active: GridCoord | null; + /** Keys of every cell in the selected rectangle. */ + selected: ReadonlySet; + /** True while a pointer range-drag is in progress. */ + dragging: boolean; +}; + +export type UseGridSelectionResult = GridSelection & { + setActive: (coord: GridCoord | null) => void; + /** Pointer-down on a cell: starts a new range, or extends it when `shift` is held. */ + beginSelection: (coord: GridCoord, opts?: { shift?: boolean }) => void; + /** Pointer-enter during a drag: extends the rectangle to `coord`. */ + extendSelection: (coord: GridCoord) => void; + endSelection: () => void; + /** Arrow-key navigation. Returns the coord moved to, or `null` at the edge. */ + moveActive: (dir: GridDirection, opts?: { shift?: boolean }) => GridCoord | null; + clearSelection: () => void; + isSelected: (lineRef: string, columnId: string) => boolean; +}; + +/** + * Owns the editable grid's cell selection. + * + * The selection anchor is only ever written by pointer-down and keyboard + * navigation. A stray native `focus` event must never move it, or a shift-click + * would collapse the very range it was meant to extend. + * + * `orderedLineRefs` and `columnIds` are expected to be memoized by the caller — + * a new array identity every render would rebuild every handler here and + * remount the active cell's editor mid-keystroke. + */ +export function useGridSelection( + orderedLineRefs: readonly string[], + columnIds: readonly string[], +): UseGridSelectionResult { + const [active, setActiveState] = React.useState(null); + const [selected, setSelected] = React.useState>(() => new Set()); + const [dragging, setDragging] = React.useState(false); + + const anchor = React.useRef(null); + + const paintRange = React.useCallback( + (from: GridCoord, to: GridCoord) => { + const cells = rectangularCells(orderedLineRefs, columnIds, from, to); + const next = new Set(); + for (const c of cells) next.add(coordKey(c.lineRef, c.columnId)); + setSelected(next); + }, + [columnIds, orderedLineRefs], + ); + + const setActive = React.useCallback((coord: GridCoord | null) => { + setActiveState(coord); + anchor.current = coord; + setSelected(coord ? new Set([coordKey(coord.lineRef, coord.columnId)]) : new Set()); + }, []); + + const beginSelection = React.useCallback( + (coord: GridCoord, opts?: { shift?: boolean }) => { + if (opts?.shift && anchor.current) { + // Shift-clicking across cells would otherwise leave the browser's own + // text selection painted over the range. Drop it before painting ours. + globalThis.getSelection?.()?.removeAllRanges(); + setActiveState(coord); + paintRange(anchor.current, coord); + return; + } + setActiveState(coord); + anchor.current = coord; + setSelected(new Set([coordKey(coord.lineRef, coord.columnId)])); + setDragging(true); + }, + [paintRange], + ); + + const extendSelection = React.useCallback( + (coord: GridCoord) => { + if (!dragging || !anchor.current) return; + if (sameCoord(anchor.current, coord)) return; + paintRange(anchor.current, coord); + }, + [dragging, paintRange], + ); + + const endSelection = React.useCallback(() => { + setDragging(false); + }, []); + + const moveActive = React.useCallback( + (dir: GridDirection, opts?: { shift?: boolean }): GridCoord | null => { + if (!active) return null; + const next = moveSelectionCoord(dir, active, orderedLineRefs, columnIds); + if (!next || sameCoord(next, active)) return null; + + setActiveState(next); + if (opts?.shift && anchor.current) { + paintRange(anchor.current, next); + } else { + anchor.current = next; + setSelected(new Set([coordKey(next.lineRef, next.columnId)])); + } + return next; + }, + [active, columnIds, orderedLineRefs, paintRange], + ); + + const clearSelection = React.useCallback(() => { + setActiveState(null); + anchor.current = null; + setSelected(new Set()); + setDragging(false); + }, []); + + // A pointer released outside the grid still has to end the drag, or the next + // hover over a cell would keep extending a range the user already dropped. + React.useEffect(() => { + if (!dragging) return; + const stop = () => setDragging(false); + globalThis.addEventListener("pointerup", stop); + return () => globalThis.removeEventListener("pointerup", stop); + }, [dragging]); + + const isSelected = React.useCallback( + (lineRef: string, columnId: string) => selected.has(coordKey(lineRef, columnId)), + [selected], + ); + + return { + active, + selected, + dragging, + setActive, + beginSelection, + extendSelection, + endSelection, + moveActive, + clearSelection, + isSelected, + }; +} diff --git a/packages/core/src/components/line-items/use-line-items-group.test.ts b/packages/core/src/components/line-items/use-line-items-group.test.ts new file mode 100644 index 000000000..9c1970d34 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items-group.test.ts @@ -0,0 +1,110 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createLineItemHelper } from "./field"; +import type { LineItemsField } from "./types"; +import { useLineItems } from "./use-line-items"; +import { useLineItemsGroup } from "./use-line-items-group"; + +type JournalLine = { lineRef: string; account: string; amount: number }; + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "account", + label: "Account", + render: (l) => l.account, + editable: ["edit"], + type: { kind: "text" }, + }), + f.field({ + key: "amount", + label: "Amount", + render: (l) => l.amount, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + }), +]; + +function setup() { + return renderHook(() => { + const debits = useLineItems({ + fields, + defaultLines: [{ lineRef: "d1", account: "Cash", amount: 100 }], + }); + const credits = useLineItems({ + fields, + defaultLines: [{ lineRef: "c1", account: "Receivables", amount: 100 }], + }); + return { debits, credits, group: useLineItemsGroup({ debits, credits }) }; + }); +} + +describe("useLineItemsGroup", () => { + it("is dirty when any member is dirty", () => { + const { result } = setup(); + expect(result.current.group.isDirty).toBe(false); + + act(() => result.current.debits.updateField("d1", "amount", 200)); + expect(result.current.group.isDirty).toBe(true); + }); + + it("returns each member's change-set keyed by name, with isEmpty rolled up", () => { + const { result } = setup(); + let cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(true); + expect(cs.changeSets.debits.lineChanges).toEqual([]); + expect(cs.changeSets.credits.lineChanges).toEqual([]); + + act(() => result.current.credits.updateField("c1", "amount", 250)); + cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(false); + expect(cs.changeSets.debits.isEmpty).toBe(true); + expect(cs.changeSets.credits.lineChanges).toEqual([ + { action: "update", lineId: "c1", patch: { amount: 250 } }, + ]); + }); + + it("does not let a member named isEmpty clobber the rolled-up flag", () => { + const { result } = renderHook(() => { + const isEmpty = useLineItems({ + fields, + defaultLines: [{ lineRef: "x1", account: "Cash", amount: 1 }], + }); + return { isEmpty, group: useLineItemsGroup({ isEmpty }) }; + }); + act(() => result.current.isEmpty.updateField("x1", "amount", 2)); + + const cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(false); + expect(cs.changeSets.isEmpty.isEmpty).toBe(false); + }); + + it("revert() rolls back every member", () => { + const { result } = setup(); + act(() => { + result.current.debits.updateField("d1", "amount", 999); + result.current.credits.updateField("c1", "amount", 999); + }); + expect(result.current.group.isDirty).toBe(true); + + act(() => result.current.group.revert()); + expect(result.current.group.isDirty).toBe(false); + expect(result.current.debits.allLines[0]!.amount).toBe(100); + expect(result.current.credits.allLines[0]!.amount).toBe(100); + }); + + it("reset() snaps every member's baseline forward", () => { + const { result } = setup(); + act(() => { + result.current.debits.updateField("d1", "amount", 300); + result.current.credits.updateField("c1", "amount", 300); + }); + + act(() => result.current.group.reset()); + expect(result.current.group.isDirty).toBe(false); + expect(result.current.group.getChangeSet().isEmpty).toBe(true); + expect(result.current.debits.allLines[0]!.amount).toBe(300); + }); +}); diff --git a/packages/core/src/components/line-items/use-line-items-group.ts b/packages/core/src/components/line-items/use-line-items-group.ts new file mode 100644 index 000000000..25cda96b1 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items-group.ts @@ -0,0 +1,76 @@ +import type { LineItemsChangeSet, UseLineItemsReturn } from "./types"; + +/** + * `useLineItems` returns keyed by collection name. The row type is erased so + * one document can mix collections with different row shapes (component lines + * and operation lines under one Work Order). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous row types per member +export type LineItemsGroupMembers = Record>; + +/** The group's change-set: one entry per member, plus a rolled-up `isEmpty`. */ +export type LineItemsGroupChangeSet = { + /** True when every member's change-set is empty. */ + isEmpty: boolean; + /** Each member's change-set, keyed by the names passed to `useLineItemsGroup`. */ + changeSets: { [K in keyof G]: LineItemsChangeSet }; +}; + +export type UseLineItemsGroupReturn = { + /** The members passed in, for forwarding into each `LineItems.Root`. */ + members: G; + /** True when any member has uncommitted changes. */ + isDirty: boolean; + /** Collects every member's change-set for one document-level submit. */ + getChangeSet: () => LineItemsGroupChangeSet; + /** Snaps every member's baseline forward. Call after a successful save. */ + reset: () => void; + /** Discards every member's changes back to its baseline. */ + revert: () => void; +}; + +/** + * Puts several `useLineItems` collections under one save boundary — for a + * document that owns more than one list of lines, like a Journal Entry with + * debits and credits, or a Work Order with components and operations. Each + * collection keeps its own table, selection and dirty tracking; the group adds + * a shared `isDirty`, a keyed change-set, and one Save / Discard. + * + * @example + * ```tsx + * const debits = useLineItems({ fields, defaultLines: entry.debits }); + * const credits = useLineItems({ fields, defaultLines: entry.credits }); + * const group = useLineItemsGroup({ debits, credits }); + * + * const onSave = async () => { + * const { isEmpty, changeSets } = group.getChangeSet(); + * if (isEmpty) return; + * await saveJournalEntry(entry.id, changeSets.debits, changeSets.credits); + * group.reset(); + * }; + * ``` + */ +export function useLineItemsGroup( + members: G, +): UseLineItemsGroupReturn { + const all = Object.values(members); + + const getChangeSet = (): LineItemsGroupChangeSet => { + const changeSets = {} as { [K in keyof G]: LineItemsChangeSet }; + for (const key of Object.keys(members) as (keyof G)[]) { + changeSets[key] = members[key].getChangeSet(); + } + return { + isEmpty: Object.values(changeSets).every((cs) => cs.isEmpty), + changeSets, + }; + }; + + return { + members, + isDirty: all.some((m) => m.isDirty), + getChangeSet, + reset: () => all.forEach((m) => m.reset()), + revert: () => all.forEach((m) => m.revert()), + }; +} diff --git a/packages/core/src/components/line-items/use-line-items.test.ts b/packages/core/src/components/line-items/use-line-items.test.ts new file mode 100644 index 000000000..42fcddd34 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items.test.ts @@ -0,0 +1,507 @@ +import { act, renderHook } from "@testing-library/react"; +import * as React from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { createLineItemHelper } from "./field"; +import type { LineItemsField, LineItemsMode, UseLineItemsOptions } from "./types"; +import { useLineItems } from "./use-line-items"; + +type Line = { + lineRef: string; + sku: string; + quantity: number; + total: number; +}; + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + sort: { comparator: (a, b) => a.quantity - b.quantity }, + }), + f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2) }), +]; + +const seed: Line[] = [ + { lineRef: "1", sku: "APPLE-01", quantity: 2, total: 10 }, + { lineRef: "2", sku: "BANANA-02", quantity: 5, total: 25 }, +]; + +function setup( + options?: Pick, "selection" | "mode" | "ordering"> & { + defaultLines?: readonly Line[]; + }, +) { + return renderHook(() => + useLineItems({ fields, defaultLines: seed, selection: true, ...options }), + ); +} + +describe("useLineItems", () => { + it("seeds lines from data and starts clean", () => { + const { result } = setup(); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().isEmpty).toBe(true); + }); + + it("generates a lineRef for rows that arrive without one", () => { + const { result } = setup({ + defaultLines: [{ sku: "NO-REF", quantity: 1, total: 0 } as unknown as Line], + }); + expect(result.current.allLines[0]?.lineRef).toBeTruthy(); + }); + + describe("mutations", () => { + it("adds a line and reports it as an add op", () => { + const { result } = setup(); + let newRef = ""; + act(() => { + newRef = result.current.addLine({ sku: "CHERRY-03", quantity: 1 }); + }); + + expect(result.current.allLines).toHaveLength(3); + expect(result.current.isDirty).toBe(true); + expect(result.current.getChangeSet().lineChanges).toContainEqual({ + action: "add", + tempId: newRef, + data: { sku: "CHERRY-03", quantity: 1 }, + }); + }); + + it("adds many lines in one batch, producing one add op each", () => { + const { result } = setup(); + act(() => { + result.current.addLines([{ sku: "A" }, { sku: "B" }, { sku: "C" }]); + }); + + expect(result.current.allLines).toHaveLength(5); + const adds = result.current.getChangeSet().lineChanges.filter((c) => c.action === "add"); + expect(adds).toHaveLength(3); + }); + + it("inserts after a given line when asked", () => { + const { result } = setup(); + act(() => { + result.current.addLine({ sku: "MIDDLE" }, { afterLineRef: "1" }); + }); + expect(result.current.allLines.map((l) => l.sku)).toEqual([ + "APPLE-01", + "MIDDLE", + "BANANA-02", + ]); + }); + + it("prepends when afterLineRef is null", () => { + const { result } = setup(); + act(() => { + result.current.addLine({ sku: "FIRST" }, { afterLineRef: null }); + }); + expect(result.current.allLines[0]?.sku).toBe("FIRST"); + }); + + it("updates a field and reports only that field", () => { + const { result } = setup(); + act(() => { + result.current.updateField("1", "quantity", 9); + }); + + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "update", lineId: "1", patch: { quantity: 9 } }, + ]); + }); + + it("removes a persisted line as a remove op", () => { + const { result } = setup(); + act(() => { + result.current.removeLine("2"); + }); + + expect(result.current.allLines).toHaveLength(1); + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "remove", lineId: "2" }, + ]); + }); + + it("removing a freshly added line leaves no trace in the change-set", () => { + const { result } = setup(); + let ref = ""; + act(() => { + ref = result.current.addLine({ sku: "TEMP" }); + }); + act(() => { + result.current.removeLine(ref); + }); + + expect(result.current.getChangeSet().isEmpty).toBe(true); + expect(result.current.isDirty).toBe(false); + }); + + it("applies batched updates in a single commit", () => { + const { result } = setup(); + act(() => { + result.current.updateLines([ + { lineRef: "1", patch: { quantity: 11 } }, + { lineRef: "2", patch: { quantity: 22 } }, + ]); + }); + + const changes = result.current.getChangeSet().lineChanges; + expect(changes).toHaveLength(2); + expect(changes.every((c) => c.action === "update")).toBe(true); + }); + + it("duplicates the last line as a new add", () => { + const { result } = setup(); + act(() => { + result.current.duplicateLastLine(); + }); + + expect(result.current.allLines).toHaveLength(3); + expect(result.current.allLines[2]?.sku).toBe("BANANA-02"); + expect(result.current.allLines[2]?.lineRef).not.toBe("2"); + }); + }); + + describe("ordering", () => { + it("ignores reorderLine under sort ordering", () => { + const { result } = setup({ ordering: "sort" }); + act(() => { + result.current.reorderLine("2", null); + }); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("moves a line and emits reorder ops under manual ordering", () => { + const { result } = setup({ ordering: "manual" }); + act(() => { + result.current.reorderLine("2", null); + }); + + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["2", "1"]); + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "reorder", lineId: "2", position: 0 }, + { action: "reorder", lineId: "1", position: 1 }, + ]); + }); + + it("emits nothing when a line is moved back to where it started", () => { + const { result } = setup({ ordering: "manual" }); + act(() => { + result.current.reorderLine("1", "2"); + }); + act(() => { + result.current.reorderLine("1", null); + }); + + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + expect(result.current.getChangeSet().isEmpty).toBe(true); + }); + }); + + describe("sort", () => { + it("starts unsorted, in document order", () => { + const { result } = setup(); + expect(result.current.sortState).toBeNull(); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("cycles ascending, descending, then back to document order", () => { + const { result } = setup(); + + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toEqual({ columnId: "quantity", direction: "asc" }); + expect(result.current.lines.map((l) => l.quantity)).toEqual([2, 5]); + + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toEqual({ columnId: "quantity", direction: "desc" }); + expect(result.current.lines.map((l) => l.quantity)).toEqual([5, 2]); + + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toBeNull(); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("leaves allLines in document order while sorted", () => { + const { result } = setup(); + act(() => { + result.current.toggleSort("quantity"); + result.current.toggleSort("quantity"); + }); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["2", "1"]); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("ignores a column with no comparator", () => { + const { result } = setup(); + act(() => { + result.current.toggleSort("total"); + }); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("is inert under manual ordering, which owns row order", () => { + const { result } = setup({ ordering: "manual" }); + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toBeNull(); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("does not make the document dirty", () => { + const { result } = setup(); + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().isEmpty).toBe(true); + }); + }); + + describe("baseline", () => { + it("reset snaps the baseline forward and clears dirty", () => { + const { result } = setup(); + act(() => { + result.current.updateField("1", "quantity", 9); + }); + expect(result.current.isDirty).toBe(true); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.allLines[0]?.quantity).toBe(9); + }); + + it("revert restores rows and drops inserts and removals", () => { + const { result } = setup(); + act(() => { + result.current.updateField("1", "quantity", 9); + result.current.addLine({ sku: "TEMP" }); + result.current.removeLine("2"); + }); + expect(result.current.isDirty).toBe(true); + + act(() => { + result.current.revert(); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + expect(result.current.allLines[0]?.quantity).toBe(2); + }); + }); + + describe("filter", () => { + it("narrows lines by the searchable fields but leaves allLines intact", () => { + const { result } = setup(); + act(() => { + result.current.setFilter("banana"); + }); + + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["2"]); + expect(result.current.allLines).toHaveLength(2); + }); + + it("returns everything for a blank query", () => { + const { result } = setup(); + act(() => { + result.current.setFilter(" "); + }); + expect(result.current.lines).toHaveLength(2); + }); + }); + + describe("selection", () => { + it("toggles a row on and off", () => { + const { result } = setup(); + act(() => { + result.current.toggleSelect("1"); + }); + expect(result.current.selectedIds).toEqual(["1"]); + + act(() => { + result.current.toggleSelect("1"); + }); + expect(result.current.selectedIds).toEqual([]); + }); + + it("ignores selection when disabled", () => { + const { result } = setup({ selection: false }); + act(() => { + result.current.toggleSelect("1"); + }); + expect(result.current.selectedIds).toEqual([]); + }); + + it("selects only the rows passing the filter", () => { + const { result } = setup(); + act(() => { + result.current.setFilter("banana"); + }); + act(() => { + result.current.selectAllVisible(); + }); + expect(result.current.selectedIds).toEqual(["2"]); + }); + + it("bulk-updates every selected row", () => { + const { result } = setup(); + act(() => { + result.current.selectAllVisible(); + }); + act(() => { + result.current.bulkUpdate({ quantity: 42 }); + }); + + expect(result.current.allLines.every((l) => l.quantity === 42)).toBe(true); + }); + + it("bulk-removes every selected row and clears the selection", () => { + const { result } = setup(); + act(() => { + result.current.selectAllVisible(); + }); + act(() => { + result.current.bulkRemove(); + }); + + expect(result.current.allLines).toEqual([]); + expect(result.current.selectedIds).toEqual([]); + }); + + it("drops a removed row from the selection", () => { + const { result } = setup(); + act(() => { + result.current.toggleSelect("2"); + }); + act(() => { + result.current.removeLine("2"); + }); + expect(result.current.selectedIds).toEqual([]); + }); + }); + + describe("controlled mode", () => { + it("requires onLinesChange alongside lines at the type level", () => { + // @ts-expect-error — `lines` without `onLinesChange` would silently drop every edit. + const options: UseLineItemsOptions = { fields, lines: seed }; + expect(options.fields).toBe(fields); + }); + + /** The usual `lines` + `onLinesChange` loop, with the parent echoing every change. */ + function renderRoundTrip() { + return renderHook(() => { + const [lines, setLines] = React.useState(seed); + return useLineItems({ fields, lines, onLinesChange: setLines }); + }); + } + + it("keeps an added line in the change-set after the parent echoes it back", () => { + const { result } = renderRoundTrip(); + act(() => void result.current.addLine({ sku: "NEW-01", quantity: 1, total: 0 })); + + expect(result.current.allLines).toHaveLength(3); + expect(result.current.isDirty).toBe(true); + expect(result.current.getChangeSet().lineChanges.map((c) => c.action)).toEqual(["add"]); + }); + + it("keeps a removed line in the change-set after the parent echoes it back", () => { + const { result } = renderRoundTrip(); + act(() => result.current.removeLine("2")); + + expect(result.current.isDirty).toBe(true); + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "remove", lineId: "2" }, + ]); + }); + + it("notifies the parent once per mutation", () => { + const onLinesChange = vi.fn(); + const { result } = renderHook(() => + useLineItems({ fields, lines: seed, onLinesChange }), + ); + + expect(onLinesChange).not.toHaveBeenCalled(); + + act(() => { + result.current.updateField("1", "quantity", 3); + }); + + expect(onLinesChange).toHaveBeenCalledTimes(1); + expect(onLinesChange.mock.calls[0]?.[0]?.[0]?.quantity).toBe(3); + }); + + it("re-seeds when the set of lineRefs changes", () => { + const { result, rerender } = renderHook( + ({ lines }: { lines: Line[] }) => + useLineItems({ fields, lines, onLinesChange: () => {} }), + { initialProps: { lines: seed } }, + ); + + rerender({ lines: [...seed, { lineRef: "3", sku: "CHERRY-03", quantity: 1, total: 5 }] }); + + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + expect(result.current.isDirty).toBe(false); + }); + + it("keeps local edits when the parent re-renders with the same lineRefs", () => { + const { result, rerender } = renderHook( + ({ lines }: { lines: Line[] }) => + useLineItems({ fields, lines, onLinesChange: () => {} }), + { initialProps: { lines: seed } }, + ); + + act(() => { + result.current.updateField("1", "quantity", 99); + }); + rerender({ lines: [...seed] }); + + expect(result.current.allLines[0]?.quantity).toBe(99); + expect(result.current.isDirty).toBe(true); + }); + }); + + describe("mode", () => { + it("defaults to edit and follows the prop", () => { + const { result, rerender } = renderHook( + ({ mode }: { mode: LineItemsMode }) => + useLineItems({ fields, defaultLines: seed, mode }), + { initialProps: { mode: "edit" as LineItemsMode } }, + ); + expect(result.current.mode).toBe("edit"); + + rerender({ mode: "view" }); + expect(result.current.mode).toBe("view"); + }); + + it("can be driven imperatively", () => { + const { result } = setup(); + act(() => { + result.current.setMode("view"); + }); + expect(result.current.mode).toBe("view"); + }); + }); +}); diff --git a/packages/core/src/components/line-items/use-line-items.ts b/packages/core/src/components/line-items/use-line-items.ts new file mode 100644 index 000000000..c52e17521 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items.ts @@ -0,0 +1,525 @@ +import * as React from "react"; +import { useSelectionActionRunner } from "@/components/internals/selection-actions/use-selection-action-runner"; +import { fieldCommitScope, fieldsToColumnDefs } from "./field"; +import { + buildChangeSet, + cloneBaseline, + cloneRow, + type LineItemsBaseline, + type LineItemsColumnDef, +} from "./internals"; +import type { + LineItemsChangeSet, + LineItemsMetadataCommit, + LineItemsMode, + LineItemsRowData, + LineItemsSortState, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; + +type RowState = { + byRef: Record; + order: string[]; +}; + +/** + * Dirty-tracking state. `inserted` and `removed` track lines added or deleted + * since the baseline, which lets the change-set engine tell an insert apart + * from an edit of a persisted line. + */ +type DirtyState = { + baseline: LineItemsBaseline; + inserted: ReadonlySet; + removed: ReadonlySet; +}; + +/** + * The single source of truth for a line-items document: row order and lookup, + * the dirty-tracking baseline behind `isDirty` and `getChangeSet()`, selection + * and filter state, and every mutation. + * + * Pair with `` to render, or drive the + * mutations directly from your own controls. + */ +/** Stable default, so a table without bulk actions never changes identity. */ +const EMPTY_ACTIONS: never[] = []; + +export function useLineItems( + options: UseLineItemsOptions, +): UseLineItemsReturn { + const { + fields, + defaultLines, + lines: controlledLines, + onLinesChange, + mode: modeProp = "edit", + ordering = "sort", + selection: selectionOption = false, + onMetadataCommit, + } = options; + const selection = selectionOption !== false; + const selectionActions = + typeof selectionOption === "object" ? selectionOption.actions : EMPTY_ACTIONS; + + const controlled = controlledLines !== undefined; + const seed = controlled ? controlledLines : defaultLines; + + const [rows, setRows] = React.useState>(() => packLines(sanitizeRows(seed ?? []))); + const [dirty, setDirty] = React.useState>(() => ({ + baseline: cloneBaseline(rows.order, rows.byRef), + inserted: new Set(), + removed: new Set(), + })); + + /** + * Re-seeds from a controlled `lines` prop only when its set of `lineRef`s + * changes, not on every value change — a parent re-rendering with equal rows + * must not clobber in-flight local edits. + */ + const seededRefs = React.useRef(undefined); + React.useEffect(() => { + if (!controlled) return; + const key = serializeRefs(controlledLines ?? []); + if (seededRefs.current === key) return; + seededRefs.current = key; + // A re-seed is not a local edit, so it must not echo back to the parent. + pendingNotify.current = false; + const next = packLines(sanitizeRows(controlledLines ?? [])); + setRows(next); + setDirty({ + baseline: cloneBaseline(next.order, next.byRef), + inserted: new Set(), + removed: new Set(), + }); + }, [controlled, controlledLines]); + + const [mode, setModeState] = React.useState(modeProp); + React.useEffect(() => { + setModeState(modeProp); + }, [modeProp]); + + const [filter, setFilterState] = React.useState(""); + const [sortState, setSortState] = React.useState(null); + const [selectedSet, setSelectedSet] = React.useState>( + () => new Set(), + ); + + const columns = React.useMemo[]>( + () => fieldsToColumnDefs(fields), + [fields], + ); + + const onLinesChangeRef = React.useRef(onLinesChange); + React.useEffect(() => { + onLinesChangeRef.current = onLinesChange; + }, [onLinesChange]); + + const onMetadataCommitRef = React.useRef(onMetadataCommit); + React.useEffect(() => { + onMetadataCommitRef.current = onMetadataCommit; + }, [onMetadataCommit]); + + /** Keys whose edits bypass the change-set and are reported out-of-band instead. */ + const metadataKeys = React.useMemo(() => { + const keys = new Set(); + for (const f of fields) { + if ((f.editable?.length ?? 0) > 0 && fieldCommitScope(f) === "metadata") keys.add(f.key); + } + return keys; + }, [fields]); + + /** + * Set by every local mutation so the notify effect below can tell a real edit + * apart from the initial mount or a re-seed from the `lines` prop, neither of + * which should echo back to the parent. + */ + const pendingNotify = React.useRef(false); + + const metadataKeysRef = React.useRef(metadataKeys); + metadataKeysRef.current = metadataKeys; + + const rowsRef = React.useRef(rows); + rowsRef.current = rows; + + const addLines = React.useCallback( + ( + items: ReadonlyArray>>, + opts?: { afterLineRef?: string | null }, + ): string[] => { + if (items.length === 0) return []; + const newRefs = items.map(() => newLineRef()); + pendingNotify.current = true; + + setRows((prev) => { + const byRef = { ...prev.byRef }; + items.forEach((partial, i) => { + const id = newRefs[i]!; + byRef[id] = { ...(partial as Record), lineRef: id } as T; + }); + return { byRef, order: insertAfter(prev.order, newRefs, opts?.afterLineRef) }; + }); + + setDirty((prev) => { + const inserted = new Set(prev.inserted); + for (const id of newRefs) inserted.add(id); + return { ...prev, inserted }; + }); + + return newRefs; + }, + [], + ); + + const addLine = React.useCallback( + (partial: Partial>, opts?: { afterLineRef?: string | null }): string => + addLines([partial], opts)[0]!, + [addLines], + ); + + const removeLines = React.useCallback((refs: readonly string[]) => { + if (refs.length === 0) return; + const doomed = new Set(refs); + pendingNotify.current = true; + + setRows((prev) => { + const byRef = { ...prev.byRef }; + for (const id of doomed) delete byRef[id]; + return { byRef, order: prev.order.filter((id) => !doomed.has(id)) }; + }); + + setDirty((prev) => { + const inserted = new Set(prev.inserted); + const removed = new Set(prev.removed); + for (const id of doomed) { + inserted.delete(id); + if (prev.baseline.rows[id]) removed.add(id); + } + return { ...prev, inserted, removed }; + }); + + setSelectedSet((prev) => { + if (prev.size === 0) return prev; + const next = new Set(prev); + for (const id of doomed) next.delete(id); + return next.size === prev.size ? prev : next; + }); + }, []); + + const removeLine = React.useCallback((lineRef: string) => removeLines([lineRef]), [removeLines]); + + const updateLines = React.useCallback((patches: { lineRef: string; patch: Partial }[]) => { + if (patches.length === 0) return; + pendingNotify.current = true; + + if (metadataKeysRef.current.size > 0 && onMetadataCommitRef.current) { + const current = rowsRef.current; + for (const { lineRef, patch } of patches) { + const row = current.byRef[lineRef]; + if (!row) continue; + const metaPatch: Record = {}; + const previous: Record = {}; + for (const key of Object.keys(patch)) { + if (!metadataKeysRef.current.has(key)) continue; + metaPatch[key] = (patch as Record)[key]; + previous[key] = (row as Record)[key]; + } + if (Object.keys(metaPatch).length === 0) continue; + onMetadataCommitRef.current({ + lineRef, + patch: metaPatch, + previous, + row: { ...row, ...patch } as T, + } satisfies LineItemsMetadataCommit); + } + } + + setRows((prev) => { + const byRef = { ...prev.byRef }; + let touched = false; + for (const { lineRef, patch } of patches) { + const cur = byRef[lineRef]; + if (!cur) continue; + byRef[lineRef] = { ...cur, ...patch } as T; + touched = true; + } + if (!touched) return prev; + return { byRef, order: prev.order }; + }); + }, []); + + const updateField = React.useCallback( + (lineRef: string, key: K, value: T[K]) => { + updateLines([{ lineRef, patch: { [key]: value } as unknown as Partial }]); + }, + [updateLines], + ); + + const reorderLine = React.useCallback( + (lineRef: string, afterLineRef: string | null) => { + if (ordering !== "manual") return; + pendingNotify.current = true; + setRows((prev) => { + if (!prev.byRef[lineRef]) return prev; + const without = prev.order.filter((id) => id !== lineRef); + return { byRef: prev.byRef, order: insertAfter(without, [lineRef], afterLineRef) }; + }); + }, + [ordering], + ); + + const duplicateLastLine = React.useCallback( + (derive?: (prev: T, newRef: string) => T): string | undefined => { + const last = rows.order.at(-1); + const src = last ? rows.byRef[last] : undefined; + if (!src) return undefined; + const id = newLineRef(); + const row = derive ? derive(cloneRow(src), id) : { ...cloneRow(src), lineRef: id }; + pendingNotify.current = true; + + setRows((prev) => ({ byRef: { ...prev.byRef, [id]: row }, order: [...prev.order, id] })); + setDirty((prev) => ({ ...prev, inserted: new Set(prev.inserted).add(id) })); + return id; + }, + [rows], + ); + + const reset = React.useCallback(() => { + setDirty({ + baseline: cloneBaseline(rows.order, rows.byRef), + inserted: new Set(), + removed: new Set(), + }); + }, [rows]); + + const revert = React.useCallback(() => { + const order = [...dirty.baseline.order]; + const byRef: Record = {}; + for (const id of order) { + const row = dirty.baseline.rows[id]; + if (row) byRef[id] = cloneRow(row); + } + pendingNotify.current = true; + setRows({ byRef, order }); + setDirty((prev) => ({ + ...prev, + inserted: new Set(), + removed: new Set(), + })); + setSelectedSet((prev) => { + if (prev.size === 0) return prev; + const next = new Set(); + for (const id of prev) if (byRef[id]) next.add(id); + return next.size === prev.size ? prev : next; + }); + }, [dirty.baseline]); + + const changeSet = React.useMemo( + (): LineItemsChangeSet => + buildChangeSet( + columns, + dirty.baseline, + rows.order, + rows.byRef, + dirty.removed, + dirty.inserted, + ordering, + ), + [columns, dirty, ordering, rows], + ); + + const getChangeSet = React.useCallback((): LineItemsChangeSet => changeSet, [changeSet]); + + const allLines = React.useMemo( + (): T[] => rows.order.map((id) => rows.byRef[id]).filter(Boolean) as T[], + [rows], + ); + + React.useEffect(() => { + if (!pendingNotify.current) return; + pendingNotify.current = false; + // The parent will echo these rows back through `lines`; record them as + // already seeded so the echo is not mistaken for new data, which would + // reset the baseline and drop the edit from the change-set. + seededRefs.current = serializeRefs(allLines); + onLinesChangeRef.current?.(allLines); + }, [allLines]); + + const filteredLines = React.useMemo((): T[] => { + const query = filter.trim(); + if (query === "") return allLines; + const searchable = fields.filter((f) => typeof f.search === "function"); + if (searchable.length === 0) return allLines; + return allLines.filter((row) => searchable.some((f) => f.search!(row, query))); + }, [allLines, fields, filter]); + + const lines = React.useMemo((): T[] => { + if (!sortState) return filteredLines; + const comparator = fields.find((f) => f.key === sortState.columnId)?.sort?.comparator; + if (!comparator) return filteredLines; + // Inverting the comparator rather than reversing keeps the sort stable, so + // rows the comparator considers equal stay in document order either way. + const direction = sortState.direction === "desc" ? -1 : 1; + return filteredLines.toSorted((a, b) => direction * comparator(a, b)); + }, [fields, filteredLines, sortState]); + + /** + * Cycles ascending, descending, then back to document order. Manual ordering + * owns row order outright, so sorting is inert there. + */ + const toggleSort = React.useCallback( + (columnId: string) => { + if (ordering !== "sort") return; + setSortState((prev) => { + if (prev?.columnId !== columnId) return { columnId, direction: "asc" }; + if (prev.direction === "asc") return { columnId, direction: "desc" }; + return null; + }); + }, + [ordering], + ); + + const selectedIds = React.useMemo( + () => + selectedSet.size === 0 + ? [] + : rows.order.filter((id) => selectedSet.has(id) && rows.byRef[id]), + [rows, selectedSet], + ); + + const toggleSelect = React.useCallback( + (lineRef: string) => { + if (!selection) return; + setSelectedSet((prev) => { + const next = new Set(prev); + if (!next.delete(lineRef)) next.add(lineRef); + return next; + }); + }, + [selection], + ); + + const visibleRefs = React.useMemo(() => lines.map((l) => l.lineRef), [lines]); + + const selectAllVisible = React.useCallback(() => { + if (!selection) return; + setSelectedSet(new Set(visibleRefs)); + }, [selection, visibleRefs]); + + const clearSelection = React.useCallback(() => { + setSelectedSet((prev) => (prev.size === 0 ? prev : new Set())); + }, []); + + const bulkUpdate = React.useCallback( + (patch: Partial) => { + if (selectedIds.length === 0) return; + updateLines(selectedIds.map((lineRef) => ({ lineRef, patch }))); + }, + [selectedIds, updateLines], + ); + + const bulkRemove = React.useCallback(() => { + removeLines(selectedIds); + }, [removeLines, selectedIds]); + + const selectedRows = React.useMemo( + () => selectedIds.map((id) => rows.byRef[id] as T), + [rows, selectedIds], + ); + + const { pendingActionId, runSelectionAction } = useSelectionActionRunner({ + enabled: selection, + clearSelection, + owner: "LineItems", + }); + + const setMode = React.useCallback((m: LineItemsMode) => setModeState(m), []); + const setFilter = React.useCallback((q: string) => setFilterState(q), []); + + return { + lines, + allLines, + fields, + mode, + ordering, + isDirty: !changeSet.isEmpty, + filter, + selectionEnabled: selection, + selectedIds, + selectedRows, + selectionActions, + pendingActionId, + runSelectionAction, + sortState: ordering === "sort" ? sortState : null, + + setMode, + setFilter, + toggleSort, + addLine, + addLines, + removeLine, + removeLines, + updateField, + updateLines, + reorderLine, + toggleSelect, + selectAllVisible, + clearSelection, + bulkUpdate, + bulkRemove, + duplicateLastLine, + reset, + revert, + getChangeSet, + }; +} + +export function newLineRef(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `line-${Math.random().toString(36).slice(2, 11)}`; +} + +/** + * Splices `ids` into `order`: appended when `after` is `undefined`, prepended + * when `null`, otherwise placed directly after that ref. + */ +function insertAfter( + order: readonly string[], + ids: readonly string[], + after: string | null | undefined, +): string[] { + const next = [...order]; + if (after === undefined) { + next.push(...ids); + } else if (after === null) { + next.unshift(...ids); + } else { + const at = next.indexOf(after); + if (at === -1) next.push(...ids); + else next.splice(at + 1, 0, ...ids); + } + return next; +} + +/** Backfills a generated `lineRef` for any row that arrives without one. */ +function sanitizeRows(rows: readonly T[]): T[] { + return rows.map((row) => { + const ref = + typeof row.lineRef === "string" && row.lineRef.length > 0 ? row.lineRef : newLineRef(); + return { ...row, lineRef: ref }; + }); +} + +function packLines(rows: readonly T[]): RowState { + const byRef: Record = {}; + const order: string[] = []; + for (const row of rows) { + byRef[row.lineRef] = row; + order.push(row.lineRef); + } + return { byRef, order }; +} + +function serializeRefs(rows: readonly T[]): string { + return `${rows.length}:${rows.map((r) => r.lineRef).join(",")}`; +} diff --git a/packages/core/src/components/line-items/use-row-virtualizer.ts b/packages/core/src/components/line-items/use-row-virtualizer.ts new file mode 100644 index 000000000..52bd27db5 --- /dev/null +++ b/packages/core/src/components/line-items/use-row-virtualizer.ts @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { computeVirtualWindow, sameVirtualWindow, type VirtualWindow } from "./virtual-window"; + +// Measure before paint so the first frame shows rows, not an empty body. Falls +// back to useEffect on the server, where layout effects warn. +const useIsomorphicLayoutEffect = + typeof document !== "undefined" ? React.useLayoutEffect : React.useEffect; + +export type UseRowVirtualizerOptions = { + rowCount: number; + rowHeight: number; + overscan?: number; + /** Set `false` to mount every row — used by tests and by short tables. */ + enabled?: boolean; +}; + +const EMPTY_WINDOW: VirtualWindow = { + startIndex: 0, + endIndex: 0, + paddingTop: 0, + paddingBottom: 0, +}; + +/** + * Tracks the scroll container and reports which row range to mount. + * + * Scroll events are coalesced into one animation frame, and state is only + * written when the row range actually changes — scrolling within a single row's + * height must not re-render the grid, or typing in a cell would drop focus + * whenever the container scrolls. + */ +export function useRowVirtualizer( + containerRef: React.RefObject, + { rowCount, rowHeight, overscan = 8, enabled = true }: UseRowVirtualizerOptions, +): VirtualWindow { + const [window_, setWindow] = React.useState(EMPTY_WINDOW); + + const measure = React.useCallback(() => { + const el = containerRef.current; + if (!el) return; + const next = computeVirtualWindow({ + rowCount, + rowHeight, + scrollTop: el.scrollTop, + viewportHeight: el.clientHeight, + overscan, + }); + setWindow((prev) => + sameVirtualWindow(prev, next) && prev.paddingTop === next.paddingTop ? prev : next, + ); + }, [containerRef, overscan, rowCount, rowHeight]); + + useIsomorphicLayoutEffect(() => { + if (!enabled) return; + const el = containerRef.current; + if (!el) return; + + measure(); + + let frame = 0; + const onScroll = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }; + + el.addEventListener("scroll", onScroll, { passive: true }); + const observer = new ResizeObserver(measure); + observer.observe(el); + + return () => { + if (frame) cancelAnimationFrame(frame); + el.removeEventListener("scroll", onScroll); + observer.disconnect(); + }; + }, [containerRef, enabled, measure]); + + if (!enabled) { + return { startIndex: 0, endIndex: rowCount, paddingTop: 0, paddingBottom: 0 }; + } + + return window_; +} diff --git a/packages/core/src/components/line-items/virtual-window.test.ts b/packages/core/src/components/line-items/virtual-window.test.ts new file mode 100644 index 000000000..ec0501f89 --- /dev/null +++ b/packages/core/src/components/line-items/virtual-window.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { computeVirtualWindow, sameVirtualWindow } from "./virtual-window"; + +const base = { rowCount: 1000, rowHeight: 32, viewportHeight: 320, overscan: 4 }; + +describe("computeVirtualWindow", () => { + it("mounts only the visible slice plus overscan", () => { + const w = computeVirtualWindow({ ...base, scrollTop: 0 }); + expect(w.startIndex).toBe(0); + expect(w.endIndex).toBe(15); + expect(w.endIndex - w.startIndex).toBeLessThan(base.rowCount); + }); + + it("spacer heights always account for the unmounted rows", () => { + const w = computeVirtualWindow({ ...base, scrollTop: 3200 }); + const totalHeight = + w.paddingTop + (w.endIndex - w.startIndex) * base.rowHeight + w.paddingBottom; + expect(totalHeight).toBe(base.rowCount * base.rowHeight); + }); + + it("advances the window as the container scrolls", () => { + const top = computeVirtualWindow({ ...base, scrollTop: 0 }); + const mid = computeVirtualWindow({ ...base, scrollTop: 3200 }); + expect(mid.startIndex).toBeGreaterThan(top.startIndex); + expect(mid.paddingTop).toBe(mid.startIndex * base.rowHeight); + }); + + it("clamps at the top rather than going negative", () => { + const w = computeVirtualWindow({ ...base, scrollTop: -500 }); + expect(w.startIndex).toBe(0); + expect(w.paddingTop).toBe(0); + }); + + it("clamps at the bottom with no trailing spacer", () => { + const w = computeVirtualWindow({ ...base, scrollTop: base.rowCount * base.rowHeight }); + expect(w.endIndex).toBe(base.rowCount); + expect(w.paddingBottom).toBe(0); + expect(w.startIndex).toBeLessThan(base.rowCount); + }); + + it("returns an empty window for an empty document", () => { + expect(computeVirtualWindow({ ...base, rowCount: 0, scrollTop: 0 })).toEqual({ + startIndex: 0, + endIndex: 0, + paddingTop: 0, + paddingBottom: 0, + }); + }); + + it("mounts a leading slice before layout has measured the viewport", () => { + const w = computeVirtualWindow({ ...base, viewportHeight: 0, scrollTop: 0 }); + expect(w.endIndex).toBeGreaterThan(0); + }); + + it("mounts every row when the document is shorter than the viewport", () => { + const w = computeVirtualWindow({ ...base, rowCount: 3, scrollTop: 0 }); + expect(w.startIndex).toBe(0); + expect(w.endIndex).toBe(3); + expect(w.paddingBottom).toBe(0); + }); +}); + +describe("sameVirtualWindow", () => { + it("compares by mounted range so sub-row scrolling does not re-render", () => { + const a = computeVirtualWindow({ ...base, scrollTop: 100 }); + const b = computeVirtualWindow({ ...base, scrollTop: 110 }); + expect(sameVirtualWindow(a, b)).toBe(true); + + const c = computeVirtualWindow({ ...base, scrollTop: 900 }); + expect(sameVirtualWindow(a, c)).toBe(false); + }); +}); diff --git a/packages/core/src/components/line-items/virtual-window.ts b/packages/core/src/components/line-items/virtual-window.ts new file mode 100644 index 000000000..defc84146 --- /dev/null +++ b/packages/core/src/components/line-items/virtual-window.ts @@ -0,0 +1,57 @@ +/** The slice of rows to mount, plus the spacer heights that stand in for the rest. */ +export type VirtualWindow = { + startIndex: number; + /** Exclusive. */ + endIndex: number; + paddingTop: number; + paddingBottom: number; +}; + +export type VirtualWindowInput = { + rowCount: number; + rowHeight: number; + scrollTop: number; + viewportHeight: number; + /** Rows rendered beyond each edge of the viewport, to cover fast scrolling. */ + overscan: number; +}; + +/** + * Windowing math for a uniform row height. Rows outside the window are replaced + * by a single spacer row above and below, so the scrollbar still reflects the + * full document while only the visible slice is mounted. + * + * A viewport height of 0 (the first render, before layout) yields a small + * leading slice rather than nothing, so the table paints content immediately + * instead of flashing empty. + */ +export function computeVirtualWindow({ + rowCount, + rowHeight, + scrollTop, + viewportHeight, + overscan, +}: VirtualWindowInput): VirtualWindow { + if (rowCount <= 0 || rowHeight <= 0) { + return { startIndex: 0, endIndex: 0, paddingTop: 0, paddingBottom: 0 }; + } + + const safeScrollTop = Math.max(0, scrollTop); + const visibleCount = + viewportHeight > 0 ? Math.ceil(viewportHeight / rowHeight) + 1 : Math.min(rowCount, overscan); + + const firstVisible = Math.floor(safeScrollTop / rowHeight); + const startIndex = Math.max(0, Math.min(firstVisible - overscan, rowCount - 1)); + const endIndex = Math.min(rowCount, firstVisible + visibleCount + overscan); + + return { + startIndex, + endIndex, + paddingTop: startIndex * rowHeight, + paddingBottom: Math.max(0, (rowCount - endIndex) * rowHeight), + }; +} + +export function sameVirtualWindow(a: VirtualWindow, b: VirtualWindow): boolean { + return a.startIndex === b.startIndex && a.endIndex === b.endIndex; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e0c0f1aab..7546acd72 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -167,6 +167,34 @@ export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; export type { BadgeVariant, BadgeOptions } from "./components/badge-list"; export { TreePicker, type TreePickerProps, type TreePickerNode } from "./components/tree-picker"; export { DescriptionCard, type DescriptionCardProps } from "./components/description-card"; +export { + LineItems, + useLineItems, + useLineItemsGroup, + createLineItemHelper, + type LineItemsChangeSet, + type LineItemsColumnAlign, + type LineItemsCustomEditorContext, + type LineItemsField, + type LineItemsFieldCommit, + type LineItemsFieldType, + type LineItemsGroupChangeSet, + type LineItemsGroupMembers, + type LineItemsLineChange, + type LineItemsMetadataCommit, + type LineItemsMode, + type LineItemsOrderingMode, + type LineItemsRootProps, + type LineItemsRowData, + type LineItemsRowPatch, + type LineItemsSelectOption, + type LineItemsSortDirection, + type LineItemsSortState, + type LineItemsTableProps, + type UseLineItemsGroupReturn, + type UseLineItemsOptions, + type UseLineItemsReturn, +} from "./components/line-items"; export { ActivityCard, type ActivityCardProps,