From 4928b311084f3782e0bac1cf1227b55a055ee628 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 6 May 2026 04:20:44 +0530 Subject: [PATCH 1/6] feat(line-items): document-line editing component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a complete `LineItems` component family for editing rows of a document (purchase orders, sales orders, invoices) with spreadsheet ergonomics — keyboard navigation, range selection, fill drag, TSV copy/paste, virtualized rendering, and dirty tracking. Public API (`@tailor-platform/app-shell`): - `useLineItems()` hook — state + mutations + change-set + revert/reset - `LineItems.{Root, Table, Search, SearchToggle, BulkActions, AddRow, FullscreenToggle, SaveActions}` compound parts - `createLineItemHelper().field({...})` for type-safe field schemas - Field-level options: `width`, `hoverExpandWidth` for column sizing Core pieces: - Virtualized table on `@tanstack/react-virtual` (newly added dep) + `@tanstack/react-table` for column defs / sorting - Spreadsheet behaviors implemented in `spreadsheet-logic.ts` (rectangular cells, paste TSV, fill drag, arrow nav) - Dirty-tracking baseline in `useLineItems`; `revert()` restores the baseline, `reset()` snaps it forward - `LineItems.Root` owns the fullscreen overlay + Escape-to-close + click-outside-to-close, with descendant CSS rules to stretch a hosted Card.Root in fullscreen - `LineItems.SearchToggle` — icon button that expands inline into a search input - Cell shell paints selection ring via `box-shadow: inset` so the ring matches cell bounds pixel-perfectly across stickiness/repaint - Native number-spinner suppression hoisted into `inputBaseClasses` so every shared input behaves consistently Demo (`examples/app-module`): - `line-items-demo.tsx` — full Purchase-Order page with: - Card.Header title/description + action cluster [Search][Import from CSV][Bulk add][Expand] - Edge-to-edge virtualized table (1200 seeded rows) - 4-side-padded inline add-row Combobox (SKU + product two-line) - Floating bottom-center action dock with dirty + selection bars (mirrors the denim-tears bulk-export pattern) - Jiggle animation + click/anchor + beforeunload guards while dirty - Reusable `` exported and dropped into the 2-column layout demo with `[]` so the empty/build-from-zero state is exercised - Resource registered in `custom-module.tsx` Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/line-items-card-layout.md | 5 + .changeset/line-items-cell-bounds-polish.md | 10 + .changeset/line-items-dense-sku-select.md | 21 + .changeset/line-items-fullscreen-card-fill.md | 8 + .changeset/line-items-hooks-rewrite.md | 104 +++ .changeset/line-items-hover-expand-column.md | 8 + .changeset/line-items-polish-pass.md | 13 + .changeset/line-items-revert.md | 5 + .changeset/line-items-select-all-on-focus.md | 5 + .changeset/line-items-thin-scrollbars.md | 5 + examples/app-module/src/custom-module.tsx | 13 + .../app-module/src/pages/layout-demos.tsx | 6 + .../app-module/src/pages/line-items-demo.tsx | 680 ++++++++++++++ .../src__components__field.test.tsx.snap | 10 +- .../src__components__form.test.tsx.snap | 4 +- .../src__components__input.test.tsx.snap | 12 +- packages/core/package.json | 1 + .../src/components/line-items/LineItems.tsx | 41 + .../core/src/components/line-items/field.ts | 121 +++ .../core/src/components/line-items/index.ts | 33 + .../src/components/line-items/internals.ts | 267 ++++++ .../line-items/line-items-default-cell.tsx | 426 +++++++++ .../line-items/line-items-grid-context.tsx | 71 ++ .../line-items/line-items-internals.test.ts | 119 +++ .../line-items/line-items-parts.tsx | 320 +++++++ .../components/line-items/line-items-root.tsx | 103 ++ .../line-items/line-items-table.tsx | 879 ++++++++++++++++++ .../line-items/spreadsheet-logic.test.ts | 73 ++ .../line-items/spreadsheet-logic.ts | 109 +++ .../core/src/components/line-items/types.ts | 192 ++++ .../line-items/use-line-items.test.tsx | 263 ++++++ .../components/line-items/use-line-items.ts | 490 ++++++++++ packages/core/src/index.ts | 29 + packages/core/src/lib/input-classes.ts | 4 + pnpm-lock.yaml | 20 + 35 files changed, 4457 insertions(+), 13 deletions(-) create mode 100644 .changeset/line-items-card-layout.md create mode 100644 .changeset/line-items-cell-bounds-polish.md create mode 100644 .changeset/line-items-dense-sku-select.md create mode 100644 .changeset/line-items-fullscreen-card-fill.md create mode 100644 .changeset/line-items-hooks-rewrite.md create mode 100644 .changeset/line-items-hover-expand-column.md create mode 100644 .changeset/line-items-polish-pass.md create mode 100644 .changeset/line-items-revert.md create mode 100644 .changeset/line-items-select-all-on-focus.md create mode 100644 .changeset/line-items-thin-scrollbars.md create mode 100644 examples/app-module/src/pages/line-items-demo.tsx create mode 100644 packages/core/src/components/line-items/LineItems.tsx create mode 100644 packages/core/src/components/line-items/field.ts create mode 100644 packages/core/src/components/line-items/index.ts create mode 100644 packages/core/src/components/line-items/internals.ts create mode 100644 packages/core/src/components/line-items/line-items-default-cell.tsx create mode 100644 packages/core/src/components/line-items/line-items-grid-context.tsx create mode 100644 packages/core/src/components/line-items/line-items-internals.test.ts create mode 100644 packages/core/src/components/line-items/line-items-parts.tsx create mode 100644 packages/core/src/components/line-items/line-items-root.tsx create mode 100644 packages/core/src/components/line-items/line-items-table.tsx create mode 100644 packages/core/src/components/line-items/spreadsheet-logic.test.ts create mode 100644 packages/core/src/components/line-items/spreadsheet-logic.ts create mode 100644 packages/core/src/components/line-items/types.ts create mode 100644 packages/core/src/components/line-items/use-line-items.test.tsx create mode 100644 packages/core/src/components/line-items/use-line-items.ts diff --git a/.changeset/line-items-card-layout.md b/.changeset/line-items-card-layout.md new file mode 100644 index 00000000..4717771d --- /dev/null +++ b/.changeset/line-items-card-layout.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: new `LineItems.SearchToggle` part — an icon button that expands inline into a search input on click, with a 200ms width transition. Collapses on blur (when the filter is empty) or Escape. Reads/writes the same `filter` state as `LineItems.Search`, so the two are interchangeable. diff --git a/.changeset/line-items-cell-bounds-polish.md b/.changeset/line-items-cell-bounds-polish.md new file mode 100644 index 00000000..3cd20190 --- /dev/null +++ b/.changeset/line-items-cell-bounds-polish.md @@ -0,0 +1,10 @@ +--- +"@tailor-platform/app-shell": patch +--- + +LineItems: cell bounds and selection ring now align pixel-perfect with the cell box. + +- The grid cell shell renders directly inside the `` as `absolute inset-0` instead of being nested under an extra wrapper, so the cell box, the input, and the selection rectangle all share identical bounds. +- Active-cell and fill-preview selection borders are painted via an `inset` box-shadow on the cell shell rather than an absolute-positioned overlay, eliminating any size/rounding mismatch. +- Default virtualized row height bumped from 32px to 36px for slightly more breathing room around input text. +- Fill drag handle is now a smaller, flush 6×6px square seated on the cell's bottom-right corner. diff --git a/.changeset/line-items-dense-sku-select.md b/.changeset/line-items-dense-sku-select.md new file mode 100644 index 00000000..125406f8 --- /dev/null +++ b/.changeset/line-items-dense-sku-select.md @@ -0,0 +1,21 @@ +--- +"@tailor-platform/app-shell": minor +--- + +**LineItems:** Default table chrome is denser and card-aligned: `bg-card`, column/row dividers, no header shadow, `32px` virtual row height, zero cell padding, and borderless cell inputs so the grid reads like a spreadsheet. + +**LineItems:** `LineItemsField.type` adds `kind: "select"` with `options: { value, label, description? }[]` and optional `placeholder`. Options render in a combobox with a two-line layout when `description` is set. + +```tsx +f.field({ + key: "sku", + label: "SKU", + type: { + kind: "select", + options: [{ value: "A", label: "A", description: "Line two" }], + placeholder: "Pick…", + }, + render: (row) => row.sku, + editable: ["edit"], +}); +``` diff --git a/.changeset/line-items-fullscreen-card-fill.md b/.changeset/line-items-fullscreen-card-fill.md new file mode 100644 index 00000000..c20a7d81 --- /dev/null +++ b/.changeset/line-items-fullscreen-card-fill.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/app-shell": patch +--- + +LineItems: fullscreen mode polish. + +- Clicking the dimmed backdrop around the card now closes fullscreen (in addition to the existing Escape-to-close). +- When the table is rendered inside a `Card`, fullscreen now stretches the immediate `Card` child to fill the viewport and expands its `Card.Content` so the `LineItems.Table` scrolls inside the card. Layout rules are scoped via descendant selectors on the fullscreen root, so only `data-slot="card"` / `data-slot="card-content"` are affected. diff --git a/.changeset/line-items-hooks-rewrite.md b/.changeset/line-items-hooks-rewrite.md new file mode 100644 index 00000000..3c94fde3 --- /dev/null +++ b/.changeset/line-items-hooks-rewrite.md @@ -0,0 +1,104 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: rewrite around a public `useLineItems()` hook + compound components. + +The line-items module now follows the "hooks own logic, components own layout" pattern. Variants (Journal Entry, Work Order, Purchase Order baseline) compose freely instead of demanding ever-more props. + +### What's new + +- **`useLineItems()`** is the single source of truth for a document. It owns row order, the dirty baseline, filter, bulk row selection, and every imperative mutation (`addLine`, `updateField`, `updateLines`, `removeLine`, `reorderLine`, `toggleSelect`, `selectAllVisible`, `clearSelection`, `bulkUpdate`, `bulkRemove`, `duplicateLastLine`, `reset`, `getChangeSet`). `isDirty` is reactive — no more `onChangeSet` push callback. +- **`createLineItemHelper().field({...})`** is the typed schema builder. Each field declares `key`, `label`, `render(line)`, `editable: LineItemsMode[]`, `type: { kind: "text" | "number"; decimals? }`, `commit: "document" | "metadata"`, `sort.comparator`, `search`, `align`, `className`. Computed read-only columns just pick a unique `key` and omit `editable` / `type`. +- **`LineItems.*` compound components** replace the monolithic ``: + - `` — provider + fullscreen container (Esc exits). + - `` — virtualized table with always-on spreadsheet UX (range select, fill-drag, TSV copy/paste, keyboard nav). + - `` — controlled by `hook.filter` / `setFilter`. + - `{({ selectedIds, bulkUpdate, bulkRemove, clear }) => …}` — render-prop, gated on selection. + - `` — children-as-slot for an inline empty row beneath the table. + - `` — hoistable expand button (default rendered inside ``). + - `` — Discard + Save, auto-disabled on `!isDirty`. +- The `cellInteraction` mode is dropped — spreadsheet behaviors are always-on. + +### BREAKING + +The previous `` API is removed entirely. The following types are no longer exported: `LineItemsRootRef`, `LineItemsCellInteractionMode`, `LineItemsColumnDef`, `LineItemsAddSlotRenderArgs`, `LineItemsInlineAddRowRenderArgs`, `LineItemsMutationScope`, `LineItemsCellRendererContext`. The `LineItemsQuickAdd` component is also removed. + +### Migration + +```tsx +// Before +const ref = React.useRef(null); + + ref={ref} + initialLines={initial} + columns={[ + { id: "sku", header: "SKU", accessorKey: "sku", sortable: true }, + { + id: "qty", + header: "Qty", + accessorKey: "qty", + align: "right", + normalize: (v) => Number(v), + equals: (a, b) => Number(a) === Number(b), + }, + { id: "note", header: "Note", accessorKey: "note", mutationScope: "metadata" }, + ]} + onChangeSet={(cs) => setChangeSet(cs)} + enableBulkActions + cellInteraction="spreadsheet" +/>; + +// After +const f = createLineItemHelper(); +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + sort: { comparator: (a, b) => a.sku.localeCompare(b.sku) }, + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "qty", + label: "Qty", + render: (l) => l.qty, + editable: ["edit", "amend"], + type: { kind: "number", decimals: 0 }, + align: "right", + }), + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit", "amend"], + type: { kind: "text" }, + commit: "metadata", + }), +]; + +const lineItems = useLineItems({ fields, data: initial, selection: true }); + + + + + {({ bulkRemove, clear }) => ( + <> + + + + )} + + + + {/* host JSX, e.g. lineItems.addLine(...)} /> */} + + save(lineItems.getChangeSet())} /> +; +``` + +Imperative ref methods are replaced 1:1 by hook methods: `ref.resetBaseline()` → `lineItems.reset()`, `ref.getChangeSet()` → `lineItems.getChangeSet()`, `ref.hasChanges()` → `lineItems.isDirty`, `ref.duplicateLastLine()` → `lineItems.duplicateLastLine()`. `ref.focusLine()` is no longer provided — focus the cell directly through the DOM if needed. diff --git a/.changeset/line-items-hover-expand-column.md b/.changeset/line-items-hover-expand-column.md new file mode 100644 index 00000000..5c92b594 --- /dev/null +++ b/.changeset/line-items-hover-expand-column.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: column-level `width` and `hoverExpandWidth` options. + +- New `LineItemsField.width` sets a resting pixel width for the column (otherwise auto-sized). +- New `LineItemsField.hoverExpandWidth` widens the column to that pixel value while the user hovers any cell (header or body) in that column, with a subtle 220ms `width` / `min-width` transition. Useful for dense columns that show truncated content (e.g. a SKU label that includes the product name). diff --git a/.changeset/line-items-polish-pass.md b/.changeset/line-items-polish-pass.md new file mode 100644 index 00000000..fe5ac88d --- /dev/null +++ b/.changeset/line-items-polish-pass.md @@ -0,0 +1,13 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: polish pass for the spreadsheet experience. + +- New `renderInlineAddRow` slot renders an always-visible empty row directly below the scrollable table. Hosts can drop a `Combobox` (or any control) into it to add lines via type-ahead search. +- New `enableFullscreenToggle` prop (default `true`) shows a small expand icon in the top-right corner of the table; clicking it (or pressing `Esc` again) toggles a viewport-filling fullscreen overlay with a dark backdrop. Component state is preserved across the toggle. +- Spreadsheet selection visuals now use the brand `--primary` color: the active cell ring, the fill drag handle, the per-cell ring on every shift-selected cell, and the per-cell ring on every cell of an active fill-drag preview (matching the active cell's border). +- Native up/down spinner buttons are hidden globally on every `` for a cleaner numeric editing experience. +- The "scroll active cell into view" effect now runs only when the focused coordinate actually changes, fixing a regression where typing in cells (and in the inline-add-row Combobox) could drop focus mid-keystroke. Smooth-scroll animation on cell focus has been removed for the same reason. +- The inline add-row sits as a sibling below the scroll container instead of inside ``, so it remains anchored even when the table is virtualized and scrolled. `maxBodyHeight` JSDoc clarifies that the table grows up to this height before scrolling and that the add-row sits below the scroll area. +- BREAKING: `pageSize` and `showDirtyOffPageBanner` props are removed. Line items now always render as a single virtualized list — large lists load on scroll automatically, no pagination UI is rendered. Hosts that were paginating client-side should drop both props; the virtualizer handles thousands of rows efficiently. diff --git a/.changeset/line-items-revert.md b/.changeset/line-items-revert.md new file mode 100644 index 00000000..2b176fd9 --- /dev/null +++ b/.changeset/line-items-revert.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: new `useLineItems().revert()` method that restores current row state back to the dirty-tracking baseline — discards every uncommitted edit/insert/remove in one shot. Use this for a "Discard changes" button; pair with the existing `reset()` after a successful save (which snaps the baseline forward instead). diff --git a/.changeset/line-items-select-all-on-focus.md b/.changeset/line-items-select-all-on-focus.md new file mode 100644 index 00000000..7b7c6fee --- /dev/null +++ b/.changeset/line-items-select-all-on-focus.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +LineItems: focusing a cell now selects all of its text by default (spreadsheet ergonomics), so a click or tab into a cell puts it in "replace" mode — the next keystroke overwrites the value instead of appending to it. Applies to text, number, and select (combobox) cells. diff --git a/.changeset/line-items-thin-scrollbars.md b/.changeset/line-items-thin-scrollbars.md new file mode 100644 index 00000000..50fa2e1b --- /dev/null +++ b/.changeset/line-items-thin-scrollbars.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +LineItems: the table's scroll container now ships with slim, semi-transparent scrollbars (4px on webkit, `thin` on Firefox) — transparent track, `rgba(0,0,0,0.4)` thumb. Reduces visual noise inside dense tables and inside cards. diff --git a/examples/app-module/src/custom-module.tsx b/examples/app-module/src/custom-module.tsx index 698e1dba..b76eb620 100644 --- a/examples/app-module/src/custom-module.tsx +++ b/examples/app-module/src/custom-module.tsx @@ -20,6 +20,7 @@ import { primitiveComponentsDemoResource } from "./pages/primitives-demo"; import { dropdownComponentsDemoResource } from "./pages/dropdown-demo"; import { formComponentsDemoResource, zodRHFFormDemoResource } from "./pages/form-demo"; import { csvImporterDemoResource } from "./pages/csv-importer-demo"; +import { lineItemsDemoResource } from "./pages/line-items-demo"; export const customPageModule = defineModule({ path: "custom-page", @@ -193,6 +194,17 @@ export const customPageModule = defineModule({ CSV Importer Demo

+

+ + Line items (document lines) + +

); @@ -218,5 +230,6 @@ export const customPageModule = defineModule({ formComponentsDemoResource, zodRHFFormDemoResource, csvImporterDemoResource, + lineItemsDemoResource, ], }); diff --git a/examples/app-module/src/pages/layout-demos.tsx b/examples/app-module/src/pages/layout-demos.tsx index ed84b70d..f2cb52d5 100644 --- a/examples/app-module/src/pages/layout-demos.tsx +++ b/examples/app-module/src/pages/layout-demos.tsx @@ -11,6 +11,7 @@ import * as React from "react"; import { mockPurchaseOrder } from "./purchase-order-demo"; import { activityCardDemoActivities } from "./activity-card-demo"; import { ReceiptIcon, FileTextIcon, ExternalLinkIcon } from "./action-panel-demo"; +import { LineItemsSection } from "./line-items-demo"; /** * Placeholder component with subtle diagonal lines pattern for empty content areas @@ -227,6 +228,11 @@ export const twoColumnLayoutResource = defineResource({ { key: "note", label: "Notes", meta: { truncateLines: 3 } }, ]} /> + + {/* Empty-state line-items section: same component as line-items-demo + but seeded with `[]` so we can build up a list from scratch via + the bottom add-product picker. */} + diff --git a/examples/app-module/src/pages/line-items-demo.tsx b/examples/app-module/src/pages/line-items-demo.tsx new file mode 100644 index 00000000..b881ff35 --- /dev/null +++ b/examples/app-module/src/pages/line-items-demo.tsx @@ -0,0 +1,680 @@ +import * as React from "react"; +import { + ActionPanel, + ActivityCard, + Button, + Card, + Combobox, + DescriptionCard, + Layout, + LineItems, + createLineItemHelper, + defineResource, + useLineItems, + type LineItemsField, + type LineItemsMode, + type LineItemsRowData, +} from "@tailor-platform/app-shell"; + +import { activityCardDemoActivities } from "./activity-card-demo"; +import { ExternalLinkIcon, FileTextIcon, ReceiptIcon } from "./action-panel-demo"; +import { mockPurchaseOrder } from "./purchase-order-demo"; + +/* ======================================================================== */ +/* Domain */ +/* ======================================================================== */ + +type POLine = LineItemsRowData & { + sku: string; + productName: string; + quantity: number; + unitPrice: number; + total: number; + note: string; +}; + +type CatalogItem = { + sku: string; + productName: string; + unitPrice: number; +}; + +const CATALOG: CatalogItem[] = [ + { sku: "SKU-1001", productName: "Indigo Denim Roll", unitPrice: 24.5 }, + { sku: "SKU-2040", productName: "Copper Rivet Pack", unitPrice: 8.25 }, + { sku: "SKU-3300", productName: "Organic Cotton Jersey", unitPrice: 15.0 }, + { sku: "SKU-4412", productName: "Leather Patch Kit", unitPrice: 12.75 }, +]; + +const round2 = (n: number) => Math.round(n * 100) / 100; +const fmtCurrency = (n: number) => n.toFixed(2); + +/* ======================================================================== */ +/* Field schema */ +/* ======================================================================== */ + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + 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.productName, + })), + placeholder: "Pick SKU", + }, + sort: { comparator: (a, b) => a.sku.localeCompare(b.sku) }, + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + width: 200, + hoverExpandWidth: 320, + }), + f.field({ + key: "productName", + label: "Product", + render: (l) => l.productName, + editable: ["edit"], + type: { kind: "text" }, + sort: { comparator: (a, b) => a.productName.localeCompare(b.productName) }, + search: (l, q) => l.productName.toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit", "amend"], + type: { kind: "number", decimals: 0 }, + align: "right", + sort: { comparator: (a, b) => a.quantity - b.quantity }, + }), + f.field({ + key: "unitPrice", + label: "Unit price", + render: (l) => fmtCurrency(l.unitPrice), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + align: "right", + sort: { comparator: (a, b) => a.unitPrice - b.unitPrice }, + }), + f.field({ + key: "total", + label: "Total", + render: (l) => fmtCurrency(l.total), + align: "right", + }), + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit", "amend"], + type: { kind: "text" }, + commit: "metadata", + }), +]; + +/* ======================================================================== */ +/* Initial seed */ +/* ======================================================================== */ + +/** + * Seed the demo with 1,200 rows so the virtualized table is exercised at scale. + * Catalogue items are recycled with deterministic per-row variations (qty, + * unit-price tweaks) so the data feels realistic without ballooning the bundle. + */ +function buildInitialLines(): POLine[] { + const TOTAL_ROWS = 1200; + const lines: POLine[] = []; + for (let i = 0; i < TOTAL_ROWS; i++) { + const base = CATALOG[i % CATALOG.length]!; + const quantity = ((i * 7) % 90) + 1; + const unitPrice = round2(base.unitPrice * (1 + ((i % 11) - 5) / 100)); + lines.push({ + lineRef: `seed-${i + 1}`, + sku: base.sku, + productName: base.productName, + quantity, + unitPrice, + total: round2(quantity * unitPrice), + note: i % 50 === 0 ? "Highlight row" : "", + }); + } + return lines; +} + +/* ======================================================================== */ +/* Page */ +/* ======================================================================== */ + +export const lineItemsDemoResource = defineResource({ + path: "line-items-demo", + component: LineItemsDemoPage, + meta: { + title: "Line items", + }, +}); + +/** + * ✅ Reusable Component: the line-items section — a self-contained block + * containing demo mode controls + the LineItems card + the floating action + * dock. Pass `initialData={[]}` to start from empty and exercise the add-row. + */ +export function LineItemsSection({ initialData }: { initialData?: POLine[] } = {}) { + const initialLines = React.useMemo(() => initialData ?? buildInitialLines(), [initialData]); + const [mode, setMode] = React.useState("edit"); + + const lineItems = useLineItems({ + fields, + data: initialLines, + mode, + selection: true, + }); + + // Keep `total` in sync with quantity / unitPrice so the read-only column + // reflects the latest cell edits without an external compute step. Depends + // on `allLines` (whose reference only changes when row state actually + // changes) instead of the whole hook return — otherwise this effect would + // re-fire on every render. + const allLines = lineItems.allLines; + const updateLines = lineItems.updateLines; + React.useEffect(() => { + const updates: { lineRef: string; patch: Partial }[] = []; + for (const l of allLines) { + const expected = round2(l.quantity * l.unitPrice); + if (expected !== l.total) updates.push({ lineRef: l.lineRef, patch: { total: expected } }); + } + if (updates.length) updateLines(updates); + }, [allLines, updateLines]); + + const handleSave = React.useCallback(() => { + const cs = lineItems.getChangeSet(); + // In a real app, send `cs` to the server here. + // eslint-disable-next-line no-console + console.log("[line-items demo] saving change set", cs); + lineItems.reset(); + }, [lineItems]); + + return ( + <> + {/* 🧪 Demo Dummy: mode + duplicate-last are demo-only knobs to flip the + table into different states. They are NOT part of the LineItems + component itself — kept in a separate card just for the demo. */} + + + Mode + {(["edit", "display", "amend"] as const).map((m) => ( + + ))} + + + + + + + +
+

Line items

+

+ {lineItems.allLines.length} lines ·{" "} + {lineItems.isDirty ? "Unsaved changes" : "All saved"} +

+
+
+ + {/* 🧪 Dummy Data: hook these up to real flows later */} + + + +
+
+ + + + + {mode !== "display" ? ( + { + lineItems.addLine({ + sku: picked.sku, + productName: picked.productName, + quantity: 1, + unitPrice: picked.unitPrice, + total: round2(picked.unitPrice), + note: "", + }); + }} + disabled={mode !== "edit"} + /> + ) : null} + +
+ + lineItems.bulkRemove()} + onClearSelection={() => lineItems.clearSelection()} + isDirty={lineItems.isDirty} + onDiscard={() => lineItems.revert()} + onSave={() => void handleSave()} + /> +
+ + ); +} + +export function LineItemsDemoPage() { + const handleSave = React.useCallback(() => { + // Page-level save (e.g. PO header). Distinct from the LineItemsSection's + // own discard/save in the floating dock. + alert("Page-level Save changes clicked"); + }, []); + + const headerActions = [ + , + , + ]; + + const sidebarActions = [ + { + key: "create-invoice", + label: "Create sales invoice", + icon: , + onClick: () => alert("Create invoice clicked"), + }, + { + key: "delivery-note", + label: "Create delivery note", + icon: , + onClick: () => alert("Create delivery note clicked"), + }, + { + key: "view-po", + label: "View Purchase Order", + icon: , + onClick: () => alert("Navigate to PO detail"), + }, + ]; + + return ( + + + + + + + + + + + + + ); +} + +/* ======================================================================== */ +/* Inline catalogue add-row */ +/* ======================================================================== */ + +function InlineCatalogueAddRow({ + onPick, + disabled, +}: { + onPick: (item: CatalogItem) => void; + disabled?: boolean; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( +
+ + key={resetKey} + items={CATALOG} + disabled={disabled} + placeholder="+ Add product — type to search…" + emptyText="No matching products." + mapItem={(p) => ({ + key: p.sku, + // `label` drives type-to-filter — include both SKU and product name + // so either matches the query. + label: `${p.sku} ${p.productName}`, + render: ( +
+ {p.sku} + {p.productName} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + onPick(picked); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + {/* 🧪 Dummy Data: hook this up to a real flow later */} + +
+ ); +} + +/* ======================================================================== */ +/* Floating bottom action bars */ +/* ======================================================================== */ + +/** + * ✅ Reusable Component: floating bottom-center action pills. Mirrors the + * denim-tears `FloatingActions` pattern (apps/ims/.../purchase-orders-table.tsx) + * — a single `position: fixed` element rendered inline in the React tree (no + * portal needed). All styling is inline so no Tailwind class-generation + * gotchas; theme tokens come from `--foreground` / `--background` / + * `--muted-foreground` defined in `theme.css`. + * + * Two bars stack vertically: the bulk-selection bar (when rows selected) and + * the dirty-state bar (when there are unsaved changes). + */ +type FloatingActionsProps = { + selectedCount: number; + onBulkDelete: () => void; + onClearSelection: () => void; + isDirty: boolean; + onDiscard: () => void; + onSave: () => void; +}; + +const dockStyle: React.CSSProperties = { + position: "fixed", + bottom: "20px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 60, + pointerEvents: "none", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "8px", +}; + +const pillStyle: React.CSSProperties = { + pointerEvents: "auto", + display: "flex", + alignItems: "center", + gap: "12px", + padding: "12px 20px", + borderRadius: "16px", + backgroundColor: "var(--foreground)", + boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", +}; + +const labelStyle: React.CSSProperties = { + color: "var(--background)", + fontSize: "14px", + fontWeight: 500, + whiteSpace: "nowrap", +}; + +const dividerStyle: React.CSSProperties = { + width: "1px", + height: "24px", + backgroundColor: "var(--muted-foreground)", + opacity: 0.4, +}; + +const primaryButtonStyle: React.CSSProperties = { + backgroundColor: "var(--background)", + color: "var(--foreground)", + padding: "6px 12px", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 500, + border: "none", + cursor: "pointer", +}; + +const secondaryButtonStyle: React.CSSProperties = { + background: "transparent", + color: "var(--muted-foreground)", + padding: "6px 12px", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 500, + border: "none", + cursor: "pointer", +}; + +/** + * Jiggles the dirty bar to draw attention when the user is about to navigate + * away (tab visibility change, window blur). The keyframe is injected as a + * ` + {selectedCount > 0 ? ( +
+ {selectedCount} selected + + + +
+ ) : null} + {isDirty ? ( +
+ Unsaved changes + + + +
+ ) : null} + + ); +} diff --git a/packages/core/__snapshots__/src__components__field.test.tsx.snap b/packages/core/__snapshots__/src__components__field.test.tsx.snap index 4a468f85..329432e8 100644 --- a/packages/core/__snapshots__/src__components__field.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__field.test.tsx.snap @@ -1,11 +1,11 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Field > snapshots > basic field with label and control 1`] = `"
"`; +exports[`Field > snapshots > basic field with label and control 1`] = `"
"`; -exports[`Field > snapshots > disabled field 1`] = `"
"`; +exports[`Field > snapshots > disabled field 1`] = `"
"`; -exports[`Field > snapshots > field with custom className 1`] = `"
"`; +exports[`Field > snapshots > field with custom className 1`] = `"
"`; -exports[`Field > snapshots > field with description 1`] = `"

We will never share your email.

"`; +exports[`Field > snapshots > field with description 1`] = `"

We will never share your email.

"`; -exports[`Field > snapshots > field with error 1`] = `"
Please enter a valid URL.
"`; +exports[`Field > snapshots > field with error 1`] = `"
Please enter a valid URL.
"`; diff --git a/packages/core/__snapshots__/src__components__form.test.tsx.snap b/packages/core/__snapshots__/src__components__form.test.tsx.snap index 5728bc2d..cf1a36bb 100644 --- a/packages/core/__snapshots__/src__components__form.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__form.test.tsx.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Form > snapshots > basic form with a field 1`] = `"
"`; +exports[`Form > snapshots > basic form with a field 1`] = `"
"`; exports[`Form > snapshots > form with custom className 1`] = `"
Content
"`; -exports[`Form > snapshots > form with noValidate 1`] = `"
"`; +exports[`Form > snapshots > form with noValidate 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__input.test.tsx.snap b/packages/core/__snapshots__/src__components__input.test.tsx.snap index bf53423a..780c5e99 100644 --- a/packages/core/__snapshots__/src__components__input.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__input.test.tsx.snap @@ -1,13 +1,13 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`Input > snapshots > default text input 1`] = `""`; +exports[`Input > snapshots > default text input 1`] = `""`; -exports[`Input > snapshots > disabled input 1`] = `""`; +exports[`Input > snapshots > disabled input 1`] = `""`; -exports[`Input > snapshots > email input 1`] = `""`; +exports[`Input > snapshots > email input 1`] = `""`; -exports[`Input > snapshots > file input 1`] = `""`; +exports[`Input > snapshots > file input 1`] = `""`; -exports[`Input > snapshots > password input 1`] = `""`; +exports[`Input > snapshots > password input 1`] = `""`; -exports[`Input > snapshots > with custom className 1`] = `""`; +exports[`Input > snapshots > with custom className 1`] = `""`; diff --git a/packages/core/package.json b/packages/core/package.json index b75e16b4..a580285c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -53,6 +53,7 @@ "@tailor-platform/app-shell-vite-plugin": "workspace:*", "@tailor-platform/auth-public-client": "^0.5.0", "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.24", "change-case": "^5.4.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/core/src/components/line-items/LineItems.tsx b/packages/core/src/components/line-items/LineItems.tsx new file mode 100644 index 00000000..7e76d418 --- /dev/null +++ b/packages/core/src/components/line-items/LineItems.tsx @@ -0,0 +1,41 @@ +import { + LineItemsAddRow, + LineItemsBulkActions, + LineItemsFullscreenToggle, + LineItemsSaveActions, + LineItemsSearch, + LineItemsSearchToggle, +} from "./line-items-parts"; +import { LineItemsRoot } from "./line-items-root"; +import { LineItemsTable } from "./line-items-table"; + +/** + * Compound line-items API. Pair with the `useLineItems` hook: + * + * ```tsx + * const lineItems = useLineItems({ fields, data: initialLines }); + * return ( + * + * + * {({ bulkRemove }) => } + * + * {...} + * + * + * ); + * ``` + * + * Spreadsheet behaviors (range select, fill-drag, TSV copy/paste, keyboard nav) + * are always-on inside ``. There is no top-level `` + * component — the namespace exports each compound part. + */ +export const LineItems = { + Root: LineItemsRoot, + Table: LineItemsTable, + Search: LineItemsSearch, + SearchToggle: LineItemsSearchToggle, + BulkActions: LineItemsBulkActions, + AddRow: LineItemsAddRow, + FullscreenToggle: LineItemsFullscreenToggle, + SaveActions: LineItemsSaveActions, +}; 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 00000000..2c7ad436 --- /dev/null +++ b/packages/core/src/components/line-items/field.ts @@ -0,0 +1,121 @@ +import type { LineItemsColumnDef } from "./internals"; +import type { LineItemsField, LineItemsMode, LineItemsRowData } from "./types"; + +/** + * Field schema builder. Use it to get full TS 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", "amend"], type: { kind: "number", decimals: 0 }, align: "right" }), + * f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2), align: "right" }), + * ]; + */ +export function createLineItemHelper() { + return { + field: (f: LineItemsField): LineItemsField => f, + }; +} + +/* ======================================================================== */ +/* Field-mode helpers */ +/* ======================================================================== */ + +/** True if the field's input should be rendered (vs the read-only `render` output) for the current mode. */ +export function fieldIsEditableInMode( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + if (mode === "display") return false; + if (!field.editable || field.editable.length === 0) return false; + return field.editable.includes(mode); +} + +/** + * Whether the field participates in the document change-set (vs per-cell metadata + * deltas). Defaults to "document" for editable fields. Computed read-only fields + * are flagged as "metadata" so they are skipped by the change-set engine. + */ +export function fieldCommitScope( + field: LineItemsField, +): "document" | "metadata" { + const editableSomewhere = (field.editable?.length ?? 0) > 0; + if (!editableSomewhere) return "metadata"; + return field.commit ?? "document"; +} + +/** Whether the column accepts paste (TSV) for the current mode. */ +export function fieldAllowsPaste( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + return fieldIsEditableInMode(field, mode); +} + +/** Whether a fill-handle should be rendered for the current mode. */ +export function fieldAllowsFill( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + if (mode !== "edit") return false; + return fieldIsEditableInMode(field, "edit") && fieldCommitScope(field) === "document"; +} + +/* ======================================================================== */ +/* Field -> internal column adapter */ +/* ======================================================================== */ + +/** + * Translate a single `LineItemsField` into the internal `LineItemsColumnDef` + * shape consumed by `internals.ts` (change-set + normalization + equality). + * + * Numeric fields get a normalizer that coerces strings to numbers (trimming and + * rounding by `decimals`) and a tolerance-aware `equals` so cosmetic input + * variations like "1" vs "1.00" don't show up as document changes. + */ +export function fieldToColumnDef( + field: LineItemsField, +): LineItemsColumnDef { + const accessorKey = field.key as keyof T & string; + const scope = fieldCommitScope(field); + + const def: LineItemsColumnDef = { + id: field.key, + accessorKey, + mutationScope: scope, + }; + + if (field.type?.kind === "number") { + const decimals = field.type.decimals; + def.normalize = (value: unknown): unknown => { + if (value === "" || value === null || value === undefined) return null; + const n = typeof value === "number" ? value : Number(String(value).trim()); + if (Number.isNaN(n)) return value; + 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; + }; + } + + return def; +} + +/** Translate a list of fields into internal column defs. */ +export function fieldsToColumnDefs( + fields: LineItemsField[], +): LineItemsColumnDef[] { + return fields.map((f) => fieldToColumnDef(f)); +} 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 00000000..a6aa6856 --- /dev/null +++ b/packages/core/src/components/line-items/index.ts @@ -0,0 +1,33 @@ +export { LineItems } from "./LineItems"; +export { useLineItems } from "./use-line-items"; +export { createLineItemHelper } from "./field"; + +export type { + LineItemsBulkActionsProps, + LineItemsBulkActionsRenderArgs, + LineItemsAddRowProps, + LineItemsFullscreenToggleProps, + LineItemsSaveActionsProps, + LineItemsSearchProps, + LineItemsSearchToggleProps, +} from "./line-items-parts"; +export type { LineItemsRootProps } from "./line-items-root"; +export type { LineItemsTableProps } from "./line-items-table"; + +export type { + LineItemsChangeSet, + LineItemsColumnAlign, + LineItemsField, + LineItemsFieldCommit, + LineItemsFieldType, + LineItemsSelectOption, + LineItemsLineChange, + LineItemsLineChangeAction, + LineItemsMetadataCommit, + LineItemsMode, + LineItemsOrderingMode, + LineItemsRowData, + LineItemsRowPatch, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; 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 00000000..885fc149 --- /dev/null +++ b/packages/core/src/components/line-items/internals.ts @@ -0,0 +1,267 @@ +import type { + LineItemsChangeSet, + LineItemsLineChange, + LineItemsOrderingMode, + LineItemsRowData, + LineItemsRowPatch, +} from "./types"; + +/* ======================================================================== */ +/* Internal column shape consumed by the change-set engine */ +/* ======================================================================== */ + +export type LineItemsMutationScope = "document" | "metadata"; + +/** + * Internal column descriptor used by the change-set engine. Public API consumers + * never see this shape — they describe columns with `LineItemsField` and the + * `fieldToColumnDef` adapter (in `field.ts`) translates each field into this + * shape so the change-set logic below stays unchanged. + */ +export type LineItemsColumnDef = { + id: string; + accessorKey?: keyof TRow & string; + /** "document" (default): bundled into the change-set; "metadata": per-cell delta. */ + mutationScope?: LineItemsMutationScope; + /** Coerce raw input (e.g. trim strings, parse numbers) before equality compare. */ + normalize?: (value: unknown, row: TRow) => unknown; + /** Tolerance-aware equality (e.g. for floats). */ + equals?: (a: unknown, b: unknown, row: TRow) => boolean; +}; + +/* ======================================================================== */ +/* Baseline + change-set engine */ +/* ======================================================================== */ + +/** Baseline captures document order plus row snapshots keyed by ref. */ +export type LineItemsBaseline = { + order: string[]; + rows: Record; +}; + +function defaultNormalizeValue(value: unknown): unknown { + if (typeof value === "string") return value.trim(); + return value; +} + +function defaultEquality(a: unknown, b: unknown): boolean { + return Object.is(a, b); +} + +export function normalizeField( + cols: LineItemsColumnDef[], + key: string, + value: unknown, + row: TRow, +): unknown { + const col = cols.find((c) => (c.accessorKey as string | undefined) === key || c.id === key); + if (col?.normalize) return col.normalize(value, row); + return defaultNormalizeValue(value); +} + +function equalsField( + cols: LineItemsColumnDef[], + key: string, + a: unknown, + b: unknown, + row: TRow, +): boolean { + const col = cols.find((c) => (c.accessorKey as string | undefined) === key || c.id === key); + if (col?.equals) return col.equals(a, b, row); + return defaultEquality(a, b); +} + +export function computeDocumentPatches( + cols: LineItemsColumnDef[], + baseline: LineItemsBaseline, + currentOrder: readonly string[], + currentByRef: Record, + removedRefs: ReadonlySet, + insertedRefs: ReadonlySet, + orderingMode: LineItemsOrderingMode, +): LineItemsLineChange[] { + const documentKeys = columnsByScope(cols, "document"); + const lines: LineItemsLineChange[] = []; + + /** Removals for ids that existed in baseline. */ + for (const id of baseline.order) { + if (removedRefs.has(id) || !currentOrder.includes(id)) { + if (baseline.rows[id]) lines.push({ action: "remove", lineRef: id }); + } + } + + /** Adds — new logical ids never present in baseline. */ + for (const id of currentOrder) { + if (!insertedRefs.has(id) || baseline.rows[id]) continue; + const row = currentByRef[id]; + if (!row) continue; + const insertAfter = previousRefInOrder(currentOrder, id); + const patch = pickDocumentPatch(cols, row, documentKeys); + lines.push({ action: "add", lineRef: id, insertAfterLineRef: insertAfter, patch }); + } + + /** Updates for persisted rows. */ + for (const id of currentOrder) { + if (removedRefs.has(id)) continue; + const base = baseline.rows[id]; + const row = currentByRef[id]; + if (!row || !base) continue; + if (insertedRefs.has(id) && !baseline.rows[id]) continue; + + const patch: LineItemsRowPatch = {}; + for (const key of documentKeys) { + if (key === "lineRef") continue; + const rawCur = row[key as keyof TRow]; + const rawBase = base[key as keyof TRow]; + const nCur = normalizeField(cols, key, rawCur, row); + const nBase = normalizeField(cols, key, rawBase, base); + if (!equalsField(cols, key, nCur, nBase, row)) patch[key] = nCur; + } + if (Object.keys(patch).length > 0) lines.push({ action: "update", lineRef: id, patch }); + } + + if (orderingMode === "manual") { + lines.push( + ...diffPersistedMoves( + baseline.order, + currentOrder, + baseline.rows, + insertedRefs, + removedRefs, + currentByRef, + ), + ); + } + + return lines; +} + +/** Emit move ops for persisted ids whose predecessor changed vs baseline. */ +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 basePred = new Map(); + for (let i = 0; i < baseIds.length; i++) { + basePred.set(baseIds[i]!, i === 0 ? null : baseIds[i - 1]!); + } + + const moves: LineItemsLineChange[] = []; + /** Single pass — any id whose predecessor in current differs from predecessor in baseline moved. */ + for (let i = 0; i < curIds.length; i++) { + const id = curIds[i]!; + const curPred = i === 0 ? null : curIds[i - 1]!; + if (basePred.get(id) !== curPred) + moves.push({ action: "move", lineRef: id, afterLineRef: curPred }); + } + return moves; +} + +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 previousRefInOrder(order: readonly string[], id: string): string | null { + const idx = order.indexOf(id); + if (idx <= 0) return null; + return order[idx - 1]!; +} + +function columnsByScope( + cols: LineItemsColumnDef[], + scope: LineItemsMutationScope, +): string[] { + const keys: string[] = []; + for (const c of cols) { + const s = c.mutationScope ?? "document"; + if (s !== scope) continue; + const key = (c.accessorKey as string | undefined) ?? c.id; + if (key && key !== "lineRef") keys.push(key); + } + return keys; +} + +function pickDocumentPatch( + cols: LineItemsColumnDef[], + row: TRow, + documentKeys: string[], +): LineItemsRowPatch { + const patch: LineItemsRowPatch = {}; + for (const key of documentKeys) { + if (key === "lineRef") continue; + const v = row[key as keyof TRow]; + patch[key] = normalizeField(cols, key, v, row); + } + return patch; +} + +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 { lineChanges }; +} + +export function isChangeSetEmpty(cs: LineItemsChangeSet): boolean { + return cs.lineChanges.length === 0; +} + +/** Deep clone row for baseline using JSON when possible. */ +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 }; +} + +/** Test helper — resolve normalize for a column key. */ +export function getColumnNormalizeFn( + cols: LineItemsColumnDef[], + key: string, +): (value: unknown, row: TRow) => unknown { + const col = cols.find((c) => (c.accessorKey as string | undefined) === key || c.id === key); + return (v: unknown, row: TRow) => + col?.normalize ? col.normalize(v, row) : defaultNormalizeValue(v); +} diff --git a/packages/core/src/components/line-items/line-items-default-cell.tsx b/packages/core/src/components/line-items/line-items-default-cell.tsx new file mode 100644 index 00000000..eb57d555 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-default-cell.tsx @@ -0,0 +1,426 @@ +import * as React from "react"; + +import { + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxList, + ComboboxRoot, + ComboboxTrigger, +} from "@/components/combobox"; +import { cn } from "@/lib/utils"; + +import { + fieldAllowsFill, + fieldAllowsPaste, + fieldCommitScope, + fieldIsEditableInMode, +} from "./field"; +import { useLineItemsGrid } from "./line-items-grid-context"; +import type { GridCoord } from "./spreadsheet-logic"; +import type { + LineItemsColumnAlign, + LineItemsField, + LineItemsRowData, + LineItemsSelectOption, +} from "./types"; + +const alignClass: Record = { + left: "astw:text-left", + center: "astw:text-center", + right: "astw:text-right astw:tabular-nums", +}; + +function lineItemsSelectOptionToLabel(o: LineItemsSelectOption): string { + return [o.label, o.description].filter(Boolean).join(" ").trim(); +} + +function lineItemsSelectOptionToValue(o: LineItemsSelectOption): string { + return o.value; +} + +/* Re-export for callers that still want to test these guards externally. */ +export { fieldAllowsFill, fieldAllowsPaste, fieldIsEditableInMode }; + +/* ======================================================================== */ +/* Select (combobox) cell */ +/* ======================================================================== */ + +function SelectFieldCell({ + field, + lineRef, + row, + value, +}: { + field: LineItemsField; + lineRef: string; + row: T; + value: unknown; +}) { + const ctx = useLineItemsGrid(); + if (!ctx) return null; + + const t = field.type; + if (!t || t.kind !== "select") return null; + + const strVal = value == null ? "" : String(value); + const items = React.useMemo((): LineItemsSelectOption[] => { + const base = [...t.options]; + if (strVal && !base.some((o) => o.value === strVal)) { + base.push({ value: strVal, label: strVal }); + } + return base; + }, [t.options, strVal]); + + const selected = items.find((o) => o.value === strVal) ?? null; + + const onCommit = (next: unknown) => { + ctx.hookRef.current.updateField(lineRef, field.key as keyof T, next as T[keyof T]); + }; + + const onFocus = (e: React.FocusEvent) => { + ctx.onCellFocused({ lineRef, columnId: field.key }); + /* Spreadsheet ergonomics: focusing a cell selects all text so the next + keystroke replaces the value rather than appending to it. */ + e.currentTarget.select(); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if ( + e.altKey && + (e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight") + ) { + e.preventDefault(); + ctx.navigateArrowFromInput(e.key, e.shiftKey); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + if (fieldCommitScope(field) === "document") onCommit(strVal); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + ctx.navigateFromEdit("enter-down"); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + ctx.navigateFromEdit(e.shiftKey ? "shift-tab" : "tab"); + } + }; + + const className = typeof field.className === "string" ? field.className : field.className?.(row); + + return ( +
+ + items={items} + value={selected} + onValueChange={(next) => { + onCommit(next?.value ?? ""); + }} + itemToStringLabel={lineItemsSelectOptionToLabel} + itemToStringValue={lineItemsSelectOptionToValue} + > + + + + + + No matches. + + {(item: LineItemsSelectOption) => ( + +
+ {item.label} + {item.description ? ( + + {item.description} + + ) : null} +
+
+ )} +
+
+ +
+ ); +} + +/* ======================================================================== */ +/* Editable cell */ +/* ======================================================================== */ + +/** + * Always-visible input cell. Driven by the field's `type` (number vs text), + * with full spreadsheet keyboard wiring (Tab / Enter / Esc / Alt+Arrow / + * Ctrl+Enter) and `onFocus` syncing the active grid coord. + */ +function EditableFieldCell(p: { + field: LineItemsField; + lineRef: string; + row: T; + value: unknown; +}) { + const { field, lineRef, row, value } = p; + const ctx = useLineItemsGrid(); + if (!ctx) return null; + + const mode = ctx.mode; + const editable = fieldIsEditableInMode(field, mode); + + const [local, setLocal] = React.useState(() => (value == null ? "" : String(value))); + React.useEffect(() => { + setLocal(value == null ? "" : String(value)); + }, [value]); + + if (!editable) { + return ( + + {field.render(row)} + + ); + } + + const fieldType = field.type; + if (fieldType?.kind === "select") { + return ; + } + + const isNumeric = field.type?.kind === "number"; + + const parseLocalToCommit = (raw: string): unknown => { + if (isNumeric) { + if (raw === "") return null; + const n = Number(raw); + return Number.isNaN(n) ? raw : n; + } + return raw; + }; + + const onCommit = (next: unknown) => { + ctx.hookRef.current.updateField(lineRef, field.key as keyof T, next as T[keyof T]); + }; + + const onKeyDown = (e: React.KeyboardEvent) => { + if ( + e.altKey && + (e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight") + ) { + e.preventDefault(); + ctx.navigateArrowFromInput(e.key, e.shiftKey); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + const v = value; + const s = v == null ? "" : String(v); + setLocal(s); + if (fieldCommitScope(field) === "document") onCommit(parseLocalToCommit(s)); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + ctx.navigateFromEdit("enter-down"); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + ctx.navigateFromEdit(e.shiftKey ? "shift-tab" : "tab"); + } + }; + + const onFocus = (e: React.FocusEvent) => { + ctx.onCellFocused({ lineRef, columnId: field.key }); + /* Spreadsheet ergonomics: focusing a cell selects all text so the next + keystroke replaces the value rather than appending to it. Number inputs + don't support setSelectionRange in Chrome, but `select()` itself works. */ + e.currentTarget.select(); + }; + + const className = typeof field.className === "string" ? field.className : field.className?.(row); + + return ( + { + const v = e.target.value; + setLocal(v); + onCommit(parseLocalToCommit(v)); + }} + onFocus={onFocus} + onKeyDown={onKeyDown} + /> + ); +} + +/* ======================================================================== */ +/* Spreadsheet cell shell (overlays + fill grip) */ +/* ======================================================================== */ + +/** + * Wraps each cell with: + * - data attributes for grid coord lookup (drag-select / clipboard / fill drag), + * - focus / range / fill overlays (all `pointer-events-none`), + * - a fill drag handle on the active editable cell. + * + * Overlays NEVER block typing; the fill grip is a sibling with its own pointer events. + */ +function SpreadsheetCellShell({ + coord, + primary, + selected, + fillHighlight, + showFillGrip, + children, + onPointerDown, + onFillGripPointerDown, +}: { + coord: GridCoord; + primary: boolean; + selected: boolean; + fillHighlight: boolean; + showFillGrip: boolean; + children: React.ReactNode; + onPointerDown: (e: React.PointerEvent) => void; + onFillGripPointerDown: (e: React.PointerEvent) => void; +}) { + // Painted via inset box-shadow so the selection ring sits exactly on the cell's + // visible bounds — no absolute overlay, no rounding/transform mismatch. + const ringStyle: React.CSSProperties | undefined = + selected || fillHighlight ? { boxShadow: "inset 0 0 0 2px var(--primary)" } : undefined; + return ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- grid hit target requires pointer handler +
+ {children} + {showFillGrip ? ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions -- small drag handle requires pointer events + + ) : null} +
+ ); +} + +/* ======================================================================== */ +/* Public cell renderer */ +/* ======================================================================== */ + +export function LineItemsFieldCell({ + field, + lineRef, + row, +}: { + field: LineItemsField; + lineRef: string; + row: T; +}) { + const ctx = useLineItemsGrid(); + if (!ctx) return null; + + const mode = ctx.mode; + const value = (row as Record)[field.key]; + + const editable = fieldIsEditableInMode(field, mode); + + const cellBody = editable ? ( + + ) : ( + + {field.render(row)} + + ); + + const coord: GridCoord = { lineRef, columnId: field.key }; + const primary = ctx.isPrimaryCell(coord); + const inSel = ctx.isInSelection(coord); + const fillHighlight = ctx.isInFillPreview(coord); + const showFill = fieldAllowsFill(field, mode) && primary && ctx.fillPreview === null; + + return ( + ctx.onCellPointerDown(coord, e)} + onFillGripPointerDown={(e) => ctx.onFillGripPointerDown(coord, e)} + > + {cellBody} + + ); +} diff --git a/packages/core/src/components/line-items/line-items-grid-context.tsx b/packages/core/src/components/line-items/line-items-grid-context.tsx new file mode 100644 index 00000000..6255e25e --- /dev/null +++ b/packages/core/src/components/line-items/line-items-grid-context.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; + +import type { GridCoord } from "./spreadsheet-logic"; +import type { LineItemsRowData, UseLineItemsReturn } from "./types"; + +export type SpreadsheetFillPreview = { from: GridCoord; toLineRef: string }; + +/** + * Internal context shared between `` and the cell renderer. + * Wraps the public `useLineItems` return value and adds always-on cell-selection + * state (anchor, focus, fill drag, range overlays). Spreadsheet behaviors are + * always wired — there is no "classic" branch. + */ +export type LineItemsGridContextValue = { + /** + * Live ref to the current `useLineItems` return value. `useLineItems` returns + * a fresh object every render; consumers must always read the latest snapshot + * via `hookRef.current` rather than capturing it. This lets the grid context + * stay referentially stable across keystrokes (which is what keeps cell + * inputs from unmounting and losing focus mid-typing). + */ + hookRef: React.MutableRefObject>; + /** Memoized snapshot of `mode` (changes are rare; cheap to put in deps). */ + mode: UseLineItemsReturn["mode"]; + + /** Stable column id list for the editable schema (drives keyboard nav + paste). */ + schemaColumnIds: readonly string[]; + /** Currently visible row order (after filter / sort), used as the row axis. */ + orderedLineRefs: readonly string[]; + + /* ---- Cell selection state ------------------------------------------- */ + anchor: GridCoord | null; + focus: GridCoord | null; + fillPreview: SpreadsheetFillPreview | null; + + /* ---- Cell event handlers -------------------------------------------- */ + onCellPointerDown: (coord: GridCoord, e: React.PointerEvent, opts?: { shift?: boolean }) => void; + /** Called from a cell input's `onFocus` so the grid stays in sync without intercepting pointer events. */ + onCellFocused: (coord: GridCoord) => void; + onFillGripPointerDown: (coord: GridCoord, e: React.PointerEvent) => void; + + /** Tab / Shift+Tab / Enter from inside a cell input. */ + navigateFromEdit: (kind: "tab" | "shift-tab" | "enter-down") => void; + /** Alt+Arrow from inside a cell input. */ + navigateArrowFromInput: ( + arrowKey: "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight", + shiftExtend: boolean, + ) => void; + + isPrimaryCell: (coord: GridCoord) => boolean; + isInSelection: (coord: GridCoord) => boolean; + isInFillPreview: (coord: GridCoord) => boolean; +}; + +const LineItemsGridContext = + React.createContext | null>(null); + +export function LineItemsGridProvider({ + value, + children, +}: { + value: LineItemsGridContextValue; + children: React.ReactNode; +}) { + return {children}; +} + +export function useLineItemsGrid() { + const v = React.useContext(LineItemsGridContext); + return v as LineItemsGridContextValue | null; +} diff --git a/packages/core/src/components/line-items/line-items-internals.test.ts b/packages/core/src/components/line-items/line-items-internals.test.ts new file mode 100644 index 00000000..677117a9 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-internals.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; + +import { + buildChangeSet, + cloneBaseline, + isChangeSetEmpty, + type LineItemsBaseline, + type LineItemsColumnDef, +} from "./internals"; + +type DemoRow = Record & { + lineRef: string; + sku: string; + qty: number; + price: string; +}; + +describe("buildChangeSet", () => { + const cols: LineItemsColumnDef[] = [ + { id: "sku", accessorKey: "sku" }, + { + id: "qty", + accessorKey: "qty", + normalize: (v: unknown): unknown => Number(v === "" ? NaN : v), + equals: (a: unknown, b: unknown) => Number(a) === Number(b), + }, + { + id: "price", + accessorKey: "price", + normalize: (v: unknown) => (typeof v === "string" ? v.trim() : v), + }, + ]; + + it("reports no-op for pristine baseline-aligned rows", () => { + const rows: DemoRow[] = [ + { lineRef: "a", sku: "X", qty: 1, price: "10" }, + { lineRef: "b", sku: "Y", qty: 2, price: "20" }, + ]; + const byRef = Object.fromEntries(rows.map((r) => [r.lineRef, r])) as Record; + const baseline: LineItemsBaseline = cloneBaseline( + rows.map((r) => r.lineRef), + byRef, + ); + const cs = buildChangeSet( + cols as never, + baseline, + rows.map((r) => r.lineRef), + byRef, + new Set(), + new Set(), + "sort", + ); + expect(isChangeSetEmpty(cs)).toBe(true); + }); + + it("detects qty edit as update patch only for changed scalar", () => { + const baselineRows: DemoRow[] = [{ lineRef: "a", sku: "X", qty: 1, price: "10" }]; + const baseline = cloneBaseline( + baselineRows.map((r) => r.lineRef), + Object.fromEntries(baselineRows.map((r) => [r.lineRef, r])) as Record, + ); + const current: DemoRow = { ...baselineRows[0]!, qty: 2 }; + + const byRef = { a: current } as Record; + const cs = buildChangeSet(cols as never, baseline, ["a"], byRef, new Set(), new Set(), "sort"); + + expect(cs.lineChanges).toEqual([ + { + action: "update", + lineRef: "a", + patch: { qty: 2 }, + }, + ]); + }); + + it("treats string qty 10 and number 10 as equal when normalized", () => { + const baselineRows: DemoRow[] = [{ lineRef: "a", sku: "X", qty: 10, price: "1" }]; + const baseline = cloneBaseline( + baselineRows.map((r) => r.lineRef), + Object.fromEntries(baselineRows.map((r) => [r.lineRef, r])) as Record, + ); + + /** Round-trip phantom: normalized forms match */ + const current: DemoRow = { ...baselineRows[0]!, qty: Number("10.00") } as DemoRow; + + const cs = buildChangeSet( + cols as never, + baseline, + ["a"], + { a: current } as Record, + new Set(), + new Set(), + "sort", + ); + + expect(isChangeSetEmpty(cs)).toBe(true); + }); + + it("emits insert for client-only refs", () => { + const baseline = cloneBaseline([], {}); + const newRow: DemoRow = { lineRef: "n1", sku: "Z", qty: 5, price: "9" }; + + const order = ["n1"]; + + const byRef = { n1: newRow } as Record; + + const cs = buildChangeSet( + cols as never, + baseline, + order, + byRef, + new Set(), + new Set(["n1"]), + "sort", + ); + + expect(cs.lineChanges.some((x) => x.action === "add" && x.lineRef === "n1")).toBe(true); + }); +}); 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 00000000..f3474075 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-parts.tsx @@ -0,0 +1,320 @@ +import * as React from "react"; +import { Maximize2Icon, MinimizeIcon, SearchIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/button"; +import { Input } from "@/components/input"; + +import { useLineItemsRoot } from "./line-items-root"; +import type { LineItemsRowData } from "./types"; + +/* ======================================================================== */ +/* Search */ +/* ======================================================================== */ + +export type LineItemsSearchProps = { + placeholder?: string; + className?: string; + /** Controls visibility of the leading search icon. Default `true`. */ + showIcon?: boolean; +}; + +export function LineItemsSearch({ + placeholder = "Search lines…", + className, + showIcon = true, +}: LineItemsSearchProps) { + const { hook } = useLineItemsRoot(); + return ( +
+ {showIcon ? ( + + ) : null} + hook.setFilter(e.target.value)} + placeholder={placeholder} + className={cn("astw:h-9", showIcon && "astw:pl-8")} + data-slot="line-items-search" + aria-label="Search lines" + /> +
+ ); +} +LineItemsSearch.displayName = "LineItems.Search"; + +/* ======================================================================== */ +/* SearchToggle (icon button → inline-expand input) */ +/* ======================================================================== */ + +export type LineItemsSearchToggleProps = { + placeholder?: string; + className?: string; + /** Width in pixels of the expanded search input. Default `240`. */ + expandedWidth?: number; + /** + * Pixel width of the wrapper while collapsed. Should match the trigger + * button's outer size to avoid clipping its border. Default `28` (matches + * `astw:size-7`); pass `32` if using `triggerSizeClassName="astw:size-8"`. + */ + collapsedWidth?: number; + /** Button variant for the resting (collapsed) icon trigger. Default `"ghost"`. */ + variant?: "ghost" | "outline" | "secondary" | "default"; + /** Tailwind size class for the icon button (e.g. `"astw:size-8"`). Default `"astw:size-7"`. */ + triggerSizeClassName?: string; +}; + +/** + * Compact alternative to `LineItems.Search`. Renders an icon button at rest, + * and expands inline into a search input with a 200ms width transition when + * clicked. The input collapses again on blur if the filter is empty, or on + * Escape. Reads/writes the same `filter` state as `LineItems.Search`, so the + * two are interchangeable. + */ +export function LineItemsSearchToggle({ + placeholder = "Search lines…", + className, + expandedWidth = 240, + collapsedWidth = 28, + variant = "ghost", + triggerSizeClassName = "astw:size-7", +}: LineItemsSearchToggleProps = {}) { + const { hook } = useLineItemsRoot(); + const filter = hook.filter; + const setFilter = hook.setFilter; + const [expanded, setExpanded] = React.useState(filter !== ""); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (filter !== "") setExpanded(true); + }, [filter]); + + // Focus the input when it expands. Avoids `autoFocus` (a11y lint rule), but + // gives the same UX since expansion is always user-triggered. + React.useEffect(() => { + if (expanded) inputRef.current?.focus(); + }, [expanded]); + + return ( +
+ {expanded ? ( + setFilter(e.target.value)} + onBlur={() => { + if (filter === "") setExpanded(false); + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + setFilter(""); + setExpanded(false); + } + }} + placeholder={placeholder} + aria-label="Search lines" + className="astw:h-8 astw:w-full" + /> + ) : ( + + )} +
+ ); +} +LineItemsSearchToggle.displayName = "LineItems.SearchToggle"; + +/* ======================================================================== */ +/* BulkActions */ +/* ======================================================================== */ + +export type LineItemsBulkActionsRenderArgs = { + selectedIds: string[]; + bulkUpdate: (patch: Partial) => void; + bulkRemove: () => void; + clear: () => void; +}; + +export type LineItemsBulkActionsProps = { + /** Render-prop child receiving the selection + bulk action callbacks. */ + children: (args: LineItemsBulkActionsRenderArgs) => React.ReactNode; + /** Optional wrapper class. */ + className?: string; +}; + +export function LineItemsBulkActions({ + children, + className, +}: LineItemsBulkActionsProps) { + const { hook } = useLineItemsRoot(); + if (hook.selectedIds.length === 0) return null; + return ( +
+ + {hook.selectedIds.length} selected + + {children({ + selectedIds: hook.selectedIds, + bulkUpdate: hook.bulkUpdate, + bulkRemove: hook.bulkRemove, + clear: hook.clearSelection, + })} +
+ ); +} +// Cast preserves the generic call signature through the displayName property. +(LineItemsBulkActions as unknown as { displayName: string }).displayName = "LineItems.BulkActions"; + +/* ======================================================================== */ +/* AddRow */ +/* ======================================================================== */ + +export type LineItemsAddRowProps = { + className?: string; + children: React.ReactNode; +}; + +/** + * Sticky inline row pinned beneath the table body. Hosts pass arbitrary JSX + * (typically a `` or row of inputs) and call `lineItems.addLine()` + * directly to materialize a new line. + */ +export function LineItemsAddRow({ className, children }: LineItemsAddRowProps) { + return ( +
+ {children} +
+ ); +} +LineItemsAddRow.displayName = "LineItems.AddRow"; + +/* ======================================================================== */ +/* FullscreenToggle */ +/* ======================================================================== */ + +export type LineItemsFullscreenToggleProps = { + className?: string; + /** Button variant. Default `"ghost"`. */ + variant?: "ghost" | "outline" | "secondary" | "default"; +}; + +export function LineItemsFullscreenToggle({ + className, + variant = "ghost", +}: LineItemsFullscreenToggleProps = {}) { + const { fullscreen, setFullscreen } = useLineItemsRoot(); + return ( + + ); +} +LineItemsFullscreenToggle.displayName = "LineItems.FullscreenToggle"; + +/* ======================================================================== */ +/* SaveActions */ +/* ======================================================================== */ + +export type LineItemsSaveActionsProps = { + /** Called when the user clicks Save. Typically reads `hook.getChangeSet()` and submits. */ + onSave: () => void | Promise; + /** + * Called when the user clicks Discard. Defaults to `hook.reset()` (snaps the + * baseline back to the current row state, clearing dirty). Override when you + * need to revert to original server state instead. + */ + onDiscard?: () => void; + saveLabel?: React.ReactNode; + discardLabel?: React.ReactNode; + className?: string; +}; + +export function LineItemsSaveActions({ + onSave, + onDiscard, + saveLabel = "Save", + discardLabel = "Discard", + className, +}: LineItemsSaveActionsProps) { + const { hook } = useLineItemsRoot(); + const handleDiscard = onDiscard ?? (() => hook.reset()); + return ( +
+ + +
+ ); +} +LineItemsSaveActions.displayName = "LineItems.SaveActions"; diff --git a/packages/core/src/components/line-items/line-items-root.tsx b/packages/core/src/components/line-items/line-items-root.tsx new file mode 100644 index 00000000..bd83346a --- /dev/null +++ b/packages/core/src/components/line-items/line-items-root.tsx @@ -0,0 +1,103 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +import type { LineItemsRowData, UseLineItemsReturn } from "./types"; + +/* ======================================================================== */ +/* Root context (shared between LineItems.* compound parts) */ +/* ======================================================================== */ + +export type LineItemsRootContextValue = { + hook: UseLineItemsReturn; + fullscreen: boolean; + setFullscreen: React.Dispatch>; +}; + +const LineItemsRootContext = + React.createContext | null>(null); + +export function useLineItemsRoot(): LineItemsRootContextValue { + const v = React.useContext(LineItemsRootContext); + if (!v) { + throw new Error( + "LineItems compound parts must be rendered inside . " + + "Wrap your tree with .", + ); + } + return v as unknown as LineItemsRootContextValue; +} + +/* ======================================================================== */ +/* Root component */ +/* ======================================================================== */ + +export type LineItemsRootProps = { + /** The hook return value from `useLineItems()`. */ + value: UseLineItemsReturn; + className?: string; + children: React.ReactNode; +}; + +export function LineItemsRoot({ + value, + className, + children, +}: LineItemsRootProps) { + const [fullscreen, setFullscreen] = React.useState(false); + + React.useEffect(() => { + if (!fullscreen) return undefined; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.stopPropagation(); + setFullscreen(false); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [fullscreen]); + + const ctx = React.useMemo>( + () => ({ hook: value, fullscreen, setFullscreen }), + [value, fullscreen], + ); + + // Click on the backdrop (the wrapper itself, not a child) closes fullscreen. + // Children with their own pointer handlers don't bubble target===currentTarget, + // so this only fires for the dimmed padding around the card. + const onBackdropPointerDown = React.useCallback( + (e: React.PointerEvent) => { + if (!fullscreen) return; + if (e.target === e.currentTarget) setFullscreen(false); + }, + [fullscreen], + ); + + return ( + } + > +
[data-slot=card]]:h-full astw:[&>[data-slot=card]]:overflow-hidden", + "astw:[&_[data-slot=card-content]]:flex astw:[&_[data-slot=card-content]]:flex-1 astw:[&_[data-slot=card-content]]:min-h-0 astw:[&_[data-slot=card-content]]:flex-col", + ], + className, + )} + > + {children} +
+
+ ); +} +LineItemsRoot.displayName = "LineItems.Root"; 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 00000000..4f14af1e --- /dev/null +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -0,0 +1,879 @@ +/* eslint-disable jsx-a11y/no-static-element-interactions */ +import * as React from "react"; +import { + flexRender, + getCoreRowModel, + getSortedRowModel, + useReactTable, + type ColumnDef, + type Header, + type Row, + type SortingState, +} from "@tanstack/react-table"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { ArrowDownIcon, ArrowUpIcon, GripVerticalIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { cn } from "@/lib/utils"; +import { Table } from "@/components/table"; + +import { fieldAllowsPaste } from "./field"; +import { LineItemsFieldCell } from "./line-items-default-cell"; +import { + LineItemsGridProvider, + type LineItemsGridContextValue, + type SpreadsheetFillPreview, +} from "./line-items-grid-context"; +import { LineItemsFullscreenToggle } from "./line-items-parts"; +import { useLineItemsRoot } from "./line-items-root"; +import { getInternals } from "./use-line-items"; +import { + coordsToRowsMatrix, + moveSelectionCoord, + parseClipboardTsv, + rectangularCells, + sameCoord, + serializeMatrixTsv, + type GridCoord, +} from "./spreadsheet-logic"; +import type { LineItemsColumnAlign, LineItemsField, LineItemsRowData } from "./types"; + +const alignClass: Record = { + left: "astw:text-left", + center: "astw:text-center", + right: "astw:text-right astw:tabular-nums", +}; + +export type LineItemsTableProps = { + /** Max body height before vertical scroll kicks in. Defaults to `min(60vh, 480px)`. */ + maxBodyHeight?: React.CSSProperties["maxHeight"]; + className?: string; + tableContainerClassName?: string; + /** Render the built-in expand button in the table's top-right. Default `true`. */ + renderFullscreenToggle?: boolean; + /** Enable manual drag-to-reorder rows (only when the hook's `ordering` is `"manual"`). */ + enableDragReorder?: boolean; + /** Empty-state copy when there are no rows. */ + emptyMessage?: React.ReactNode; +}; + +export function LineItemsTable(props: LineItemsTableProps) { + const { + maxBodyHeight = "min(60vh, 480px)", + className, + tableContainerClassName, + renderFullscreenToggle = true, + enableDragReorder = false, + emptyMessage = "No lines yet.", + } = props; + + const root = useLineItemsRoot(); + const { hook, fullscreen } = root; + const { fields, mode, ordering } = hook; + + const internals = getInternals(hook); + if (!internals) { + throw new Error( + " could not read internal hook state. Make sure `value` was produced by `useLineItems()`.", + ); + } + + // `useLineItems` returns a fresh object every render, so we keep a ref to the + // current value. Cells / column renderers / event handlers read the latest + // hook via `hookRef.current` instead of capturing it in deps — otherwise the + // tanstack column array would be rebuilt on every keystroke and re-mount + // every input, dropping focus after a single character. + const hookRef = React.useRef(hook); + hookRef.current = hook; + const selectionEnabled = hook.selectionEnabled; + + /* ---- Cell-selection state ------------------------------------------- */ + + const [sorting, setSorting] = React.useState([]); + const [hoveredColumnId, setHoveredColumnId] = React.useState(null); + const [ssAnchor, setSsAnchor] = React.useState(null); + const [ssFocus, setSsFocus] = React.useState(null); + const [ssPointerDragActive, setSsPointerDragActive] = React.useState(false); + const [fillGestureSource, setFillGestureSource] = React.useState(null); + const [fillHoverLineRef, setFillHoverLineRef] = React.useState(null); + + const scrollParentRef = React.useRef(null); + const rowElRefs = React.useRef(new Map()); + + /* ---- Schema ids + Tanstack column defs ------------------------------ */ + + const schemaColumnIds = React.useMemo(() => fields.map((f) => f.key), [fields]); + const fieldByKey = React.useMemo(() => new Map(fields.map((f) => [f.key, f])), [fields]); + + /** Filtered visible rows from the hook (after search). */ + const data = hook.lines; + + const tanCols = React.useMemo((): ColumnDef[] => { + const cols: ColumnDef[] = []; + + if (selectionEnabled && mode !== "display") { + cols.push({ + id: "__select", + header: () => { + const live = hookRef.current; + const allSelected = + live.lines.length > 0 && live.selectedIds.length === live.lines.length; + return ( + { + if (e.target.checked) hookRef.current.selectAllVisible(); + else hookRef.current.clearSelection(); + }} + className="astw:size-4" + /> + ); + }, + cell: ({ row }) => ( +
+ hookRef.current.toggleSelect(row.original.lineRef)} + className="astw:size-4" + /> +
+ ), + size: 36, + }); + } + + if (enableDragReorder && ordering === "manual" && mode !== "display") { + cols.push({ + id: "__drag", + header: "", + cell: ({ row }) => ( +
+ { + e.dataTransfer.setData("text/line-ref", row.original.lineRef); + e.dataTransfer.effectAllowed = "move"; + }} + className="astw:inline-flex astw:cursor-grab astw:text-muted-foreground" + aria-label="Drag to reorder" + > + + +
+ ), + size: 28, + }); + } + + for (const field of fields) { + const f = field as LineItemsField; + cols.push({ + id: f.key, + accessorKey: f.key as string, + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + ), + enableSorting: ordering === "sort" && !!f.sort, + sortingFn: + ordering !== "sort" || !f.sort + ? "alphanumeric" + : (a, b) => f.sort!.comparator(a.original as T, b.original as T), + }); + } + + return cols; + }, [enableDragReorder, fields, mode, ordering, selectionEnabled]); + + const table = useReactTable({ + data, + columns: tanCols, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + ...(ordering === "sort" ? { getSortedRowModel: getSortedRowModel() } : {}), + getRowId: (row) => row.lineRef, + }); + + const allRows = table.getRowModel().rows; + + const rowVirtualizer = useVirtualizer({ + count: allRows.length, + getScrollElement: () => scrollParentRef.current, + estimateSize: () => 36, + overscan: 8, + }); + + const orderedLineRefs = React.useMemo(() => allRows.map((r) => r.original.lineRef), [allRows]); + + /* ---- Per-column resting / hover-expand width ------------------------ */ + + const getColumnWidthStyle = React.useCallback( + (colId: string): React.CSSProperties | undefined => { + const f = fieldByKey.get(colId); + if (!f) return undefined; + const expand = f.hoverExpandWidth; + const rest = f.width; + if (expand == null && rest == null) return undefined; + const target = expand != null && hoveredColumnId === colId ? expand : rest; + const style: React.CSSProperties = { + transition: "width 220ms ease-out, min-width 220ms ease-out", + }; + if (target != null) { + style.width = target; + style.minWidth = target; + } + return style; + }, + [fieldByKey, hoveredColumnId], + ); + + const onColumnHoverEnter = React.useCallback( + (colId: string) => { + const f = fieldByKey.get(colId); + if (f?.hoverExpandWidth != null) setHoveredColumnId(colId); + }, + [fieldByKey], + ); + + const onColumnHoverLeave = React.useCallback((colId: string) => { + setHoveredColumnId((cur) => (cur === colId ? null : cur)); + }, []); + + const ssFocusRef = React.useRef(null); + React.useEffect(() => { + ssFocusRef.current = ssFocus; + }, [ssFocus]); + + const allRowsRef = React.useRef(allRows); + allRowsRef.current = allRows; + + /* ---- Selection rectangle + fill preview ----------------------------- */ + + const selectionCoordsMemo = React.useMemo(() => { + if (!ssAnchor || !ssFocus) return [] as GridCoord[]; + return rectangularCells(orderedLineRefs, schemaColumnIds, ssAnchor, ssFocus); + }, [ssAnchor, ssFocus, orderedLineRefs, schemaColumnIds]); + + const fillPreview = React.useMemo(() => { + if (!fillGestureSource || !fillHoverLineRef) return null; + return { from: fillGestureSource, toLineRef: fillHoverLineRef }; + }, [fillGestureSource, fillHoverLineRef]); + + const isInFillPreview = React.useCallback( + (coord: GridCoord) => { + if (!fillPreview) return false; + if (coord.columnId !== fillPreview.from.columnId) return false; + const ri0 = orderedLineRefs.indexOf(fillPreview.from.lineRef); + const ri1 = orderedLineRefs.indexOf(fillPreview.toLineRef); + const ri = orderedLineRefs.indexOf(coord.lineRef); + if (ri0 < 0 || ri1 < 0 || ri < 0) return false; + return ri >= Math.min(ri0, ri1) && ri <= Math.max(ri0, ri1); + }, + [fillPreview, orderedLineRefs], + ); + + const isPrimaryCell = React.useCallback( + (coord: GridCoord) => sameCoord(coord, ssFocus), + [ssFocus], + ); + + const isInSelection = React.useCallback( + (coord: GridCoord) => + selectionCoordsMemo.some((c) => c.lineRef === coord.lineRef && c.columnId === coord.columnId), + [selectionCoordsMemo], + ); + + /* ---- Scroll active cell into view (only when focus *changes*) ------- */ + + const prevSsFocusRef = React.useRef(null); + React.useEffect(() => { + if (!ssFocus) { + prevSsFocusRef.current = ssFocus; + return undefined; + } + const prev = prevSsFocusRef.current; + if (prev && sameCoord(prev, ssFocus)) return undefined; + prevSsFocusRef.current = ssFocus; + const idx = allRowsRef.current.findIndex((r) => r.original.lineRef === ssFocus.lineRef); + if (idx >= 0) { + rowVirtualizer.scrollToIndex(idx, { align: "auto" }); + queueMicrotask(() => + rowElRefs.current.get(ssFocus.lineRef)?.scrollIntoView?.({ block: "nearest" }), + ); + } + return undefined; + }, [ssFocus, rowVirtualizer]); + + /* ---- Pointer drag (drag-select) ------------------------------------- */ + + React.useEffect(() => { + if (!ssPointerDragActive) return undefined; + const onMove = (e: PointerEvent) => { + const cell = document + .elementFromPoint(e.clientX, e.clientY) + ?.closest('[data-slot="line-items-grid-cell"]'); + const lr = cell?.getAttribute("data-line-ref"); + const cid = cell?.getAttribute("data-column-id"); + if (lr && cid) setSsFocus({ lineRef: lr, columnId: cid }); + }; + const onUp = () => setSsPointerDragActive(false); + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onUp); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onUp); + }; + }, [ssPointerDragActive]); + + /* ---- Fill drag gesture ---------------------------------------------- */ + + const fillHoverSyncRef = React.useRef(null); + React.useEffect(() => { + fillHoverSyncRef.current = fillHoverLineRef; + }, [fillHoverLineRef]); + + const fillGestureRef = React.useRef(null); + React.useEffect(() => { + fillGestureRef.current = fillGestureSource; + }, [fillGestureSource]); + + React.useEffect(() => { + if (!fillGestureSource) return undefined; + const source = fillGestureSource; + fillHoverSyncRef.current = source.lineRef; + + const onMove = (e: PointerEvent) => { + const tr = document.elementFromPoint(e.clientX, e.clientY)?.closest("tr[data-line-ref]"); + const lr = tr?.getAttribute("data-line-ref"); + if (lr) { + fillHoverSyncRef.current = lr; + setFillHoverLineRef(lr); + } + }; + const finish = () => { + const gesture = fillGestureRef.current; + const endLine = fillHoverSyncRef.current ?? source.lineRef; + setFillGestureSource(null); + setFillHoverLineRef(null); + if (!gesture) return; + const field = fieldByKey.get(gesture.columnId); + if (!field) return; + const accessor = field.key as keyof T; + const srcRow = hook.allLines.find((r) => r.lineRef === gesture.lineRef); + if (!srcRow) return; + const v = srcRow[accessor]; + const ri0 = orderedLineRefs.indexOf(gesture.lineRef); + const ri1 = orderedLineRefs.indexOf(endLine); + if (ri0 < 0 || ri1 < 0) return; + const updates: { lineRef: string; patch: Partial }[] = []; + for (let i = Math.min(ri0, ri1); i <= Math.max(ri0, ri1); i++) { + const lineRef = orderedLineRefs[i]; + if (!lineRef) continue; + updates.push({ lineRef, patch: { [accessor]: v } as Partial }); + } + if (updates.length) hook.updateLines(updates); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", finish); + window.addEventListener("pointercancel", finish); + return () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", finish); + window.removeEventListener("pointercancel", finish); + }; + }, [fillGestureSource, fieldByKey, hook, orderedLineRefs]); + + /* ---- Focus a cell input by coord (queueMicrotask to allow re-render) - */ + + const focusSsInput = React.useCallback((coord: GridCoord | null) => { + if (!coord) return; + queueMicrotask(() => { + const scrollEl = scrollParentRef.current; + const escapeSelector = + typeof CSS !== "undefined" && typeof CSS.escape === "function" + ? CSS.escape + : (s: string) => s.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + const sel = `[data-slot="line-items-grid-cell"][data-line-ref="${escapeSelector(coord.lineRef)}"][data-column-id="${escapeSelector(coord.columnId)}"] input`; + (scrollEl?.querySelector(sel) as HTMLElement | null)?.focus(); + }); + }, []); + + /* ---- Keyboard nav (from input or grid root) ------------------------- */ + + const navigateFromEdit = React.useCallback( + (kind: "tab" | "shift-tab" | "enter-down") => { + const cur = ssFocusRef.current; + if (!cur) { + queueMicrotask(() => scrollParentRef.current?.focus()); + return; + } + let next: GridCoord | null = null; + if (kind === "enter-down") { + next = moveSelectionCoord("ArrowDown", cur, orderedLineRefs, schemaColumnIds) ?? cur; + } else if (kind === "tab") { + const r = moveSelectionCoord("ArrowRight", cur, orderedLineRefs, schemaColumnIds); + if (r && !sameCoord(r, cur)) next = r; + else { + const down = moveSelectionCoord("ArrowDown", cur, orderedLineRefs, schemaColumnIds); + const firstCol = schemaColumnIds[0]; + next = down && firstCol ? { lineRef: down.lineRef, columnId: firstCol } : cur; + } + } else if (kind === "shift-tab") { + const l = moveSelectionCoord("ArrowLeft", cur, orderedLineRefs, schemaColumnIds); + if (l && !sameCoord(l, cur)) next = l; + else { + const up = moveSelectionCoord("ArrowUp", cur, orderedLineRefs, schemaColumnIds); + const lastCol = schemaColumnIds[schemaColumnIds.length - 1]; + next = up && lastCol ? { lineRef: up.lineRef, columnId: lastCol } : cur; + } + } + if (next && !sameCoord(next, cur)) { + setSsAnchor(next); + setSsFocus(next); + queueMicrotask(() => focusSsInput(next)); + } + }, + [focusSsInput, orderedLineRefs, schemaColumnIds], + ); + + const navigateArrowFromInput = React.useCallback( + (arrowKey: "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight", shiftExtend: boolean) => { + const cur = ssFocusRef.current; + if (!cur || !schemaColumnIds.length) return; + const next = moveSelectionCoord(arrowKey, cur, orderedLineRefs, schemaColumnIds); + if (!next || sameCoord(next, cur)) return; + if (shiftExtend) setSsAnchor((a) => a ?? cur); + else setSsAnchor(next); + setSsFocus(next); + queueMicrotask(() => focusSsInput(next)); + }, + [focusSsInput, orderedLineRefs, schemaColumnIds], + ); + + const onCellPointerDown = React.useCallback( + (coord: GridCoord, e: React.PointerEvent) => { + if (mode === "display") return; + if (fillGestureSource) return; + if (e.button !== 0) return; + const tgt = e.target as HTMLElement | null; + const insideInteractive = !!tgt?.closest?.( + "input,textarea,button,select,[contenteditable=true]", + ); + + if (e.shiftKey) { + setSsAnchor((a) => a ?? ssFocusRef.current ?? coord); + setSsFocus(coord); + return; + } + + if (insideInteractive) return; + + setSsAnchor(coord); + setSsFocus(coord); + setSsPointerDragActive(true); + scrollParentRef.current?.focus(); + }, + [fillGestureSource, mode], + ); + + const onCellFocused = React.useCallback( + (coord: GridCoord) => { + if (mode === "display") return; + const cur = ssFocusRef.current; + if (cur && sameCoord(cur, coord)) return; + setSsAnchor(coord); + setSsFocus(coord); + }, + [mode], + ); + + const onFillGripPointerDown = React.useCallback( + (coord: GridCoord, e: React.PointerEvent) => { + e.preventDefault(); + e.stopPropagation(); + const field = fieldByKey.get(coord.columnId); + if (!field) return; + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + setFillGestureSource(coord); + setFillHoverLineRef(coord.lineRef); + }, + [fieldByKey], + ); + + /* ---- Grid root keyboard / clipboard --------------------------------- */ + + const onGridKeyDown = React.useCallback( + (e: React.KeyboardEvent) => { + if (mode === "display") return; + const scrollEl = scrollParentRef.current; + const ae = document.activeElement; + if (!scrollEl || !ae || !scrollEl.contains(ae)) return; + if (ae !== scrollEl && ae.matches("input,textarea,button,select,[contenteditable=true]")) + return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if ( + e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight" + ) { + const cur = ssFocusRef.current; + if (!cur || !schemaColumnIds.length) return; + e.preventDefault(); + const next = moveSelectionCoord(e.key, cur, orderedLineRefs, schemaColumnIds); + if (!next || sameCoord(next, cur)) return; + if (e.shiftKey) setSsAnchor((a) => a ?? cur); + else setSsAnchor(next); + setSsFocus(next); + queueMicrotask(() => focusSsInput(next)); + } + }, + [focusSsInput, mode, orderedLineRefs, schemaColumnIds], + ); + + const onGridCopy = React.useCallback( + (e: React.ClipboardEvent) => { + const ae = typeof document !== "undefined" ? document.activeElement : null; + if (ae && ae.matches?.("input,textarea,select,[contenteditable=true]")) { + const inGridCell = ae.closest?.("[data-slot='line-items-grid-cell']"); + if (!inGridCell) return; + } + const coords = selectionCoordsMemo; + if (!coords.length) return; + e.preventDefault(); + const clip = coordsToRowsMatrix(orderedLineRefs, schemaColumnIds, coords, (lr, cid) => { + const f = fieldByKey.get(cid); + const accessor = (f?.key ?? cid) as keyof T; + const rowData = hook.allLines.find((r) => r.lineRef === lr); + return rowData ? String(rowData[accessor] ?? "") : ""; + }); + e.clipboardData.setData("text/plain", serializeMatrixTsv(clip)); + }, + [selectionCoordsMemo, orderedLineRefs, schemaColumnIds, fieldByKey, hook.allLines], + ); + + const onGridPaste = React.useCallback( + (e: React.ClipboardEvent) => { + if (mode === "display" || !ssFocus) return; + const ae = typeof document !== "undefined" ? document.activeElement : null; + if (ae && ae.matches?.("input,textarea,select,[contenteditable=true]")) { + const inGridCell = ae.closest?.("[data-slot='line-items-grid-cell']"); + if (!inGridCell) return; + } + e.preventDefault(); + const text = e.clipboardData.getData("text/plain") ?? ""; + const gridParsed = parseClipboardTsv(text); + if (!gridParsed.length || !orderedLineRefs.length) return; + + const startRi = orderedLineRefs.indexOf(ssFocus.lineRef); + const startCi = schemaColumnIds.indexOf(ssFocus.columnId); + if (startRi < 0 || startCi < 0) return; + + let skipped = 0; + const updates: { lineRef: string; patch: Partial }[] = []; + const existing = new Map>(); + + for (let r = 0; r < gridParsed.length; r++) { + const rowLineRef = orderedLineRefs[startRi + r]; + if (!rowLineRef) break; + const rowObj = hook.allLines.find((row) => row.lineRef === rowLineRef); + if (!rowObj) continue; + const line = gridParsed[r]!; + const rowPatch: Partial = {}; + let rowHas = false; + for (let c = 0; c < line.length; c++) { + const colId = schemaColumnIds[startCi + c]; + if (!colId) break; + const field = fieldByKey.get(colId); + if (!field || !fieldAllowsPaste(field, mode)) { + skipped++; + continue; + } + const raw = line[c]; + const parsed = coerceForField(field, raw); + (rowPatch as Record)[field.key] = parsed as never; + rowHas = true; + } + if (!rowHas) continue; + const prev = existing.get(rowLineRef) ?? {}; + existing.set(rowLineRef, { ...prev, ...rowPatch }); + } + for (const [lineRef, patch] of existing.entries()) updates.push({ lineRef, patch }); + if (updates.length) hook.updateLines(updates); + if (skipped) toast.info("Some cells were skipped (read-only columns)."); + }, + [mode, ssFocus, orderedLineRefs, schemaColumnIds, fieldByKey, hook], + ); + + /* ---- Drag-reorder (drop on row / drop on grid background) ----------- */ + + const handleDropOnRow = (afterLineRef: string | null, e: React.DragEvent) => { + e.preventDefault(); + if (!enableDragReorder || ordering !== "manual") return; + const dragged = e.dataTransfer.getData("text/line-ref"); + if (!dragged || dragged === afterLineRef) return; + hook.reorderLine(dragged, afterLineRef); + }; + + const lastOrderRef = data.length ? (data[data.length - 1]?.lineRef ?? null) : null; + + /* ---- Grid context (for cells) --------------------------------------- */ + + const gridCtx = React.useMemo>( + () => ({ + hookRef, + mode, + schemaColumnIds, + orderedLineRefs, + anchor: ssAnchor, + focus: ssFocus, + fillPreview, + onCellPointerDown, + onCellFocused, + navigateFromEdit, + navigateArrowFromInput, + onFillGripPointerDown, + isPrimaryCell, + isInSelection, + isInFillPreview, + }), + // `hookRef` is a stable ref object; intentionally excluded from deps. Excluding + // the per-render `hook` snapshot here is critical — putting it in deps would + // bust this memo on every keystroke, fan a new context value through every + // visible cell, and re-mount cell inputs (which is what was dropping focus + // after the first character). + [ + mode, + schemaColumnIds, + orderedLineRefs, + ssAnchor, + ssFocus, + fillPreview, + onCellPointerDown, + onCellFocused, + navigateFromEdit, + navigateArrowFromInput, + onFillGripPointerDown, + isPrimaryCell, + isInSelection, + isInFillPreview, + ], + ); + + /* ---- Render --------------------------------------------------------- */ + + const tableWidthClass = "astw:w-full astw:caption-bottom astw:text-sm"; + const vItems = rowVirtualizer.getVirtualItems(); + const colCount = Math.max(1, table.getVisibleLeafColumns().length); + const padTop = vItems.length ? vItems[0]!.start : 0; + const padBot = vItems.length ? rowVirtualizer.getTotalSize() - vItems[vItems.length - 1]!.end : 0; + + return ( +
+ {renderFullscreenToggle ? ( +
+ +
+ ) : null} + + } + > +
{ + if (!enableDragReorder || ordering !== "manual") return; + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={(e) => { + if (!enableDragReorder || ordering !== "manual") return; + if ((e.target as HTMLElement).closest("tr[data-line-ref]")) return; + handleDropOnRow(lastOrderRef, e); + }} + > + {data.length === 0 ? ( +
+ {emptyMessage} +
+ ) : ( + + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header: Header) => { + const colId = header.column.columnDef.id ?? header.column.id; + const widthStyle = getColumnWidthStyle(colId); + return ( + + ); + })} + + ))} + + + {padTop > 0 ? ( + + + ) : null} + {vItems.map((vi) => { + const row = allRows[vi.index] as Row | undefined; + if (!row) return null; + return ( + { + rowElRefs.current.set(row.original.lineRef, el); + }} + className={cn( + "astw:data-[state=selected]:bg-muted astw:border-b astw:border-border", + )} + style={{ height: vi.size }} + onDragOver={(e) => { + if (!enableDragReorder || ordering !== "manual") return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={(e) => { + e.stopPropagation(); + handleDropOnRow(row.original.lineRef, e); + }} + > + {row.getVisibleCells().map((cell) => { + const colId = cell.column.id; + const widthStyle = getColumnWidthStyle(colId); + return ( + onColumnHoverEnter(colId)} + onMouseLeave={() => onColumnHoverLeave(colId)} + > + {/* + Cell content renders directly inside the + ); + })} + {padBot > 0 ? ( + + + ) : null} + +
onColumnHoverEnter(colId)} + onMouseLeave={() => onColumnHoverLeave(colId)} + > + {flexRender(header.column.columnDef.header, header.getContext())} +
+
as a relative flex box. + No absolute-positioned wrapper → the cell box, the input, and the + selection overlay all share identical bounds. Selection rectangle is + painted via an inset box-shadow on the shell so it matches edges + pixel-for-pixel without any layout displacement. + */} + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} +
+
+ )} +
+
+
+ ); +} +LineItemsTable.displayName = "LineItems.Table"; + +/* ======================================================================== */ +/* Helpers */ +/* ======================================================================== */ + +/** Coerce a TSV string into the field's typed value (mirrors the input parser). */ +function coerceForField( + field: LineItemsField, + raw: string | undefined, +): unknown { + if (raw === undefined) return raw; + if (field.type?.kind === "number") { + const trimmed = raw.trim(); + if (trimmed === "") return null; + const n = Number(trimmed); + return Number.isNaN(n) ? raw : n; + } + /* text + select: commit trimmed string (select options validated in UI, not here) */ + return raw.trim(); +} 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 00000000..4159b3be --- /dev/null +++ b/packages/core/src/components/line-items/spreadsheet-logic.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { + coordsToRowsMatrix, + moveSelectionCoord, + parseClipboardTsv, + rectangularCells, + sameCoord, + serializeMatrixTsv, + type GridCoord, +} from "./spreadsheet-logic"; + +describe("spreadsheet-logic", () => { + const lines = ["r1", "r2", "r3"]; + const cols = ["a", "b", "c"]; + + it("moveSelectionCoord moves inside bounds", () => { + expect(moveSelectionCoord("ArrowRight", { lineRef: "r1", columnId: "a" }, lines, cols)).toEqual( + { lineRef: "r1", columnId: "b" }, + ); + + expect(moveSelectionCoord("ArrowRight", { lineRef: "r1", columnId: "c" }, lines, cols)).toEqual( + { lineRef: "r1", columnId: "c" }, + ); + + expect(moveSelectionCoord("ArrowDown", { lineRef: "r3", columnId: "b" }, lines, cols)).toEqual({ + lineRef: "r3", + columnId: "b", + }); + }); + + it("rectangularCells returns row-major stripes", () => { + const a: GridCoord = { lineRef: "r1", columnId: "a" }; + const b: GridCoord = { lineRef: "r3", columnId: "b" }; + const cells = rectangularCells(lines, cols, a, b); + expect(cells).toContainEqual({ lineRef: "r2", columnId: "b" }); + expect(cells.length).toBe(6); + }); + + it("coordsToRowsMatrix preserves column order from schema", () => { + const coords = rectangularCells( + lines, + cols, + { lineRef: "r1", columnId: "c" }, + { lineRef: "r2", columnId: "a" }, + ); + const m = coordsToRowsMatrix(lines, cols, coords, (_lr, cid) => cid); + expect(m).toEqual([ + ["a", "b", "c"], + ["a", "b", "c"], + ]); + }); + + it("serializeMatrixTsv and parseClipboardTsv round-trip and trim trailing newline", () => { + const tsv = serializeMatrixTsv([ + ["a", "b"], + ["1", "2"], + ]); + expect(tsv).toBe("a\tb\n1\t2"); + const back = parseClipboardTsv(`${tsv}\n\n`); + expect(back).toEqual([ + ["a", "b"], + ["1", "2"], + ]); + }); + + it("sameCoord compares coords", () => { + expect(sameCoord({ lineRef: "r1", columnId: "a" }, { lineRef: "r1", columnId: "a" })).toBe( + true, + ); + expect(sameCoord({ lineRef: "r1", columnId: "a" }, 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 00000000..d5a649d9 --- /dev/null +++ b/packages/core/src/components/line-items/spreadsheet-logic.ts @@ -0,0 +1,109 @@ +/** Logical cell coordinate in the editable grid area (excluding __ columns). */ +export type GridCoord = { lineRef: string; columnId: string }; + +export function moveSelectionCoord( + dir: "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight", + 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 }; +} + +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; +} + +/** Build rectangular row slice for TSV in schema column order within the bbox. */ +export function coordsToRowsMatrix( + orderedLineRefs: readonly string[], + columnIdsOrdered: readonly string[], + coords: 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 lines: string[] = []; + for (let r = r0; r <= r1; r++) { + const lr = orderedLineRefs[r]; + if (lr) lines.push(lr); + } + + const columnSpan: string[] = []; + for (let c = c0; c <= c1; c++) { + const id = columnIdsOrdered[c]; + if (id) columnSpan.push(id); + } + + return lines.map((lr) => columnSpan.map((cid) => getDisplay(lr, cid))); +} + +export function serializeMatrixTsv(rows: 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 00000000..fbec0ea7 --- /dev/null +++ b/packages/core/src/components/line-items/types.ts @@ -0,0 +1,192 @@ +import * as React from "react"; + +/* ======================================================================== */ +/* Domain primitives */ +/* ======================================================================== */ + +/** Discriminated line change operations (transport-agnostic; apps map to GraphQL/REST). */ +export type LineItemsLineChangeAction = "add" | "update" | "remove" | "move"; + +export type LineItemsRowPatch = Record; + +export type LineItemsLineChange = + | { + action: "add"; + lineRef: string; + /** `null` inserts at the start of the document order. */ + insertAfterLineRef: string | null; + patch: LineItemsRowPatch; + } + | { action: "update"; lineRef: string; patch: LineItemsRowPatch } + | { action: "remove"; lineRef: string } + | { + action: "move"; + lineRef: string; + /** `null` moves to the front. */ + afterLineRef: string | null; + }; + +export type LineItemsChangeSet = { + lineChanges: LineItemsLineChange[]; +}; + +export type LineItemsMode = "display" | "edit" | "amend"; +export type LineItemsOrderingMode = "sort" | "manual"; +export type LineItemsColumnAlign = "left" | "center" | "right"; + +/** Every row exposes a stable `lineRef` (server id when present, else a generated temp id). */ +export type LineItemsRowData = Record & { lineRef: string }; + +/** Per-cell delta emitted by metadata-scoped fields (see `LineItemsField.commit`). */ +export type LineItemsMetadataCommit = { + lineRef: string; + patch: LineItemsRowPatch; + previous: LineItemsRowPatch; + row: TRow; +}; + +/* ======================================================================== */ +/* Field schema */ +/* ======================================================================== */ + +/** One option for `kind: "select"` fields (SKU pickers, UoM, etc.). */ +export type LineItemsSelectOption = { + value: string; + label: string; + description?: string; +}; + +/** + * Drives input type, formatting, normalization, and equality for an editable field. + * Required when the field is editable; omit for read-only / computed fields. + */ +export type LineItemsFieldType = + | { kind: "text" } + | { kind: "number"; decimals?: number } + | { + kind: "select"; + options: ReadonlyArray; + placeholder?: string; + }; + +/** + * Describes how cell mutations are propagated: + * - "document" (default): bundled into the document change-set (see `getChangeSet()`). + * - "metadata": per-cell deltas that bypass the change-set (e.g. journal-entry notes + * that update on commit even while the rest of the document is in `amend` mode). + */ +export type LineItemsFieldCommit = "document" | "metadata"; + +/** + * One column in the line-items table. + * + * The `key` property doubles as the column id AND the property name on `T` for + * editable fields. Computed/derived columns (e.g. a "total" column) just pick a + * unique string for `key` and omit `editable` / `type`. + * + * Build with `createLineItemHelper().field({ ... })` for full TypeScript + * inference of `key`, `render`, and `sort.comparator`. + */ +export type LineItemsField = { + /** Stable column id. For editable fields use a real key on `T`. */ + // eslint-disable-next-line @typescript-eslint/ban-types -- the `string & {}` wrapper preserves keyof inference + key: (keyof T & string) | (string & {}); + /** Header content (string or any React node). */ + label: React.ReactNode; + /** Cell content for read-only display AND when no editor is shown. */ + render: (line: T) => React.ReactNode; + /** + * Modes in which an editable input replaces `render`. Empty/omitted -> read-only. + * For typical PO/SO line columns this is `["edit"]`. Notes and similar metadata + * use `["edit", "amend"]` together with `commit: "metadata"`. + */ + editable?: LineItemsMode[]; + /** Required when the field is editable; drives input type and equality semantics. */ + type?: LineItemsFieldType; + /** "document" (default) bundles into the change-set; "metadata" emits per-cell deltas. */ + commit?: LineItemsFieldCommit; + /** Adds a sort affordance to the column header; called when the user clicks it. */ + sort?: { comparator: (a: T, b: T) => number }; + /** Returns `true` if `line` matches the current search query. */ + search?: (line: T, query: string) => boolean; + align?: LineItemsColumnAlign; + className?: string | ((line: T) => string | undefined); + /** Resting column width in pixels. Honored when set; otherwise auto-sized. */ + width?: number; + /** + * If set, the column widens to this pixel value while the user hovers any cell + * (header or body) in the column, with a subtle CSS transition. Useful for + * dense columns that show truncated content (e.g. a SKU + product label). + */ + hoverExpandWidth?: number; +}; + +/* ======================================================================== */ +/* Hook surface */ +/* ======================================================================== */ + +export type UseLineItemsOptions = { + /** Column / field schema. Build with `createLineItemHelper().field({...})`. */ + fields: LineItemsField[]; + /** Uncontrolled seed (used as the dirty-tracking baseline). */ + data?: readonly T[]; + /** Controlled lines + change handler. When set, `data` is ignored. */ + lines?: readonly T[]; + onLinesChange?: (next: T[]) => void; + /** Default `"edit"`. */ + mode?: LineItemsMode; + /** Default `"sort"`. */ + ordering?: LineItemsOrderingMode; + /** Enable bulk row checkbox selection. */ + selection?: boolean; + /** Per-cell delta callback for `commit: "metadata"` fields. */ + onMetadataCommit?: (event: LineItemsMetadataCommit) => void; +}; + +export type UseLineItemsReturn = { + /* ---- Reactive state ---- */ + /** Filtered (search-applied) document order. */ + lines: T[]; + /** Unfiltered document order. */ + allLines: T[]; + fields: LineItemsField[]; + mode: LineItemsMode; + ordering: LineItemsOrderingMode; + /** True when the current state diverges from the baseline. */ + isDirty: boolean; + filter: string; + /** Whether bulk row selection is enabled (mirrors `UseLineItemsOptions.selection`). */ + selectionEnabled: boolean; + selectedIds: string[]; + + /* ---- Imperative ---- */ + setMode: (m: LineItemsMode) => void; + setFilter: (q: string) => void; + /** Insert a new logical line. Returns the new lineRef. */ + addLine: (data: Partial>, opts?: { afterLineRef?: string | null }) => string; + removeLine: (lineRef: string) => void; + /** Single-cell typed update. */ + updateField: (lineRef: string, key: K, value: T[K]) => void; + /** Batched updates (used by paste/fill and for a single re-render). */ + updateLines: (patches: { lineRef: string; patch: Partial }[]) => void; + /** Order-only reorder; only meaningful with `ordering: "manual"`. */ + reorderLine: (lineRef: string, afterLineRef: string | null) => void; + toggleSelect: (lineRef: string) => void; + /** Select every currently-visible (filtered) row. */ + selectAllVisible: () => void; + clearSelection: () => void; + /** Apply `patch` to every selected row. */ + bulkUpdate: (patch: Partial) => void; + /** Remove every selected row. */ + bulkRemove: () => void; + duplicateLastLine: (derive?: (prev: T, newRef: string) => T) => string | undefined; + /** Snap the baseline to the current state (typically called after a successful save). */ + reset: () => void; + /** + * Revert current row state back to the dirty-tracking baseline — discards + * every uncommitted edit/insert/remove in one shot. Use this for "Discard + * changes" UI; pair with `reset()` after a successful save. + */ + revert: () => void; + getChangeSet: () => LineItemsChangeSet; +}; diff --git a/packages/core/src/components/line-items/use-line-items.test.tsx b/packages/core/src/components/line-items/use-line-items.test.tsx new file mode 100644 index 00000000..6de3e916 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items.test.tsx @@ -0,0 +1,263 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createLineItemHelper } from "./field"; +import { useLineItems } from "./use-line-items"; +import type { LineItemsField, LineItemsRowData } from "./types"; + +afterEach(() => { + cleanup(); +}); + +type DemoLine = LineItemsRowData & { + sku: string; + qty: number; + unitPrice: number; + note: string; +}; + +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: "qty", + label: "Qty", + render: (l) => l.qty, + editable: ["edit", "amend"], + type: { kind: "number", decimals: 0 }, + }), + f.field({ + key: "unitPrice", + label: "Unit price", + render: (l) => l.unitPrice, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + }), + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit", "amend"], + type: { kind: "text" }, + commit: "metadata", + }), +]; + +const seed = (): DemoLine[] => [ + { lineRef: "a", sku: "X", qty: 1, unitPrice: 10, note: "" }, + { lineRef: "b", sku: "Y", qty: 2, unitPrice: 20, note: "" }, +]; + +describe("useLineItems", () => { + it("seeds rows and reports a clean baseline", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + expect(result.current.allLines).toHaveLength(2); + expect(result.current.lines).toHaveLength(2); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().lineChanges).toEqual([]); + }); + + it("addLine returns the new lineRef and marks dirty", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + let newRef = ""; + act(() => { + newRef = result.current.addLine({ sku: "Z", qty: 3, unitPrice: 30, note: "" }); + }); + expect(newRef).toBeTruthy(); + expect(result.current.allLines).toHaveLength(3); + expect(result.current.isDirty).toBe(true); + const cs = result.current.getChangeSet(); + expect(cs.lineChanges.some((c) => c.action === "add" && c.lineRef === newRef)).toBe(true); + }); + + it("updateField writes a single cell and shows up as an update in the change set", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + result.current.updateField("a", "qty", 5); + }); + expect(result.current.isDirty).toBe(true); + const cs = result.current.getChangeSet(); + expect(cs.lineChanges).toEqual([{ action: "update", lineRef: "a", patch: { qty: 5 } }]); + }); + + it("updateLines applies batched patches in one render", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + result.current.updateLines([ + { lineRef: "a", patch: { qty: 7 } }, + { lineRef: "b", patch: { qty: 9 } }, + ]); + }); + const cs = result.current.getChangeSet(); + expect(cs.lineChanges).toEqual([ + { action: "update", lineRef: "a", patch: { qty: 7 } }, + { action: "update", lineRef: "b", patch: { qty: 9 } }, + ]); + }); + + it("removeLine emits a remove change", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + result.current.removeLine("a"); + }); + expect(result.current.allLines).toHaveLength(1); + expect(result.current.getChangeSet().lineChanges).toEqual([{ action: "remove", lineRef: "a" }]); + }); + + it("reorderLine emits a move change in manual ordering", () => { + const { result } = renderHook(() => + useLineItems({ fields, data: seed(), ordering: "manual" }), + ); + act(() => { + result.current.reorderLine("a", "b"); + }); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["b", "a"]); + const cs = result.current.getChangeSet(); + expect(cs.lineChanges.some((c) => c.action === "move" && c.lineRef === "a")).toBe(true); + }); + + it("reorderLine is a no-op in sort ordering", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + result.current.reorderLine("a", "b"); + }); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["a", "b"]); + }); + + it("isDirty toggles back to false after reset()", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + result.current.updateField("a", "qty", 99); + }); + expect(result.current.isDirty).toBe(true); + act(() => { + result.current.reset(); + }); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().lineChanges).toEqual([]); + }); + + it("filter narrows `lines` but keeps `allLines` unchanged", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + result.current.setFilter("X"); + }); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["a"]); + expect(result.current.allLines).toHaveLength(2); + }); + + it("selectAllVisible only selects filtered rows", () => { + const { result } = renderHook(() => + useLineItems({ fields, data: seed(), selection: true }), + ); + act(() => { + result.current.setFilter("X"); + }); + act(() => { + result.current.selectAllVisible(); + }); + expect(result.current.selectedIds).toEqual(["a"]); + }); + + it("toggleSelect adds and removes ids; clearSelection empties them", () => { + const { result } = renderHook(() => + useLineItems({ fields, data: seed(), selection: true }), + ); + act(() => { + result.current.toggleSelect("a"); + result.current.toggleSelect("b"); + }); + expect(new Set(result.current.selectedIds)).toEqual(new Set(["a", "b"])); + act(() => { + result.current.toggleSelect("a"); + }); + expect(result.current.selectedIds).toEqual(["b"]); + act(() => { + result.current.clearSelection(); + }); + expect(result.current.selectedIds).toEqual([]); + }); + + it("bulkUpdate applies the patch to every selected row", () => { + const { result } = renderHook(() => + useLineItems({ fields, data: seed(), selection: true }), + ); + act(() => { + result.current.toggleSelect("a"); + result.current.toggleSelect("b"); + }); + act(() => { + result.current.bulkUpdate({ qty: 100 }); + }); + expect(result.current.allLines.map((l) => l.qty)).toEqual([100, 100]); + }); + + it("bulkRemove removes every selected row", () => { + const { result } = renderHook(() => + useLineItems({ fields, data: seed(), selection: true }), + ); + act(() => { + result.current.toggleSelect("a"); + }); + act(() => { + result.current.bulkRemove(); + }); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["b"]); + expect(result.current.selectedIds).toEqual([]); + }); + + it("metadata fields do NOT show up in the change set", () => { + const events: { lineRef: string; patch: Record }[] = []; + const { result } = renderHook(() => + useLineItems({ + fields, + data: seed(), + onMetadataCommit: (e) => { + events.push({ lineRef: e.lineRef, patch: e.patch }); + }, + }), + ); + act(() => { + result.current.updateField("a", "note", "hello"); + }); + // Metadata commit fires as a per-cell delta… + expect(events).toEqual([{ lineRef: "a", patch: { note: "hello" } }]); + // …and is excluded from the document change set. + expect(result.current.getChangeSet().lineChanges).toEqual([]); + // But isDirty still tracks the row state vs baseline; metadata edits don't dirty + // the document, so the document is still clean here. + expect(result.current.isDirty).toBe(false); + }); + + it("number fields normalize cosmetic edits as no-ops", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + act(() => { + // Re-write the same numeric value as a string with extra precision; the + // adapter normalizes both sides so the change set stays empty. + result.current.updateField("a", "qty", Number("1.0") as DemoLine["qty"]); + }); + expect(result.current.getChangeSet().lineChanges).toEqual([]); + expect(result.current.isDirty).toBe(false); + }); + + it("duplicateLastLine appends a copy with a new lineRef", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + let newRef: string | undefined; + act(() => { + newRef = result.current.duplicateLastLine(); + }); + expect(newRef).toBeTruthy(); + expect(result.current.allLines).toHaveLength(3); + const last = result.current.allLines.at(-1)!; + expect(last.lineRef).toBe(newRef); + expect(last.sku).toBe("Y"); + }); +}); 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 00000000..46319544 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items.ts @@ -0,0 +1,490 @@ +import * as React from "react"; + +import { fieldsToColumnDefs } from "./field"; +import { + buildChangeSet, + cloneBaseline, + cloneRow, + isChangeSetEmpty, + type LineItemsBaseline, + type LineItemsColumnDef, +} from "./internals"; +import type { + LineItemsChangeSet, + LineItemsMetadataCommit, + LineItemsMode, + LineItemsOrderingMode, + LineItemsRowData, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; + +/* ======================================================================== */ +/* Public hook */ +/* ======================================================================== */ + +/** + * The single source of truth for a line-items document. Owns: + * - canonical row order + keyed lookup, + * - dirty-tracking baseline (drives `isDirty` + `getChangeSet`), + * - row selection + filter state, + * - all imperative mutations (`addLine`, `updateField`, `bulkUpdate`, ...). + * + * Pair with `` and the compound subcomponents + * to render. Hosts can also call mutations directly (e.g. wire a custom + * `` to `lineItems.addLine` for an inline catalogue picker). + */ +export function useLineItems( + options: UseLineItemsOptions, +): UseLineItemsReturn { + const { + fields, + data, + lines: controlledLinesProp, + onLinesChange, + mode: modeProp = "edit", + ordering: orderingProp = "sort", + selection = false, + onMetadataCommit, + } = options; + + const controlled = controlledLinesProp !== undefined; + + /* ---- Canonical row state ---------------------------------------------- */ + + const [uncontrolledSeed, setUncontrolledSeed] = React.useState(() => + sanitizeInitial(data ?? []), + ); + + /** Source of truth depending on controlled vs uncontrolled. */ + const activeSeed = controlled ? sanitizeInitial(controlledLinesProp ?? []) : uncontrolledSeed; + + const [byRef, setByRef] = React.useState>( + () => packLines(activeSeed).byRefInit, + ); + const [order, setOrder] = React.useState(() => packLines(activeSeed).orderInit); + + const baseline = React.useRef>( + cloneBaseline(packLines(activeSeed).orderInit, packLines(activeSeed).byRefInit), + ); + const insertedRefsRef = React.useRef(new Set()); + const removedBaselineRefsRef = React.useRef(new Set()); + + const [rerenderNonce, bump] = React.useReducer((n: number) => n + 1, 0); + + /* ---- Sync controlled lines into local state when prop ref changes ----- */ + + const prevControlledSerialized = React.useRef(undefined); + React.useEffect(() => { + if (!controlled) return undefined; + const key = serializeRows(controlledLinesProp ?? []); + if (prevControlledSerialized.current === key) return undefined; + prevControlledSerialized.current = key; + const init = packLines(sanitizeInitial(controlledLinesProp ?? [])); + setByRef(init.byRefInit); + setOrder(init.orderInit); + insertedRefsRef.current = new Set(); + removedBaselineRefsRef.current = new Set(); + baseline.current = cloneBaseline(init.orderInit, init.byRefInit); + bump(); + return undefined; + }, [controlled, controlledLinesProp]); + + /* ---- Other reactive state --------------------------------------------- */ + + const [mode, setModeState] = React.useState(modeProp); + React.useEffect(() => { + setModeState(modeProp); + }, [modeProp]); + + const ordering: LineItemsOrderingMode = orderingProp; + + const [filter, setFilterState] = React.useState(""); + const [selectedSet, setSelectedSet] = React.useState>(() => new Set()); + + /* ---- Internal column defs (memoized on `fields`) ---------------------- */ + + const columns = React.useMemo[]>( + () => fieldsToColumnDefs(fields), + [fields], + ); + + /* ---- Helpers shared across mutations ---------------------------------- */ + + const pushParent = React.useCallback( + (nextByRef: Record, nextOrder: string[]) => { + const rows = nextOrder.map((id) => nextByRef[id]).filter(Boolean) as T[]; + if (!controlled) setUncontrolledSeed(rows); + onLinesChange?.(rows); + bump(); + }, + [controlled, onLinesChange], + ); + + const replaceAll = React.useCallback( + (nextByRef: Record, nextOrder: string[]) => { + setByRef(nextByRef); + setOrder(nextOrder); + pushParent(nextByRef, nextOrder); + }, + [pushParent], + ); + + /* ---- Imperative API --------------------------------------------------- */ + + const addLine = React.useCallback( + (partial: Partial>, opts?: { afterLineRef?: string | null }): string => { + const id = newLineRef(); + const template = { ...(partial as Record), lineRef: id } as T; + insertedRefsRef.current.add(id); + const nextOrder = [...order]; + const after = opts?.afterLineRef; + if (after === undefined) nextOrder.push(id); + else if (after === null) nextOrder.unshift(id); + else { + const pos = nextOrder.indexOf(after); + nextOrder.splice(pos === -1 ? nextOrder.length : pos + 1, 0, id); + } + replaceAll({ ...byRef, [id]: template }, nextOrder); + return id; + }, + [byRef, order, replaceAll], + ); + + const removeLine = React.useCallback( + (lineRef: string) => { + if (baseline.current.rows[lineRef]) removedBaselineRefsRef.current.add(lineRef); + insertedRefsRef.current.delete(lineRef); + + const nextBy = { ...byRef }; + delete nextBy[lineRef]; + const nextOrder = order.filter((id) => id !== lineRef); + // Drop selection for removed rows. + setSelectedSet((prev) => { + if (!prev.has(lineRef)) return prev; + const next = new Set(prev); + next.delete(lineRef); + return next; + }); + replaceAll(nextBy, nextOrder); + }, + [byRef, order, replaceAll], + ); + + const updateField = React.useCallback( + (lineRef: string, key: K, value: T[K]) => { + const cur = byRef[lineRef]; + if (!cur) return; + const next: T = { ...cur, [key]: value } as T; + const fld = fields.find((f) => f.key === (key as unknown as string)); + const isMetadata = fld && fld.commit === "metadata"; + if (isMetadata && onMetadataCommit) { + const prev = (baseline.current.rows[lineRef] ?? cur) as T; + const event: LineItemsMetadataCommit = { + lineRef, + patch: { [key as string]: value }, + previous: { [key as string]: (prev as Record)[key as string] }, + row: next, + }; + onMetadataCommit(event); + } + replaceAll({ ...byRef, [lineRef]: next }, order); + }, + [byRef, fields, onMetadataCommit, order, replaceAll], + ); + + const updateLines = React.useCallback( + (patches: { lineRef: string; patch: Partial }[]) => { + if (patches.length === 0) return; + let next = { ...byRef }; + for (const u of patches) { + const cur = next[u.lineRef]; + if (!cur) continue; + next = { ...next, [u.lineRef]: { ...cur, ...u.patch } as T }; + } + replaceAll(next, order); + }, + [byRef, order, replaceAll], + ); + + const reorderLine = React.useCallback( + (lineRef: string, afterLineRef: string | null) => { + if (ordering !== "manual") return; + const next = order.filter((id) => id !== lineRef); + if (afterLineRef === null) next.unshift(lineRef); + else { + const pos = next.indexOf(afterLineRef); + next.splice(pos === -1 ? next.length : pos + 1, 0, lineRef); + } + replaceAll(byRef, next); + }, + [byRef, order, ordering, replaceAll], + ); + + const reset = React.useCallback(() => { + baseline.current = cloneBaseline(order, byRef); + insertedRefsRef.current = new Set(); + removedBaselineRefsRef.current = new Set(); + bump(); + }, [byRef, order]); + + /** + * Restore current row state to the dirty-tracking baseline (the seed data, + * or whatever was last accepted via `reset()`). Used to implement Discard: + * forgets every uncommitted edit, insertion, and removal in one shot. + */ + const revert = React.useCallback(() => { + const nextOrder = [...baseline.current.order]; + const nextByRef: Record = {}; + for (const id of nextOrder) { + const row = baseline.current.rows[id]; + if (row) nextByRef[id] = { ...row } as T; + } + insertedRefsRef.current = new Set(); + removedBaselineRefsRef.current = new Set(); + setSelectedSet((prev) => { + if (prev.size === 0) return prev; + const next = new Set(); + for (const id of prev) if (nextByRef[id]) next.add(id); + return next.size === prev.size ? prev : next; + }); + replaceAll(nextByRef, nextOrder); + }, [replaceAll]); + + const getChangeSet = React.useCallback((): LineItemsChangeSet => { + // Read `rerenderNonce` so callers re-derive `isDirty` after `reset()`, + // which only mutates refs (baseline, inserted/removed sets). + void rerenderNonce; + return buildChangeSet( + columns, + baseline.current, + order, + byRef, + removedBaselineRefsRef.current, + insertedRefsRef.current, + ordering, + ); + }, [byRef, columns, order, ordering, rerenderNonce]); + + /* ---- Derived: dirty bit ---------------------------------------------- */ + + const isDirty = React.useMemo(() => !isChangeSetEmpty(getChangeSet()), [getChangeSet]); + + /* ---- Lines (filtered + unfiltered) ------------------------------------ */ + + const allLines = React.useMemo( + (): T[] => order.map((id) => byRef[id]).filter(Boolean) as T[], + [byRef, order], + ); + + const lines = React.useMemo((): T[] => { + const q = filter.trim(); + if (q === "") return allLines; + const searchableFields = fields.filter((f) => typeof f.search === "function"); + if (searchableFields.length === 0) return allLines; + return allLines.filter((row) => + searchableFields.some((f) => (f.search as (l: T, q: string) => boolean)(row, q)), + ); + }, [allLines, fields, filter]); + + /* ---- Selection -------------------------------------------------------- */ + + const visibleSet = React.useMemo(() => { + return new Set(lines.map((l) => l.lineRef)); + }, [lines]); + + const selectedIds = React.useMemo(() => { + if (selectedSet.size === 0) return [] as string[]; + return order.filter((id) => selectedSet.has(id) && byRef[id]); + }, [byRef, order, selectedSet]); + + const toggleSelect = React.useCallback( + (lineRef: string) => { + if (!selection) return; + setSelectedSet((prev) => { + const next = new Set(prev); + if (next.has(lineRef)) next.delete(lineRef); + else next.add(lineRef); + return next; + }); + }, + [selection], + ); + + const selectAllVisible = React.useCallback(() => { + if (!selection) return; + setSelectedSet(new Set(visibleSet)); + }, [selection, visibleSet]); + + const clearSelection = React.useCallback(() => { + setSelectedSet((prev) => (prev.size === 0 ? prev : new Set())); + }, []); + + const bulkUpdate = React.useCallback( + (patch: Partial) => { + if (selectedIds.length === 0) return; + const updates = selectedIds.map((lineRef) => ({ lineRef, patch })); + updateLines(updates); + }, + [selectedIds, updateLines], + ); + + const bulkRemove = React.useCallback(() => { + if (selectedIds.length === 0) return; + const ids = [...selectedIds]; + let nextBy = { ...byRef }; + let nextOrder = order; + for (const id of ids) { + if (baseline.current.rows[id]) removedBaselineRefsRef.current.add(id); + insertedRefsRef.current.delete(id); + delete nextBy[id]; + nextOrder = nextOrder.filter((x) => x !== id); + } + setSelectedSet(new Set()); + replaceAll(nextBy, nextOrder); + }, [byRef, order, replaceAll, selectedIds]); + + /* ---- Misc ------------------------------------------------------------- */ + + const duplicateLastLine = React.useCallback( + (derive?: (prev: T, newRef: string) => T): string | undefined => { + const last = order.at(-1); + if (!last) return undefined; + const src = byRef[last]; + if (!src) return undefined; + const id = newLineRef(); + const row = (derive ?? defaultDerive)(cloneRow(src), id); + insertedRefsRef.current.add(id); + const nextOrder = [...order, id]; + replaceAll({ ...byRef, [id]: row }, nextOrder); + return id; + }, + [byRef, order, replaceAll], + ); + + const setMode = React.useCallback((m: LineItemsMode) => setModeState(m), []); + const setFilter = React.useCallback((q: string) => setFilterState(q), []); + + const ret: UseLineItemsReturn = { + lines, + allLines, + fields, + mode, + ordering, + isDirty, + filter, + selectionEnabled: selection, + selectedIds, + + setMode, + setFilter, + addLine, + removeLine, + updateField, + updateLines, + reorderLine, + toggleSelect, + selectAllVisible, + clearSelection, + bulkUpdate, + bulkRemove, + duplicateLastLine, + reset, + revert, + getChangeSet, + }; + + attachInternals(ret, { + columns, + baselineRef: baseline, + insertedRefsRef, + removedBaselineRefsRef, + }); + + return ret; +} + +/* ======================================================================== */ +/* Helpers */ +/* ======================================================================== */ + +export function newLineRef(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `line-${Math.random().toString(36).slice(2, 11)}`; +} + +function defaultDerive(prev: T, newRef: string): T { + return { ...prev, lineRef: newRef }; +} + +function sanitizeInitial(rows: readonly T[]): T[] { + return rows.map((r) => { + const ref = + typeof (r as { lineRef?: string }).lineRef === "string" && + String((r as { lineRef: string }).lineRef).length > 0 + ? (r as { lineRef: string }).lineRef + : newLineRef(); + return { ...r, lineRef: ref }; + }) as T[]; +} + +function packLines( + rows: readonly T[], +): { byRefInit: Record; orderInit: string[] } { + const byRefInit: Record = {}; + const orderInit: string[] = []; + for (const r of rows) { + byRefInit[r.lineRef] = r; + orderInit.push(r.lineRef); + } + return { byRefInit, orderInit }; +} + +function serializeRows(rows: readonly T[]): string { + try { + const refs = rows.map((r) => r.lineRef); + return ( + JSON.stringify( + // oxlint-disable-next-line unicorn/no-array-sort -- ES2020 bundle; avoid `toSorted` (ES2023-only) + refs.slice().sort((a, b) => (a === b ? 0 : a > b ? 1 : -1)), + ) + rows.length.toString() + ); + } catch { + return `${rows.length}`; + } +} + +/* ======================================================================== */ +/* Internal accessor for the table layer */ +/* ======================================================================== */ + +/** + * Internal: the rendering layer (compound ``) needs a few + * pieces of mutable state from the hook (the baseline, inserted/removed sets, + * and internal column defs) that aren't part of the public surface. We + * surface them via a hidden symbol so the hook return shape stays clean. + */ +export const LINE_ITEMS_INTERNALS = Symbol.for("@tailor/line-items/internals"); + +export type LineItemsHookInternals = { + columns: LineItemsColumnDef[]; + baselineRef: React.MutableRefObject>; + insertedRefsRef: React.MutableRefObject>; + removedBaselineRefsRef: React.MutableRefObject>; +}; + +/** Attach internals to the public hook return value (called from `useLineItems`). */ +export function attachInternals( + ret: UseLineItemsReturn, + internals: LineItemsHookInternals, +): UseLineItemsReturn { + (ret as unknown as Record)[LINE_ITEMS_INTERNALS] = internals; + return ret; +} + +export function getInternals( + hook: UseLineItemsReturn, +): LineItemsHookInternals | null { + const value = (hook as unknown as Record)[LINE_ITEMS_INTERNALS]; + return (value as LineItemsHookInternals) ?? null; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index f23c2e72..ea518b9c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -126,3 +126,32 @@ export { type ParsedRow, type InferCsvRow, } from "./components/csv-importer"; +export { + LineItems, + useLineItems, + createLineItemHelper, + type LineItemsAddRowProps, + type LineItemsBulkActionsProps, + type LineItemsBulkActionsRenderArgs, + type LineItemsChangeSet, + type LineItemsColumnAlign, + type LineItemsField, + type LineItemsFieldCommit, + type LineItemsFieldType, + type LineItemsSelectOption, + type LineItemsFullscreenToggleProps, + type LineItemsLineChange, + type LineItemsLineChangeAction, + type LineItemsMetadataCommit, + type LineItemsMode, + type LineItemsOrderingMode, + type LineItemsRootProps, + type LineItemsRowData, + type LineItemsRowPatch, + type LineItemsSaveActionsProps, + type LineItemsSearchProps, + type LineItemsSearchToggleProps, + type LineItemsTableProps, + type UseLineItemsOptions, + type UseLineItemsReturn, +} from "./components/line-items"; diff --git a/packages/core/src/lib/input-classes.ts b/packages/core/src/lib/input-classes.ts index f814a266..323f2c19 100644 --- a/packages/core/src/lib/input-classes.ts +++ b/packages/core/src/lib/input-classes.ts @@ -12,4 +12,8 @@ export const inputBaseClasses = [ "astw:placeholder:text-muted-foreground", "astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px]", "astw:disabled:pointer-events-none astw:disabled:cursor-not-allowed astw:disabled:opacity-50", + // Hide the native up/down spinner on ``. No-op for other types. + "astw:[appearance:textfield]", + "astw:[&::-webkit-outer-spin-button]:appearance-none astw:[&::-webkit-outer-spin-button]:m-0", + "astw:[&::-webkit-inner-spin-button]:appearance-none astw:[&::-webkit-inner-spin-button]:m-0", ] as const; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a64e61d..b729d74e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@tanstack/react-virtual': + specifier: ^3.13.24 + version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) change-case: specifier: ^5.4.4 version: 5.4.4 @@ -1644,10 +1647,19 @@ packages: react: '>=16.8' react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.24': + resolution: {integrity: sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} engines: {node: '>=12'} + '@tanstack/virtual-core@3.14.0': + resolution: {integrity: sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -4597,8 +4609,16 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@tanstack/react-virtual@3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@tanstack/virtual-core': 3.14.0 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.14.0': {} + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 From 504e70989806369235914813da8c1ff1dc9fcf6f Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 6 May 2026 16:42:07 +0530 Subject: [PATCH 2/6] feat(line-items): polish, PRD alignment, group hook, floating dock, per-module demos Visual & UX polish: - Cell selection ring, hover-expand columns, edge-to-edge table inside Card - Search toggle, fullscreen toggle with click-outside-close - Shift-click range, fill drag, copy/paste fixes - Header checkbox centering, pinned columns, row-end actions, totals row - Flex column with min-width floor; trailing spacer absorbs leftover space Library additions (all opt-in, non-breaking): - LineItems.FloatingDock + DirtyBar + SelectionBar (denim-tears floating bar pattern) - lineItemsFloatingBarStyles helpers for matching button chrome - warnOnNav prop intercepts in-app links + beforeunload + jiggle animation - LineItems.TotalsRow render-prop for sticky footer - pinned: 'left' | 'right' on fields - rowActions slot on LineItems.Table - useLineItemsGroup helper for multi-collection documents - equals / normalize / amend readonly tint / expanded field types PRD alignment: - ChangeSet shape: {isEmpty, lineChanges: [{action, tempId|lineId|position, ...}]} Per-module demos (examples/app-module): - sales-invoice-demo (totals row) - goods-receipt-demo (pinned columns) - stock-transfer-demo (row actions + cross-field validation) - work-order-demo + journal-entry-demo (multi-collection via group hook) Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/line-items-amend-readonly-tint.md | 5 + .changeset/line-items-equals-normalize.md | 9 + .changeset/line-items-field-types.md | 11 + .changeset/line-items-flex-and-shift-click.md | 8 + .changeset/line-items-floating-dock.md | 31 ++ .changeset/line-items-group.md | 13 + .changeset/line-items-pinned-columns.md | 11 + .changeset/line-items-prd-alignment.md | 15 + .changeset/line-items-row-actions.md | 21 + .changeset/line-items-totals-row.md | 21 + CLAUDE.md | 8 +- README.md | 9 +- examples/app-module/src/custom-module.tsx | 53 +++ .../src/pages/goods-receipt-demo.tsx | 284 ++++++++++++ .../src/pages/journal-entry-demo.tsx | 297 ++++++++++++ .../app-module/src/pages/line-items-demo.tsx | 280 +++--------- .../src/pages/sales-invoice-demo.tsx | 322 +++++++++++++ .../src/pages/stock-transfer-demo.tsx | 327 ++++++++++++++ .../app-module/src/pages/work-order-demo.tsx | 427 ++++++++++++++++++ examples/nextjs-app/README.md | 10 + examples/nextjs-app/next.config.ts | 14 +- examples/vite-app/README.md | 12 +- package.json | 5 +- .../src/components/line-items/LineItems.tsx | 8 + .../core/src/components/line-items/field.ts | 29 +- .../core/src/components/line-items/index.ts | 13 + .../src/components/line-items/internals.ts | 39 +- .../line-items/line-items-default-cell.tsx | 235 +++++++++- .../line-items/line-items-internals.test.ts | 5 +- .../line-items/line-items-parts.tsx | 344 ++++++++++++++ .../components/line-items/line-items-root.tsx | 14 +- .../line-items/line-items-table.test.tsx | 103 +++++ .../line-items/line-items-table.tsx | 377 ++++++++++++++-- .../core/src/components/line-items/types.ts | 104 ++++- .../line-items/use-line-items-group.test.tsx | 96 ++++ .../line-items/use-line-items-group.ts | 109 +++++ .../line-items/use-line-items.test.tsx | 75 ++- .../sidebar/default-sidebar.test.tsx | 12 +- packages/core/src/contexts/theme-context.tsx | 29 +- packages/core/src/index.ts | 11 + 40 files changed, 3445 insertions(+), 351 deletions(-) create mode 100644 .changeset/line-items-amend-readonly-tint.md create mode 100644 .changeset/line-items-equals-normalize.md create mode 100644 .changeset/line-items-field-types.md create mode 100644 .changeset/line-items-flex-and-shift-click.md create mode 100644 .changeset/line-items-floating-dock.md create mode 100644 .changeset/line-items-group.md create mode 100644 .changeset/line-items-pinned-columns.md create mode 100644 .changeset/line-items-prd-alignment.md create mode 100644 .changeset/line-items-row-actions.md create mode 100644 .changeset/line-items-totals-row.md create mode 100644 examples/app-module/src/pages/goods-receipt-demo.tsx create mode 100644 examples/app-module/src/pages/journal-entry-demo.tsx create mode 100644 examples/app-module/src/pages/sales-invoice-demo.tsx create mode 100644 examples/app-module/src/pages/stock-transfer-demo.tsx create mode 100644 examples/app-module/src/pages/work-order-demo.tsx create mode 100644 packages/core/src/components/line-items/line-items-table.test.tsx create mode 100644 packages/core/src/components/line-items/use-line-items-group.test.tsx create mode 100644 packages/core/src/components/line-items/use-line-items-group.ts diff --git a/.changeset/line-items-amend-readonly-tint.md b/.changeset/line-items-amend-readonly-tint.md new file mode 100644 index 00000000..2f939181 --- /dev/null +++ b/.changeset/line-items-amend-readonly-tint.md @@ -0,0 +1,5 @@ +--- +"@tailor-platform/app-shell": patch +--- + +LineItems: in `amend` mode, cells that aren't editable in amend now get a subtle `bg-muted/40` background so users can see at a glance which cells they can touch and which are locked. Selection ring + fill preview still override the tint when active. No effect in `edit` or `display` mode. diff --git a/.changeset/line-items-equals-normalize.md b/.changeset/line-items-equals-normalize.md new file mode 100644 index 00000000..00877eaa --- /dev/null +++ b/.changeset/line-items-equals-normalize.md @@ -0,0 +1,9 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: per-field `equals?` and `normalize?` hooks on `LineItemsField`. + +App-supplied callbacks let consumers override dirty-equality and value coercion for fields the component can't normalize on its own — e.g. id-based equality on attribute objects (`equals: (a, b) => a.id === b.id`), deep-equal on JSON blobs, currency-aware money comparison, custom rounding rules. The built-in tolerance-aware numeric equality and string trim still apply when the field doesn't supply its own. + +Resolution order: field-level `equals` / `normalize` win over `kind: "custom"` type-level `equals` / `normalize`, which win over the built-in numeric defaults. diff --git a/.changeset/line-items-field-types.md b/.changeset/line-items-field-types.md new file mode 100644 index 00000000..6a5f742f --- /dev/null +++ b/.changeset/line-items-field-types.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: three new `LineItemsFieldType` variants. + +- `{ kind: "boolean"; trueLabel?; falseLabel? }` — checkbox cell with full keyboard nav. Default alignment is `center`. +- `{ kind: "date"; min?; max? }` — native `` cell using ISO `yyyy-mm-dd` values. Empty value commits as `null`. +- `{ kind: "custom"; renderEditor; normalize?; equals? }` — escape hatch. Apps drop in any React editor (async product picker, attribute selector, currency-pair input, …). The editor receives `{ value, onCommit, onCancel, row, mode, field }` and routes commits through the standard hook so dirty-tracking and the change-set keep working. + +Numeric fields now default to `align: "right"` (with `tabular-nums`); boolean fields default to `align: "center"`. Apps that explicitly set `align` keep their value. diff --git a/.changeset/line-items-flex-and-shift-click.md b/.changeset/line-items-flex-and-shift-click.md new file mode 100644 index 00000000..022d6c66 --- /dev/null +++ b/.changeset/line-items-flex-and-shift-click.md @@ -0,0 +1,8 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: two related fixes for table interaction and layout consistency. + +- **`LineItemsField.flex?: boolean`** — opt-in flag that makes a column absorb leftover horizontal space (typical use: a description / product-name column with the longest content). When at least one field is `flex` (or has no declared `width`), the table drops its trailing spacer and routes leftover space to the flagged column. Tables where every column has an explicit width still get an invisible trailing spacer so column widths stay pixel-exact across pages. +- **Shift-click multi-select fix** — `onCellFocused` no longer overwrites the selection anchor when an input fires its native focus event after a shift-click. Anchor is now owned exclusively by `onCellPointerDown` and the keyboard-nav handlers, so shift-click on a different cell now correctly extends the rectangular selection. diff --git a/.changeset/line-items-floating-dock.md b/.changeset/line-items-floating-dock.md new file mode 100644 index 00000000..4c94ce00 --- /dev/null +++ b/.changeset/line-items-floating-dock.md @@ -0,0 +1,31 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: floating-bar UX is now a first-class library pattern. + +Three new compound parts hosted at the bottom-center of the viewport when the hook reflects the relevant state. Hook-driven (auto-mount/unmount), composable, no boilerplate per page. + +- **`LineItems.FloatingDock`** — fixed bottom-center container that hosts the bars and stacks them vertically. `pointer-events: none` outer / `auto` inner so the surrounding area stays click-through. +- **`LineItems.DirtyBar`** — auto-shows when `useLineItemsRoot().hook.isDirty`. Discard button defaults to `hook.revert()`; Save calls the consumer's `onSave`. Optional `warnOnNav` prop intercepts in-app anchor clicks + browser `beforeunload` so the user can't leave with unsaved changes; bar jiggles to draw attention. +- **`LineItems.SelectionBar`** — render-prop bar that auto-shows when rows are selected. Library renders the dark pill chrome (count label + divider); consumer plugs in domain-specific actions (Delete, Export PDF, Update price, etc.) via render-prop receiving `{selectedIds, bulkUpdate, bulkRemove, clear}`. +- **`lineItemsFloatingBarStyles`** — exported style helpers (`primaryButton`, `secondaryButton`, `divider`, `label`) so consumers can build buttons inside the SelectionBar render-prop that visually match the DirtyBar's defaults. + +```tsx + + + + + + {({ bulkRemove, clear }) => ( + <> + + + + )} + + + +``` + +`LineItems.BulkActions` (the inline-toolbar variant) stays unchanged for apps that don't want the floating dock. diff --git a/.changeset/line-items-group.md b/.changeset/line-items-group.md new file mode 100644 index 00000000..577ce68f --- /dev/null +++ b/.changeset/line-items-group.md @@ -0,0 +1,13 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: new `useLineItemsGroup({ a, b, ... })` hook. + +Composes multiple `useLineItems` hooks into a single document-level boundary. Use it when one header record owns more than one ordered list of lines — e.g. a Journal Entry with `debits` + `credits` that must balance, or a Work Order with `componentLines` + `operationLines`. Each collection still gets its own table + selection + dirty tracking; the group helper provides: + +- `isDirty` — `true` when **any** member is dirty +- `getChangeSet()` — keyed bundle `{ isEmpty, [memberName]: ChangeSet }` so the page-level submit handler dispatches one transactional mutation +- `reset()` / `revert()` — fan out to every member at once + +See the new `journal-entry-demo` for a complete worked example with a balance-check header derived from both collections. diff --git a/.changeset/line-items-pinned-columns.md b/.changeset/line-items-pinned-columns.md new file mode 100644 index 00000000..d0d24686 --- /dev/null +++ b/.changeset/line-items-pinned-columns.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: new `LineItemsField.pinned?: "left" | "right"` per-field option. + +Pinned columns stay visible while the user scrolls horizontally — `position: sticky` with offsets accumulated from preceding pinned columns of the same side. Useful for dense tables where the SKU / Product columns should stay anchored as the user scrolls through 8+ data columns. + +Pinned columns must declare a `width` so subsequent offsets can be computed. The selection checkbox column (`__select`) is auto-pinned to the left when present. + +Demo: `/custom-page/goods-receipt-demo` pins SKU + Product on the left while horizontal-scrolling Condition / Lot / Expiry. diff --git a/.changeset/line-items-prd-alignment.md b/.changeset/line-items-prd-alignment.md new file mode 100644 index 00000000..d1956cd3 --- /dev/null +++ b/.changeset/line-items-prd-alignment.md @@ -0,0 +1,15 @@ +--- +"@tailor-platform/app-shell": major +--- + +LineItems: align `getChangeSet()` shape with the platform PRD ("Generalized Line-Item Component"). + +**Breaking** field-name changes inside `LineItemsLineChange`: + +- `add` → `{ tempId, data }` (was `{ lineRef, insertAfterLineRef, patch }`). `tempId` is a client-only id; the server should mint the persistent id on insert. +- `update` / `remove` → keyed on `lineId` (was `lineRef`). +- `move` action renamed to `reorder`; payload is `{ lineId, position: number }` — zero-based final index in document order. Replaces the previous after-cursor model. + +`LineItemsChangeSet` now carries a top-level `isEmpty: boolean` flag for ergonomic no-op detection (`if (cs.isEmpty) return`). + +Migration: rename your switch arms and field reads (e.g. `change.lineRef` → `change.lineId`; `add.patch` → `add.data`; `move`/`afterLineRef` → `reorder`/`position`). Behavior is unchanged — only field names move. diff --git a/.changeset/line-items-row-actions.md b/.changeset/line-items-row-actions.md new file mode 100644 index 00000000..25f9edb6 --- /dev/null +++ b/.changeset/line-items-row-actions.md @@ -0,0 +1,21 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: new `rowActions?: (line: T) => ReactNode` prop on `LineItems.Table`. + +Renders a trailing per-row actions column (delete, view, attach, etc.) auto-pinned to the right edge so the buttons stay visible during horizontal scroll. The actions cell is **not** part of the spreadsheet selection grid — no fill, no copy/paste, no drag-select. + +```tsx + ( + <> + + + + )} + rowActionsWidth={84} +/> +``` + +Demo: `/custom-page/stock-transfer-demo` shows the trailing actions column with a "view history" + "remove" pair. diff --git a/.changeset/line-items-totals-row.md b/.changeset/line-items-totals-row.md new file mode 100644 index 00000000..20ce6f4f --- /dev/null +++ b/.changeset/line-items-totals-row.md @@ -0,0 +1,21 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: new `LineItems.TotalsRow` part — render-prop component that renders a sticky totals row at the bottom of the table. + +```tsx + + + + {(lines) => ({ + quantity: lines.reduce((s, l) => s + l.quantity, 0), + amount: `$${lines.reduce((s, l) => s + l.amount, 0).toFixed(2)}`, + })} + + +``` + +The render-prop receives the live `allLines` array and returns a `Record` aligned to the table's columns. Place it as a sibling of the `Table` inside `Root`; the table picks up the render-fn via context and renders the row inside its ``. Sticks to the bottom of the scroll container with `position: sticky; bottom: 0`. + +Demo: `/custom-page/sales-invoice-demo` shows running Qty + Amount totals. diff --git a/CLAUDE.md b/CLAUDE.md index 0c6695e6..93d7e982 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,8 +55,12 @@ Tailor Platform AppShell - A React-based framework for building ERP applications # Install dependencies pnpm install -# Start dev server (opens localhost:3000 with example app) -pnpm dev +# Start one example dev server (avoids running every example watcher at once) +pnpm dev # Vite example — localhost:3030 +pnpm dev:next # Next.js example — localhost:3000 + +# Heavy: Turbo watch across all ./examples packages +pnpm dev:examples ``` ## Versioning & Publishing diff --git a/README.md b/README.md index 64452b12..e0864005 100644 --- a/README.md +++ b/README.md @@ -39,9 +39,12 @@ pnpm install ### Commands ```bash -pnpm dev # Start all packages in development mode with hot reloading -pnpm build # Build all packages for production -pnpm type-check # Run type checking across all packages +pnpm dev # Start the Vite example only (recommended; avoids high RAM use) +pnpm dev:vite # Same as `pnpm dev` — http://localhost:3030 +pnpm dev:next # Next.js example only — http://localhost:3000 +pnpm dev:examples # All examples via Turbo watch (heavy: Vite + Next + app-module, etc.) +pnpm build # Build all packages for production +pnpm type-check # Run type checking across all packages ``` ### Testing diff --git a/examples/app-module/src/custom-module.tsx b/examples/app-module/src/custom-module.tsx index b76eb620..b6e5d654 100644 --- a/examples/app-module/src/custom-module.tsx +++ b/examples/app-module/src/custom-module.tsx @@ -21,6 +21,11 @@ import { dropdownComponentsDemoResource } from "./pages/dropdown-demo"; import { formComponentsDemoResource, zodRHFFormDemoResource } from "./pages/form-demo"; import { csvImporterDemoResource } from "./pages/csv-importer-demo"; import { lineItemsDemoResource } from "./pages/line-items-demo"; +import { journalEntryDemoResource } from "./pages/journal-entry-demo"; +import { salesInvoiceDemoResource } from "./pages/sales-invoice-demo"; +import { goodsReceiptDemoResource } from "./pages/goods-receipt-demo"; +import { workOrderDemoResource } from "./pages/work-order-demo"; +import { stockTransferDemoResource } from "./pages/stock-transfer-demo"; export const customPageModule = defineModule({ path: "custom-page", @@ -205,6 +210,49 @@ export const customPageModule = defineModule({ Line items (document lines)

+

+ + Journal entry (group helper) + +

+

+ + Sales invoice (totals row) + +

+

+ + Goods receipt (pinned columns) + +

+

+ + Work order (multi-collection) + +

+

+ + Stock transfer (row actions) + +

); @@ -231,5 +279,10 @@ export const customPageModule = defineModule({ zodRHFFormDemoResource, csvImporterDemoResource, lineItemsDemoResource, + journalEntryDemoResource, + salesInvoiceDemoResource, + goodsReceiptDemoResource, + workOrderDemoResource, + stockTransferDemoResource, ], }); diff --git a/examples/app-module/src/pages/goods-receipt-demo.tsx b/examples/app-module/src/pages/goods-receipt-demo.tsx new file mode 100644 index 00000000..6ff6fda2 --- /dev/null +++ b/examples/app-module/src/pages/goods-receipt-demo.tsx @@ -0,0 +1,284 @@ +import * as React from "react"; +import { + Button, + Card, + Combobox, + Layout, + LineItems, + createLineItemHelper, + defineResource, + lineItemsFloatingBarStyles, + useLineItems, + type LineItemsField, + type LineItemsRowData, +} from "@tailor-platform/app-shell"; + +/* ======================================================================== */ +/* Domain — Goods Receipt */ +/* ======================================================================== */ + +type GRLine = LineItemsRowData & { + sku: string; + productName: string; + expectedQty: number; + receivedQty: number; + condition: "OK" | "DAMAGED" | "SHORT"; + lotNumber: string; + expiryDate: string; +}; + +const CONDITION_OPTIONS = [ + { value: "OK", label: "OK" }, + { value: "DAMAGED", label: "DAMAGED" }, + { value: "SHORT", label: "SHORT" }, +]; + +/* ======================================================================== */ +/* Field schema */ +/* ======================================================================== */ + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + width: 160, + pinned: "left", + }), + f.field({ + key: "productName", + label: "Product", + render: (l) => l.productName, + width: 220, + pinned: "left", + flex: true, + }), + f.field({ + key: "expectedQty", + label: "Expected", + render: (l) => l.expectedQty, + width: 110, + }), + f.field({ + key: "receivedQty", + label: "Received", + render: (l) => l.receivedQty, + editable: ["edit", "amend"], + type: { kind: "number", decimals: 0 }, + width: 110, + // 🎨 Highlight the cell red when received quantity differs from expected. + className: (l) => + l.receivedQty !== l.expectedQty + ? "astw:bg-destructive/10 astw:text-destructive" + : undefined, + }), + f.field({ + key: "condition", + label: "Condition", + render: (l) => l.condition, + editable: ["edit"], + type: { kind: "select", options: CONDITION_OPTIONS }, + width: 140, + }), + f.field({ + key: "lotNumber", + label: "Lot No", + render: (l) => l.lotNumber, + editable: ["edit"], + type: { kind: "text" }, + width: 140, + }), + f.field({ + key: "expiryDate", + label: "Expiry", + render: (l) => l.expiryDate, + editable: ["edit"], + type: { kind: "date" }, + width: 160, + }), +]; + +const GR_CATALOG: ReadonlyArray<{ sku: string; productName: string }> = [ + { sku: "SKU-1001", productName: "Indigo Denim Roll" }, + { sku: "SKU-2040", productName: "Copper Rivet Pack" }, + { sku: "SKU-3300", productName: "Organic Cotton Jersey" }, + { sku: "SKU-4412", productName: "Leather Patch Kit" }, +]; + +const seed = (): GRLine[] => [ + { lineRef: "GR1", sku: "SKU-1001", productName: "Indigo Denim Roll", expectedQty: 50, receivedQty: 50, condition: "OK", lotNumber: "L-0612", expiryDate: "2027-06-30" }, + { lineRef: "GR2", sku: "SKU-2040", productName: "Copper Rivet Pack", expectedQty: 100, receivedQty: 92, condition: "SHORT", lotNumber: "L-0613", expiryDate: "2028-01-15" }, + { lineRef: "GR3", sku: "SKU-3300", productName: "Organic Cotton Jersey", expectedQty: 200, receivedQty: 200, condition: "OK", lotNumber: "L-0614", expiryDate: "2027-03-10" }, + { lineRef: "GR4", sku: "SKU-4412", productName: "Leather Patch Kit", expectedQty: 30, receivedQty: 28, condition: "DAMAGED", lotNumber: "L-0615", expiryDate: "2026-11-22" }, +]; + +/* ======================================================================== */ +/* Page */ +/* ======================================================================== */ + +export const goodsReceiptDemoResource = defineResource({ + path: "goods-receipt-demo", + component: GoodsReceiptDemoPage, + meta: { title: "Goods Receipt (pinned columns)" }, +}); + +export function GoodsReceiptDemoPage() { + const lineItems = useLineItems({ + fields, + data: seed(), + mode: "edit", + selection: true, + }); + + const handleSave = React.useCallback(() => { + const cs = lineItems.getChangeSet(); + if (cs.isEmpty) return; + // eslint-disable-next-line no-console + console.log("[goods receipt] save", cs); + lineItems.reset(); + }, [lineItems]); + + return ( + + lineItems.revert()}> + Discard + , + , + ]} + /> + + + + +
+

Receipt lines

+

+ {lineItems.allLines.length} lines · scroll horizontally — SKU + Product stay pinned +

+
+
+ + + + +
+
+ + + + + { + lineItems.addLine({ + sku: picked.sku, + productName: picked.productName, + expectedQty: 0, + receivedQty: 0, + condition: "OK", + lotNumber: "", + expiryDate: "", + }); + }} + /> + +
+ + + void handleSave()} /> + > + {({ bulkRemove, clear }) => ( + <> + + + + )} + + +
+
+
+ ); +} + +function AddReceiptLineRow({ + onPick, +}: { + onPick: (item: { sku: string; productName: string }) => void; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( +
+ + key={resetKey} + items={GR_CATALOG as Array<{ sku: string; productName: string }>} + placeholder="+ Add line item — type to search…" + emptyText="No matching products." + mapItem={(p) => ({ + key: p.sku, + label: `${p.sku} ${p.productName}`, + render: ( +
+ {p.sku} + {p.productName} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + onPick(picked); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + +
+ ); +} diff --git a/examples/app-module/src/pages/journal-entry-demo.tsx b/examples/app-module/src/pages/journal-entry-demo.tsx new file mode 100644 index 00000000..f70ac1b9 --- /dev/null +++ b/examples/app-module/src/pages/journal-entry-demo.tsx @@ -0,0 +1,297 @@ +import * as React from "react"; +import { + Button, + Card, + Combobox, + Layout, + LineItems, + createLineItemHelper, + defineResource, + useLineItems, + useLineItemsGroup, + type LineItemsField, + type LineItemsRowData, +} from "@tailor-platform/app-shell"; + +/* ======================================================================== */ +/* Domain */ +/* ======================================================================== */ + +type JournalLine = LineItemsRowData & { + account: string; + memo: string; + amount: number; +}; + +const ACCOUNTS: ReadonlyArray<{ value: string; label: string; description?: string }> = [ + { value: "1000", label: "1000", description: "Cash" }, + { value: "1100", label: "1100", description: "Accounts receivable" }, + { value: "2000", label: "2000", description: "Accounts payable" }, + { value: "4000", label: "4000", description: "Sales revenue" }, + { value: "5000", label: "5000", description: "Cost of goods sold" }, + { value: "6000", label: "6000", description: "Operating expenses" }, +]; + +const round2 = (n: number) => Math.round(n * 100) / 100; +const fmt = (n: number) => `$${n.toFixed(2)}`; + +/* ======================================================================== */ +/* Field schema (shared between debits + credits) */ +/* ======================================================================== */ + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "account", + label: "Account", + render: (l) => l.account, + editable: ["edit"], + type: { kind: "select", options: ACCOUNTS, placeholder: "Pick account" }, + sort: { comparator: (a, b) => a.account.localeCompare(b.account) }, + width: 160, + }), + f.field({ + key: "memo", + label: "Memo", + render: (l) => l.memo, + editable: ["edit", "amend"], + type: { kind: "text" }, + commit: "metadata", + }), + f.field({ + key: "amount", + label: "Amount", + render: (l) => fmt(l.amount), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + sort: { comparator: (a, b) => a.amount - b.amount }, + }), +]; + +const seedDebits = (): JournalLine[] => [ + { lineRef: "d1", account: "1000", memo: "Customer payment", amount: 1200 }, + { lineRef: "d2", account: "5000", memo: "COGS allocation", amount: 800 }, +]; + +const seedCredits = (): JournalLine[] => [ + { lineRef: "c1", account: "1100", memo: "Invoice INV-1042", amount: 1200 }, + { lineRef: "c2", account: "1000", memo: "Inventory drawdown", amount: 800 }, +]; + +/* ======================================================================== */ +/* Page */ +/* ======================================================================== */ + +export const journalEntryDemoResource = defineResource({ + path: "journal-entry-demo", + component: JournalEntryDemoPage, + meta: { title: "Journal Entry (group helper)" }, +}); + +export function JournalEntryDemoPage() { + const debits = useLineItems({ + fields, + data: seedDebits(), + selection: true, + }); + const credits = useLineItems({ + fields, + data: seedCredits(), + selection: true, + }); + + // ✅ Reusable Pattern: bundle two collections under one header so the page + // gets a single isDirty + Discard / Save boundary, while each collection + // keeps its own table + selection + change-set. + const group = useLineItemsGroup({ debits, credits }); + + // Balance check at the page level. The component doesn't know about + // debit/credit semantics — that's the consumer's job. + const debitTotal = round2(debits.allLines.reduce((s, l) => s + Number(l.amount), 0)); + const creditTotal = round2(credits.allLines.reduce((s, l) => s + Number(l.amount), 0)); + const outOfBalance = round2(debitTotal - creditTotal); + const balanced = Math.abs(outOfBalance) < 1e-9; + + const handleSave = React.useCallback(() => { + const cs = group.getChangeSet(); + if (cs.isEmpty) return; + // In a real app, translate cs.debits.lineChanges + cs.credits.lineChanges + // into one Journal Entry mutation that posts both sides atomically. + // eslint-disable-next-line no-console + console.log("[journal entry demo] saving grouped change set", cs); + group.reset(); + }, [group]); + + return ( + + group.revert()} + disabled={!group.isDirty} + > + Discard + , + , + ]} + /> + + {/* 🔽 Balance summary — page-level, derived from both collections */} + + + + + + {group.isDirty ? ( + + Unsaved changes across both collections + + ) : null} + + + + + + + + ); +} + +/* ======================================================================== */ +/* Reusable helpers */ +/* ======================================================================== */ + +function BalanceItem({ + label, + value, + tone, +}: { + label: string; + value: string; + tone?: "ok" | "warn"; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} + +/* ✅ Reusable Component: a thin wrapper around + table. The + group helper above is what lets two of these share one Save / Discard. */ +function CollectionSection({ + title, + hook, +}: { + title: string; + hook: ReturnType>; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( + + {/* overflow-hidden clips the edge-to-edge table to the rounded card. */} + + +
+

{title}

+

+ {hook.allLines.length} lines{hook.isDirty ? " · Modified" : ""} +

+
+
+ + + + +
+
+ + + +
+ + key={resetKey} + items={ACCOUNTS as Array<{ value: string; label: string; description?: string }>} + placeholder="+ Add line item — type to search…" + emptyText="No matching accounts." + mapItem={(p) => ({ + key: p.value, + label: `${p.value} ${p.description ?? ""}`, + render: ( +
+ {p.value} + {p.description ? ( + {p.description} + ) : null} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + hook.addLine({ account: picked.value, memo: "", amount: 0 }); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + +
+
+
+
+ ); +} diff --git a/examples/app-module/src/pages/line-items-demo.tsx b/examples/app-module/src/pages/line-items-demo.tsx index b881ff35..189d04d6 100644 --- a/examples/app-module/src/pages/line-items-demo.tsx +++ b/examples/app-module/src/pages/line-items-demo.tsx @@ -10,6 +10,7 @@ import { LineItems, createLineItemHelper, defineResource, + lineItemsFloatingBarStyles, useLineItems, type LineItemsField, type LineItemsMode, @@ -30,6 +31,7 @@ type POLine = LineItemsRowData & { quantity: number; unitPrice: number; total: number; + expectedReady: string; note: string; }; @@ -83,6 +85,7 @@ const fields: LineItemsField[] = [ type: { kind: "text" }, sort: { comparator: (a, b) => a.productName.localeCompare(b.productName) }, search: (l, q) => l.productName.toLowerCase().includes(q.toLowerCase()), + flex: true, }), f.field({ key: "quantity", @@ -92,6 +95,7 @@ const fields: LineItemsField[] = [ type: { kind: "number", decimals: 0 }, align: "right", sort: { comparator: (a, b) => a.quantity - b.quantity }, + width: 90, }), f.field({ key: "unitPrice", @@ -101,12 +105,23 @@ const fields: LineItemsField[] = [ type: { kind: "number", decimals: 2 }, align: "right", sort: { comparator: (a, b) => a.unitPrice - b.unitPrice }, + width: 110, }), f.field({ key: "total", label: "Total", render: (l) => fmtCurrency(l.total), align: "right", + width: 110, + }), + f.field({ + key: "expectedReady", + label: "Expected", + render: (l) => l.expectedReady, + editable: ["edit"], + type: { kind: "date" }, + sort: { comparator: (a, b) => a.expectedReady.localeCompare(b.expectedReady) }, + width: 140, }), f.field({ key: "note", @@ -115,6 +130,7 @@ const fields: LineItemsField[] = [ editable: ["edit", "amend"], type: { kind: "text" }, commit: "metadata", + width: 200, }), ]; @@ -134,6 +150,10 @@ function buildInitialLines(): POLine[] { const base = CATALOG[i % CATALOG.length]!; const quantity = ((i * 7) % 90) + 1; const unitPrice = round2(base.unitPrice * (1 + ((i % 11) - 5) / 100)); + // Cycle expected-ready dates +/- a few weeks for visual variety. + const baseDate = new Date(2026, 4, 1); // 2026-05-01 — May (month is 0-indexed) + baseDate.setDate(baseDate.getDate() + (i % 21)); + const iso = baseDate.toISOString().slice(0, 10); lines.push({ lineRef: `seed-${i + 1}`, sku: base.sku, @@ -141,6 +161,7 @@ function buildInitialLines(): POLine[] { quantity, unitPrice, total: round2(quantity * unitPrice), + expectedReady: iso, note: i % 50 === 0 ? "Highlight row" : "", }); } @@ -193,7 +214,9 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { const handleSave = React.useCallback(() => { const cs = lineItems.getChangeSet(); - // In a real app, send `cs` to the server here. + if (cs.isEmpty) return; // true client-side no-op + // In a real app, translate `cs.lineChanges` into the document's mutation + // shape (PO update, SO update, etc.) and dispatch one transactional submit. // eslint-disable-next-line no-console console.log("[line-items demo] saving change set", cs); lineItems.reset(); @@ -233,7 +256,10 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { - + {/* overflow-hidden clips the edge-to-edge table to the Card's rounded + corners. Without it the table fills Card.Content flush to the card's + bottom edge with square corners that poke outside `rounded-xl`. */} +

Line items

@@ -286,6 +312,7 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { quantity: 1, unitPrice: picked.unitPrice, total: round2(picked.unitPrice), + expectedReady: new Date().toISOString().slice(0, 10), note: "", }); }} @@ -295,14 +322,32 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { - lineItems.bulkRemove()} - onClearSelection={() => lineItems.clearSelection()} - isDirty={lineItems.isDirty} - onDiscard={() => lineItems.revert()} - onSave={() => void handleSave()} - /> + {/* ✅ Library pattern: dirty + selection bars auto-mount/unmount + based on hook state. Discard defaults to lineItems.revert(); + warnOnNav blocks anchor clicks + window unload while dirty. */} + + void handleSave()} /> + > + {({ bulkRemove, clear }) => ( + <> + + + + )} + + ); @@ -463,218 +508,3 @@ function InlineCatalogueAddRow({ ); } -/* ======================================================================== */ -/* Floating bottom action bars */ -/* ======================================================================== */ - -/** - * ✅ Reusable Component: floating bottom-center action pills. Mirrors the - * denim-tears `FloatingActions` pattern (apps/ims/.../purchase-orders-table.tsx) - * — a single `position: fixed` element rendered inline in the React tree (no - * portal needed). All styling is inline so no Tailwind class-generation - * gotchas; theme tokens come from `--foreground` / `--background` / - * `--muted-foreground` defined in `theme.css`. - * - * Two bars stack vertically: the bulk-selection bar (when rows selected) and - * the dirty-state bar (when there are unsaved changes). - */ -type FloatingActionsProps = { - selectedCount: number; - onBulkDelete: () => void; - onClearSelection: () => void; - isDirty: boolean; - onDiscard: () => void; - onSave: () => void; -}; - -const dockStyle: React.CSSProperties = { - position: "fixed", - bottom: "20px", - left: "50%", - transform: "translateX(-50%)", - zIndex: 60, - pointerEvents: "none", - display: "flex", - flexDirection: "column", - alignItems: "center", - gap: "8px", -}; - -const pillStyle: React.CSSProperties = { - pointerEvents: "auto", - display: "flex", - alignItems: "center", - gap: "12px", - padding: "12px 20px", - borderRadius: "16px", - backgroundColor: "var(--foreground)", - boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", -}; - -const labelStyle: React.CSSProperties = { - color: "var(--background)", - fontSize: "14px", - fontWeight: 500, - whiteSpace: "nowrap", -}; - -const dividerStyle: React.CSSProperties = { - width: "1px", - height: "24px", - backgroundColor: "var(--muted-foreground)", - opacity: 0.4, -}; - -const primaryButtonStyle: React.CSSProperties = { - backgroundColor: "var(--background)", - color: "var(--foreground)", - padding: "6px 12px", - borderRadius: "6px", - fontSize: "14px", - fontWeight: 500, - border: "none", - cursor: "pointer", -}; - -const secondaryButtonStyle: React.CSSProperties = { - background: "transparent", - color: "var(--muted-foreground)", - padding: "6px 12px", - borderRadius: "6px", - fontSize: "14px", - fontWeight: 500, - border: "none", - cursor: "pointer", -}; - -/** - * Jiggles the dirty bar to draw attention when the user is about to navigate - * away (tab visibility change, window blur). The keyframe is injected as a - * ` - {selectedCount > 0 ? ( -
- {selectedCount} selected - - - -
- ) : null} - {isDirty ? ( -
- Unsaved changes - - - -
- ) : null} -
- ); -} diff --git a/examples/app-module/src/pages/sales-invoice-demo.tsx b/examples/app-module/src/pages/sales-invoice-demo.tsx new file mode 100644 index 00000000..9c5a2810 --- /dev/null +++ b/examples/app-module/src/pages/sales-invoice-demo.tsx @@ -0,0 +1,322 @@ +import * as React from "react"; +import { + Button, + Card, + Combobox, + Layout, + LineItems, + createLineItemHelper, + defineResource, + lineItemsFloatingBarStyles, + useLineItems, + type LineItemsField, + type LineItemsRowData, +} from "@tailor-platform/app-shell"; + +/* ======================================================================== */ +/* Domain — Sales Invoice */ +/* ======================================================================== */ + +type InvoiceLine = LineItemsRowData & { + description: string; + quantity: number; + rate: number; + discountPct: number; + taxCode: string; + amount: number; +}; + +const TAX_CODES: ReadonlyArray<{ value: string; label: string; description?: string }> = [ + { value: "STD", label: "STD", description: "Standard rate" }, + { value: "RED", label: "RED", description: "Reduced 8%" }, + { value: "ZER", label: "ZER", description: "Zero-rated" }, + { value: "EXM", label: "EXM", description: "Exempt" }, +]; + +const TAX_PCT: Record = { STD: 0.1, RED: 0.08, ZER: 0, EXM: 0 }; + +const round2 = (n: number) => Math.round(n * 100) / 100; +const fmt = (n: number) => `$${n.toFixed(2)}`; + +const computeAmount = (l: InvoiceLine): number => { + const subtotal = l.quantity * l.rate; + const afterDiscount = subtotal * (1 - l.discountPct / 100); + const taxRate = TAX_PCT[l.taxCode] ?? 0; + return round2(afterDiscount * (1 + taxRate)); +}; + +/* ======================================================================== */ +/* Field schema */ +/* ======================================================================== */ + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "description", + label: "Description", + render: (l) => l.description, + editable: ["edit"], + type: { kind: "text" }, + flex: true, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + width: 100, + }), + f.field({ + key: "rate", + label: "Rate", + render: (l) => fmt(l.rate), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + width: 120, + }), + f.field({ + key: "discountPct", + label: "Discount %", + render: (l) => `${l.discountPct}%`, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 120, + }), + f.field({ + key: "taxCode", + label: "Tax", + render: (l) => l.taxCode, + editable: ["edit"], + type: { kind: "select", options: TAX_CODES }, + width: 120, + }), + f.field({ + key: "amount", + label: "Amount", + render: (l) => fmt(l.amount), + width: 140, + }), +]; + +const SERVICE_CATALOG: ReadonlyArray<{ description: string; rate: number; taxCode: string }> = [ + { description: "Consulting hour", rate: 150, taxCode: "STD" }, + { description: "Premium support — quarterly", rate: 1200, taxCode: "STD" }, + { description: "Training session — half day", rate: 600, taxCode: "RED" }, + { description: "Travel reimbursement", rate: 480, taxCode: "EXM" }, +]; + +const seed = (): InvoiceLine[] => [ + { lineRef: "L1", description: "Consulting hours — June", quantity: 24, rate: 150, discountPct: 0, taxCode: "STD", amount: 0 }, + { lineRef: "L2", description: "Premium support — Q2", quantity: 1, rate: 1200, discountPct: 10, taxCode: "STD", amount: 0 }, + { lineRef: "L3", description: "Travel reimbursement", quantity: 1, rate: 480, discountPct: 0, taxCode: "EXM", amount: 0 }, + { lineRef: "L4", description: "Training session — half day", quantity: 2, rate: 600, discountPct: 5, taxCode: "RED", amount: 0 }, +]; + +/* ======================================================================== */ +/* Page */ +/* ======================================================================== */ + +export const salesInvoiceDemoResource = defineResource({ + path: "sales-invoice-demo", + component: SalesInvoiceDemoPage, + meta: { title: "Sales Invoice (totals row)" }, +}); + +export function SalesInvoiceDemoPage() { + const initialLines = React.useMemo(() => { + const rows = seed(); + return rows.map((r) => ({ ...r, amount: computeAmount(r) })); + }, []); + + const lineItems = useLineItems({ + fields, + data: initialLines, + mode: "edit", + selection: true, + }); + + // Recompute amount when qty / rate / discount / tax changes. + const allLines = lineItems.allLines; + const updateLines = lineItems.updateLines; + React.useEffect(() => { + const updates: { lineRef: string; patch: Partial }[] = []; + for (const l of allLines) { + const expected = computeAmount(l); + if (expected !== l.amount) updates.push({ lineRef: l.lineRef, patch: { amount: expected } }); + } + if (updates.length) updateLines(updates); + }, [allLines, updateLines]); + + const handleSave = React.useCallback(() => { + const cs = lineItems.getChangeSet(); + if (cs.isEmpty) return; + // eslint-disable-next-line no-console + console.log("[sales invoice] save", cs); + lineItems.reset(); + }, [lineItems]); + + return ( + + lineItems.revert()}> + Discard + , + , + ]} + /> + + + + +
+

Invoice lines

+

+ {lineItems.allLines.length} lines ·{" "} + {lineItems.isDirty ? "Unsaved changes" : "All saved"} +

+
+
+ + + + +
+
+ + + + + { + const line: Partial = { + description: picked.description, + quantity: 1, + rate: picked.rate, + discountPct: 0, + taxCode: picked.taxCode, + amount: 0, + }; + lineItems.addLine(line); + }} + /> + +
+ + + void handleSave()} /> + > + {({ bulkRemove, clear }) => ( + <> + + + + )} + + + + {/* ✅ Reusable Pattern: a sticky totals row, fed by the live row state. */} + > + {(lines) => { + const totalQty = round2(lines.reduce((s, l) => s + Number(l.quantity), 0)); + const totalAmt = round2(lines.reduce((s, l) => s + Number(l.amount), 0)); + return { + description: Total, + quantity: totalQty, + amount: {fmt(totalAmt)}, + }; + }} + +
+
+
+ ); +} + +/* ======================================================================== */ +/* Bottom add-line picker */ +/* ======================================================================== */ + +function AddInvoiceLineRow({ + onPick, +}: { + onPick: (item: { description: string; rate: number; taxCode: string }) => void; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( +
+ + key={resetKey} + items={SERVICE_CATALOG as Array<{ description: string; rate: number; taxCode: string }>} + placeholder="+ Add line item — type to search…" + emptyText="No matching services." + mapItem={(p) => ({ + key: p.description, + label: `${p.description} ${p.rate}`, + render: ( +
+ {p.description} + {`$${p.rate} · ${p.taxCode}`} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + onPick(picked); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + +
+ ); +} diff --git a/examples/app-module/src/pages/stock-transfer-demo.tsx b/examples/app-module/src/pages/stock-transfer-demo.tsx new file mode 100644 index 00000000..9f8113c3 --- /dev/null +++ b/examples/app-module/src/pages/stock-transfer-demo.tsx @@ -0,0 +1,327 @@ +import * as React from "react"; +import { + Button, + Card, + Combobox, + Layout, + LineItems, + createLineItemHelper, + defineResource, + lineItemsFloatingBarStyles, + useLineItems, + type LineItemsField, + type LineItemsRowData, +} from "@tailor-platform/app-shell"; + +/* ======================================================================== */ +/* Domain — Stock Transfer */ +/* ======================================================================== */ + +type TransferLine = LineItemsRowData & { + sku: string; + productName: string; + fromWarehouse: string; + toWarehouse: string; + quantity: number; + lotNumber: string; +}; + +const WAREHOUSES = [ + { value: "WH-NYC", label: "WH-NYC", description: "New York" }, + { value: "WH-LAX", label: "WH-LAX", description: "Los Angeles" }, + { value: "WH-CHI", label: "WH-CHI", description: "Chicago" }, + { value: "WH-DAL", label: "WH-DAL", description: "Dallas" }, +]; + +/* ======================================================================== */ +/* Field schema */ +/* ======================================================================== */ + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "productName", + label: "Product", + render: (l) => l.productName, + editable: ["edit"], + type: { kind: "text" }, + flex: true, + }), + f.field({ + key: "fromWarehouse", + label: "From", + render: (l) => l.fromWarehouse, + editable: ["edit"], + type: { kind: "select", options: WAREHOUSES }, + width: 140, + }), + f.field({ + key: "toWarehouse", + label: "To", + render: (l) => l.toWarehouse, + editable: ["edit"], + type: { kind: "select", options: WAREHOUSES }, + width: 140, + // 🎨 Highlight when from === to (cross-field validation hint). + className: (l) => + l.fromWarehouse && l.fromWarehouse === l.toWarehouse + ? "astw:bg-destructive/10 astw:text-destructive" + : undefined, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 100, + }), + f.field({ + key: "lotNumber", + label: "Lot No", + render: (l) => l.lotNumber, + editable: ["edit"], + type: { kind: "text" }, + width: 140, + }), +]; + +const ST_CATALOG: ReadonlyArray<{ sku: string; productName: string }> = [ + { sku: "SKU-1001", productName: "Indigo Denim Roll" }, + { sku: "SKU-2040", productName: "Copper Rivet Pack" }, + { sku: "SKU-3300", productName: "Organic Cotton Jersey" }, + { sku: "SKU-4412", productName: "Leather Patch Kit" }, +]; + +const seed = (): TransferLine[] => [ + { lineRef: "T1", sku: "SKU-1001", productName: "Indigo Denim Roll", fromWarehouse: "WH-NYC", toWarehouse: "WH-LAX", quantity: 20, lotNumber: "L-0701" }, + { lineRef: "T2", sku: "SKU-2040", productName: "Copper Rivet Pack", fromWarehouse: "WH-NYC", toWarehouse: "WH-CHI", quantity: 50, lotNumber: "L-0702" }, + { lineRef: "T3", sku: "SKU-3300", productName: "Organic Cotton Jersey", fromWarehouse: "WH-DAL", toWarehouse: "WH-LAX", quantity: 80, lotNumber: "L-0703" }, +]; + +/* ======================================================================== */ +/* Page */ +/* ======================================================================== */ + +export const stockTransferDemoResource = defineResource({ + path: "stock-transfer-demo", + component: StockTransferDemoPage, + meta: { title: "Stock Transfer (row actions)" }, +}); + +export function StockTransferDemoPage() { + const lineItems = useLineItems({ + fields, + data: seed(), + mode: "edit", + selection: true, + }); + + const [errors, setErrors] = React.useState([]); + + const handleSave = React.useCallback(() => { + // Cross-field validation at submit: from must differ from to. + const offending = lineItems.allLines.filter((l) => l.fromWarehouse === l.toWarehouse); + if (offending.length) { + setErrors(offending.map((l) => `${l.sku}: From / To warehouse must differ.`)); + return; + } + setErrors([]); + const cs = lineItems.getChangeSet(); + if (cs.isEmpty) return; + // eslint-disable-next-line no-console + console.log("[stock transfer] save", cs); + lineItems.reset(); + }, [lineItems]); + + return ( + + lineItems.revert()}> + Discard + , + , + ]} + /> + + {errors.length ? ( + + +

+ {errors.length} validation error{errors.length === 1 ? "" : "s"} +

+
    + {errors.map((e) => ( +
  • {e}
  • + ))} +
+
+
+ ) : null} + + + + +
+

Transfer lines

+

+ {lineItems.allLines.length} lines · trailing actions stay visible while you scroll +

+
+
+ + + + +
+
+ + + ( + <> + + + + )} + /> + + { + lineItems.addLine({ + sku: picked.sku, + productName: picked.productName, + fromWarehouse: WAREHOUSES[0]!.value, + toWarehouse: WAREHOUSES[1]!.value, + quantity: 1, + lotNumber: "", + }); + }} + /> + +
+ + + void handleSave()} /> + > + {({ bulkRemove, clear }) => ( + <> + + + + )} + + +
+
+
+ ); +} + +function AddTransferLineRow({ + onPick, +}: { + onPick: (item: { sku: string; productName: string }) => void; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( +
+ + key={resetKey} + items={ST_CATALOG as Array<{ sku: string; productName: string }>} + placeholder="+ Add line item — type to search…" + emptyText="No matching products." + mapItem={(p) => ({ + key: p.sku, + label: `${p.sku} ${p.productName}`, + render: ( +
+ {p.sku} + {p.productName} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + onPick(picked); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + +
+ ); +} diff --git a/examples/app-module/src/pages/work-order-demo.tsx b/examples/app-module/src/pages/work-order-demo.tsx new file mode 100644 index 00000000..b92632e1 --- /dev/null +++ b/examples/app-module/src/pages/work-order-demo.tsx @@ -0,0 +1,427 @@ +import * as React from "react"; +import { + Button, + Card, + Combobox, + Layout, + LineItems, + createLineItemHelper, + defineResource, + useLineItems, + useLineItemsGroup, + type LineItemsField, + type LineItemsRowData, +} from "@tailor-platform/app-shell"; + +/* ======================================================================== */ +/* Domain — Work Order */ +/* Components and operations have DIFFERENT row types under one header. */ +/* ======================================================================== */ + +type ComponentLine = LineItemsRowData & { + partSku: string; + partName: string; + qtyRequired: number; + uom: string; +}; + +type OperationLine = LineItemsRowData & { + sequence: number; + step: string; + workstation: string; + durationMinutes: number; +}; + +const UOM_OPTIONS = [ + { value: "EA", label: "EA", description: "Each" }, + { value: "KG", label: "KG", description: "Kilogram" }, + { value: "M", label: "M", description: "Meter" }, +]; + +const WORKSTATION_OPTIONS = [ + { value: "WS-01", label: "WS-01", description: "Cutting" }, + { value: "WS-02", label: "WS-02", description: "Sewing" }, + { value: "WS-03", label: "WS-03", description: "Pressing" }, + { value: "WS-04", label: "WS-04", description: "QA" }, +]; + +/* ======================================================================== */ +/* Field schemas */ +/* ======================================================================== */ + +const fc = createLineItemHelper(); +const fo = createLineItemHelper(); + +const componentFields: LineItemsField[] = [ + fc.field({ + key: "partSku", + label: "Part SKU", + render: (l) => l.partSku, + editable: ["edit"], + type: { kind: "text" }, + width: 160, + pinned: "left", + }), + fc.field({ + key: "partName", + label: "Part name", + render: (l) => l.partName, + editable: ["edit"], + type: { kind: "text" }, + flex: true, + }), + fc.field({ + key: "qtyRequired", + label: "Qty required", + render: (l) => l.qtyRequired, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + width: 130, + }), + fc.field({ + key: "uom", + label: "UOM", + render: (l) => l.uom, + editable: ["edit"], + type: { kind: "select", options: UOM_OPTIONS }, + width: 100, + }), +]; + +const operationFields: LineItemsField[] = [ + fo.field({ + key: "sequence", + label: "Seq", + render: (l) => l.sequence, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + sort: { comparator: (a, b) => a.sequence - b.sequence }, + width: 80, + pinned: "left", + }), + fo.field({ + key: "step", + label: "Step", + render: (l) => l.step, + editable: ["edit"], + type: { kind: "text" }, + flex: true, + }), + fo.field({ + key: "workstation", + label: "Workstation", + render: (l) => l.workstation, + editable: ["edit"], + type: { kind: "select", options: WORKSTATION_OPTIONS }, + width: 160, + }), + fo.field({ + key: "durationMinutes", + label: "Duration (min)", + render: (l) => l.durationMinutes, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 140, + }), +]; + +const PARTS_CATALOG: ReadonlyArray<{ partSku: string; partName: string; uom: string }> = [ + { partSku: "FAB-001", partName: "Indigo denim", uom: "M" }, + { partSku: "FAB-002", partName: "Cotton fabric", uom: "M" }, + { partSku: "TR-220", partName: "Cotton thread", uom: "M" }, + { partSku: "BTN-010", partName: "Brass buttons", uom: "EA" }, + { partSku: "ZIP-007", partName: "YKK zipper 7\"", uom: "EA" }, +]; + +const STEP_CATALOG: ReadonlyArray<{ step: string; workstation: string; durationMinutes: number }> = [ + { step: "Cut fabric", workstation: "WS-01", durationMinutes: 15 }, + { step: "Sew panels", workstation: "WS-02", durationMinutes: 45 }, + { step: "Attach zipper", workstation: "WS-02", durationMinutes: 12 }, + { step: "Press seams", workstation: "WS-03", durationMinutes: 8 }, + { step: "Final QA inspect", workstation: "WS-04", durationMinutes: 6 }, +]; + +const seedComponents = (): ComponentLine[] => [ + { lineRef: "C1", partSku: "FAB-001", partName: "Indigo denim", qtyRequired: 2.5, uom: "M" }, + { lineRef: "C2", partSku: "TR-220", partName: "Cotton thread", qtyRequired: 200, uom: "M" }, + { lineRef: "C3", partSku: "BTN-010", partName: "Brass buttons", qtyRequired: 12, uom: "EA" }, + { lineRef: "C4", partSku: "ZIP-007", partName: "YKK zipper 7\"", qtyRequired: 1, uom: "EA" }, +]; + +const seedOperations = (): OperationLine[] => [ + { lineRef: "O1", sequence: 10, step: "Cut fabric", workstation: "WS-01", durationMinutes: 15 }, + { lineRef: "O2", sequence: 20, step: "Sew panels", workstation: "WS-02", durationMinutes: 45 }, + { lineRef: "O3", sequence: 30, step: "Attach zipper", workstation: "WS-02", durationMinutes: 12 }, + { lineRef: "O4", sequence: 40, step: "Press seams", workstation: "WS-03", durationMinutes: 8 }, + { lineRef: "O5", sequence: 50, step: "Final QA inspect", workstation: "WS-04", durationMinutes: 6 }, +]; + +/* ======================================================================== */ +/* Page */ +/* ======================================================================== */ + +export const workOrderDemoResource = defineResource({ + path: "work-order-demo", + component: WorkOrderDemoPage, + meta: { title: "Work Order (multi-collection)" }, +}); + +export function WorkOrderDemoPage() { + const components = useLineItems({ + fields: componentFields, + data: seedComponents(), + mode: "edit", + selection: true, + }); + const operations = useLineItems({ + fields: operationFields, + data: seedOperations(), + mode: "edit", + selection: true, + }); + + // ✅ Reusable Pattern: bundle two heterogeneous collections under one header + // — different row types, different field schemas, one shared submit boundary. + const group = useLineItemsGroup({ components, operations }); + + const totalDuration = operations.allLines.reduce((s, l) => s + Number(l.durationMinutes), 0); + + const handleSave = React.useCallback(() => { + const cs = group.getChangeSet(); + if (cs.isEmpty) return; + // eslint-disable-next-line no-console + console.log("[work order] save", cs); + group.reset(); + }, [group]); + + return ( + + group.revert()} + disabled={!group.isDirty} + > + Discard + , + , + ]} + /> + + + + + + + {group.isDirty ? ( + Unsaved changes across both collections + ) : null} + + + + + components.addLine({ + partSku: p.partSku, + partName: p.partName, + qtyRequired: 1, + uom: p.uom, + }) + } + /> + } + /> + { + const nextSeq = + operations.allLines.length > 0 + ? Math.max(...operations.allLines.map((l) => l.sequence)) + 10 + : 10; + operations.addLine({ + sequence: nextSeq, + step: p.step, + workstation: p.workstation, + durationMinutes: p.durationMinutes, + }); + }} + /> + } + /> + + + ); +} + +function Stat({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + + {value} + +
+ ); +} + +function CollectionSection({ + title, + hook, + bottomRow, +}: { + title: string; + hook: ReturnType>; + bottomRow?: React.ReactNode; +}) { + return ( + + + +
+

{title}

+

+ {hook.allLines.length} lines{hook.isDirty ? " · Modified" : ""} +

+
+
+ + + + +
+
+ + + {bottomRow} + +
+
+ ); +} + +type PartItem = { partSku: string; partName: string; uom: string }; +type StepItem = { step: string; workstation: string; durationMinutes: number }; + +function AddComponentLineRow({ + onPick, +}: { + onPick: (p: PartItem) => void; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( +
+ + key={resetKey} + items={PARTS_CATALOG as PartItem[]} + placeholder="+ Add line item — type to search…" + emptyText="No matching parts." + mapItem={(p) => ({ + key: p.partSku, + label: `${p.partSku} ${p.partName}`, + render: ( +
+ {p.partSku} + {p.partName} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + onPick(picked); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + +
+ ); +} + +function AddOperationLineRow({ + onPick, +}: { + onPick: (p: StepItem) => void; +}) { + const [resetKey, setResetKey] = React.useState(0); + return ( +
+ + key={resetKey} + items={STEP_CATALOG as StepItem[]} + placeholder="+ Add line item — type to search…" + emptyText="No matching steps." + mapItem={(p) => ({ + key: p.step, + label: `${p.step} ${p.workstation}`, + render: ( +
+ {p.step} + {`${p.workstation} · ${p.durationMinutes} min`} +
+ ), + })} + onValueChange={(picked) => { + if (!picked) return; + onPick(picked); + setResetKey((k) => k + 1); + }} + className="astw:flex-1" + /> + +
+ ); +} diff --git a/examples/nextjs-app/README.md b/examples/nextjs-app/README.md index 4832bd78..82b90dee 100644 --- a/examples/nextjs-app/README.md +++ b/examples/nextjs-app/README.md @@ -4,8 +4,18 @@ Example AppShell application using Next.js App Router. ## Run +From this directory (only the Next.js app): + ```bash pnpm dev ``` +From the **monorepo root**: + +```bash +pnpm dev:next +``` + Open [http://localhost:3000](http://localhost:3000) to see the result. + +To watch **every** example at once via Turbo (uses more RAM), use `pnpm dev:examples` from the repo root instead. diff --git a/examples/nextjs-app/next.config.ts b/examples/nextjs-app/next.config.ts index e9ffa308..f9b1bb6a 100644 --- a/examples/nextjs-app/next.config.ts +++ b/examples/nextjs-app/next.config.ts @@ -1,7 +1,19 @@ import type { NextConfig } from "next"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +/** + * Lock this app to the **app-shell** monorepo root so Turbopack does not pick a + * parent directory that happens to contain another `package-lock.json` or + * `pnpm-workspace.yaml` (which breaks `workspace:*` resolution for `app-module`). + */ +const monorepoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); const nextConfig: NextConfig = { - /* config options here */ + turbopack: { + root: monorepoRoot, + }, + transpilePackages: ["app-module", "@tailor-platform/app-shell"], }; export default nextConfig; diff --git a/examples/vite-app/README.md b/examples/vite-app/README.md index bcfb4df4..e5fc9df6 100644 --- a/examples/vite-app/README.md +++ b/examples/vite-app/README.md @@ -4,8 +4,18 @@ Example AppShell application using Vite with file-based routing. ## Run +From this directory (only the Vite app): + +```bash +pnpm dev +``` + +From the **monorepo root** (same isolated process): + ```bash pnpm dev +# or explicitly: +pnpm dev:vite ``` -Open [http://localhost:5173](http://localhost:5173) to see the result. +Open [http://localhost:3030](http://localhost:3030) (see `vite.config.ts` `server.port`). diff --git a/package.json b/package.json index 7f8fceec..7cb93080 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,10 @@ "name": "app-shell", "private": "true", "scripts": { - "dev": "turbo watch dev --filter='./examples/*'", + "dev": "pnpm dev:vite", + "dev:vite": "pnpm --filter vite-app dev", + "dev:next": "turbo watch dev --filter=nextjs-app --filter=app-module", + "dev:examples": "turbo watch dev --filter='./examples/*'", "build": "turbo build", "type-check": "turbo type-check", "lint": "turbo lint", diff --git a/packages/core/src/components/line-items/LineItems.tsx b/packages/core/src/components/line-items/LineItems.tsx index 7e76d418..94af6d45 100644 --- a/packages/core/src/components/line-items/LineItems.tsx +++ b/packages/core/src/components/line-items/LineItems.tsx @@ -1,10 +1,14 @@ import { LineItemsAddRow, LineItemsBulkActions, + LineItemsDirtyBar, + LineItemsFloatingDock, LineItemsFullscreenToggle, LineItemsSaveActions, LineItemsSearch, LineItemsSearchToggle, + LineItemsSelectionBar, + LineItemsTotalsRow, } from "./line-items-parts"; import { LineItemsRoot } from "./line-items-root"; import { LineItemsTable } from "./line-items-table"; @@ -38,4 +42,8 @@ export const LineItems = { AddRow: LineItemsAddRow, FullscreenToggle: LineItemsFullscreenToggle, SaveActions: LineItemsSaveActions, + TotalsRow: LineItemsTotalsRow, + FloatingDock: LineItemsFloatingDock, + DirtyBar: LineItemsDirtyBar, + SelectionBar: LineItemsSelectionBar, }; diff --git a/packages/core/src/components/line-items/field.ts b/packages/core/src/components/line-items/field.ts index 2c7ad436..ae0661b9 100644 --- a/packages/core/src/components/line-items/field.ts +++ b/packages/core/src/components/line-items/field.ts @@ -72,9 +72,12 @@ export function fieldAllowsFill( * Translate a single `LineItemsField` into the internal `LineItemsColumnDef` * shape consumed by `internals.ts` (change-set + normalization + equality). * - * Numeric fields get a normalizer that coerces strings to numbers (trimming and - * rounding by `decimals`) and a tolerance-aware `equals` so cosmetic input - * variations like "1" vs "1.00" don't show up as document changes. + * Default behaviors: + * - Numeric fields get a normalizer that coerces strings to numbers (trimming + * and rounding by `decimals`) and a tolerance-aware `equals` so cosmetic + * input variations like "1" vs "1.00" don't show up as document changes. + * - The field's own `normalize` / `equals` (or those on a `kind: "custom"` + * type) take precedence over the built-in defaults. */ export function fieldToColumnDef( field: LineItemsField, @@ -110,9 +113,29 @@ export function fieldToColumnDef( }; } + // App-supplied overrides on the type take precedence over built-in defaults. + if (field.type?.kind === "custom") { + if (field.type.normalize) def.normalize = field.type.normalize as typeof def.normalize; + if (field.type.equals) def.equals = field.type.equals as typeof def.equals; + } + + // Field-level overrides win over both type-level and built-in defaults. + if (field.normalize) def.normalize = field.normalize as typeof def.normalize; + if (field.equals) def.equals = field.equals as typeof def.equals; + return def; } +/** Default column alignment given a field. Numbers right-align; everything else left. */ +export function defaultAlignForField( + field: LineItemsField, +): "left" | "center" | "right" { + if (field.align) return field.align; + if (field.type?.kind === "number") return "right"; + if (field.type?.kind === "boolean") return "center"; + return "left"; +} + /** Translate a list of fields into internal column defs. */ export function fieldsToColumnDefs( fields: LineItemsField[], diff --git a/packages/core/src/components/line-items/index.ts b/packages/core/src/components/line-items/index.ts index a6aa6856..cb50375a 100644 --- a/packages/core/src/components/line-items/index.ts +++ b/packages/core/src/components/line-items/index.ts @@ -1,22 +1,35 @@ export { LineItems } from "./LineItems"; export { useLineItems } from "./use-line-items"; +export { useLineItemsGroup } from "./use-line-items-group"; +export type { + LineItemsGroupInput, + LineItemsGroupChangeSet, + LineItemsGroupReturn, +} from "./use-line-items-group"; export { createLineItemHelper } from "./field"; export type { LineItemsBulkActionsProps, LineItemsBulkActionsRenderArgs, LineItemsAddRowProps, + LineItemsDirtyBarProps, + LineItemsFloatingDockProps, LineItemsFullscreenToggleProps, LineItemsSaveActionsProps, LineItemsSearchProps, LineItemsSearchToggleProps, + LineItemsSelectionBarProps, + LineItemsSelectionBarRenderArgs, + LineItemsTotalsRowProps, } from "./line-items-parts"; +export { lineItemsFloatingBarStyles } from "./line-items-parts"; export type { LineItemsRootProps } from "./line-items-root"; export type { LineItemsTableProps } from "./line-items-table"; export type { LineItemsChangeSet, LineItemsColumnAlign, + LineItemsCustomEditorContext, LineItemsField, LineItemsFieldCommit, LineItemsFieldType, diff --git a/packages/core/src/components/line-items/internals.ts b/packages/core/src/components/line-items/internals.ts index 885fc149..8ac24e13 100644 --- a/packages/core/src/components/line-items/internals.ts +++ b/packages/core/src/components/line-items/internals.ts @@ -86,7 +86,7 @@ export function computeDocumentPatches( /** Removals for ids that existed in baseline. */ for (const id of baseline.order) { if (removedRefs.has(id) || !currentOrder.includes(id)) { - if (baseline.rows[id]) lines.push({ action: "remove", lineRef: id }); + if (baseline.rows[id]) lines.push({ action: "remove", lineId: id }); } } @@ -95,9 +95,8 @@ export function computeDocumentPatches( if (!insertedRefs.has(id) || baseline.rows[id]) continue; const row = currentByRef[id]; if (!row) continue; - const insertAfter = previousRefInOrder(currentOrder, id); - const patch = pickDocumentPatch(cols, row, documentKeys); - lines.push({ action: "add", lineRef: id, insertAfterLineRef: insertAfter, patch }); + const data = pickDocumentPatch(cols, row, documentKeys); + lines.push({ action: "add", tempId: id, data }); } /** Updates for persisted rows. */ @@ -117,7 +116,7 @@ export function computeDocumentPatches( const nBase = normalizeField(cols, key, rawBase, base); if (!equalsField(cols, key, nCur, nBase, row)) patch[key] = nCur; } - if (Object.keys(patch).length > 0) lines.push({ action: "update", lineRef: id, patch }); + if (Object.keys(patch).length > 0) lines.push({ action: "update", lineId: id, patch }); } if (orderingMode === "manual") { @@ -136,7 +135,7 @@ export function computeDocumentPatches( return lines; } -/** Emit move ops for persisted ids whose predecessor changed vs baseline. */ +/** Emit reorder ops for persisted ids whose position changed vs baseline. */ function diffPersistedMoves( baseOrderFull: readonly string[], curOrderFull: readonly string[], @@ -154,20 +153,18 @@ function diffPersistedMoves( const curIds = persisted(curOrderFull); if (baseIds.length !== curIds.length || !setsEqual(new Set(baseIds), new Set(curIds))) return []; - const basePred = new Map(); - for (let i = 0; i < baseIds.length; i++) { - basePred.set(baseIds[i]!, i === 0 ? null : baseIds[i - 1]!); - } + const baseIndex = new Map(); + for (let i = 0; i < baseIds.length; i++) baseIndex.set(baseIds[i]!, i); - const moves: LineItemsLineChange[] = []; - /** Single pass — any id whose predecessor in current differs from predecessor in baseline moved. */ + const reorders: LineItemsLineChange[] = []; + /** Emit a reorder op for each persisted id whose final index differs from baseline. */ for (let i = 0; i < curIds.length; i++) { const id = curIds[i]!; - const curPred = i === 0 ? null : curIds[i - 1]!; - if (basePred.get(id) !== curPred) - moves.push({ action: "move", lineRef: id, afterLineRef: curPred }); + if (baseIndex.get(id) !== i) { + reorders.push({ action: "reorder", lineId: id, position: i }); + } } - return moves; + return reorders; } function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { @@ -176,12 +173,6 @@ function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { return true; } -function previousRefInOrder(order: readonly string[], id: string): string | null { - const idx = order.indexOf(id); - if (idx <= 0) return null; - return order[idx - 1]!; -} - function columnsByScope( cols: LineItemsColumnDef[], scope: LineItemsMutationScope, @@ -228,11 +219,11 @@ export function buildChangeSet( insertedRefs, orderingMode, ); - return { lineChanges }; + return { isEmpty: lineChanges.length === 0, lineChanges }; } export function isChangeSetEmpty(cs: LineItemsChangeSet): boolean { - return cs.lineChanges.length === 0; + return cs.isEmpty; } /** Deep clone row for baseline using JSON when possible. */ diff --git a/packages/core/src/components/line-items/line-items-default-cell.tsx b/packages/core/src/components/line-items/line-items-default-cell.tsx index eb57d555..3e8a50e9 100644 --- a/packages/core/src/components/line-items/line-items-default-cell.tsx +++ b/packages/core/src/components/line-items/line-items-default-cell.tsx @@ -13,6 +13,7 @@ import { import { cn } from "@/lib/utils"; import { + defaultAlignForField, fieldAllowsFill, fieldAllowsPaste, fieldCommitScope, @@ -205,7 +206,7 @@ function EditableFieldCell(p: { {field.render(row)} @@ -217,6 +218,15 @@ function EditableFieldCell(p: { if (fieldType?.kind === "select") { return ; } + if (fieldType?.kind === "boolean") { + return ; + } + if (fieldType?.kind === "date") { + return ; + } + if (fieldType?.kind === "custom") { + return ; + } const isNumeric = field.type?.kind === "number"; @@ -290,7 +300,7 @@ function EditableFieldCell(p: { "astw:placeholder:text-muted-foreground astw:selection:bg-primary astw:selection:text-primary-foreground", "astw:disabled:cursor-not-allowed astw:disabled:opacity-50", "astw:[appearance:textfield] astw:[&::-webkit-outer-spin-button]:appearance-none astw:[&::-webkit-outer-spin-button]:m-0 astw:[&::-webkit-inner-spin-button]:appearance-none astw:[&::-webkit-inner-spin-button]:m-0", - field.align === "right" && "astw:text-right astw:tabular-nums", + defaultAlignForField(field) === "right" && "astw:text-right astw:tabular-nums", className, )} value={local} @@ -305,6 +315,209 @@ function EditableFieldCell(p: { ); } +/* ======================================================================== */ +/* Boolean cell */ +/* ======================================================================== */ + +function BooleanFieldCell({ + field, + lineRef, + value, +}: { + field: LineItemsField; + lineRef: string; + row: T; + value: unknown; +}) { + const ctx = useLineItemsGrid(); + if (!ctx) return null; + const t = field.type; + if (!t || t.kind !== "boolean") return null; + + const checked = value === true; + const onCommit = (next: boolean) => { + ctx.hookRef.current.updateField(lineRef, field.key as keyof T, next as T[keyof T]); + }; + const onFocus = () => ctx.onCellFocused({ lineRef, columnId: field.key }); + const onKeyDown = (e: React.KeyboardEvent) => { + if ( + e.altKey && + (e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight") + ) { + e.preventDefault(); + ctx.navigateArrowFromInput(e.key, e.shiftKey); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + ctx.navigateFromEdit("enter-down"); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + ctx.navigateFromEdit(e.shiftKey ? "shift-tab" : "tab"); + } + }; + + return ( + + ); +} + +/* ======================================================================== */ +/* Date cell */ +/* ======================================================================== */ + +function DateFieldCell({ + field, + lineRef, + value, +}: { + field: LineItemsField; + lineRef: string; + row: T; + value: unknown; +}) { + const ctx = useLineItemsGrid(); + if (!ctx) return null; + const t = field.type; + if (!t || t.kind !== "date") return null; + + const strVal = value == null ? "" : String(value); + const [local, setLocal] = React.useState(strVal); + React.useEffect(() => setLocal(strVal), [strVal]); + + const onCommit = (next: string) => { + ctx.hookRef.current.updateField( + lineRef, + field.key as keyof T, + (next === "" ? null : next) as T[keyof T], + ); + }; + const onFocus = (e: React.FocusEvent) => { + ctx.onCellFocused({ lineRef, columnId: field.key }); + try { + e.currentTarget.select(); + } catch { + /* date inputs may reject select() in some browsers; harmless */ + } + }; + const onKeyDown = (e: React.KeyboardEvent) => { + if ( + e.altKey && + (e.key === "ArrowUp" || + e.key === "ArrowDown" || + e.key === "ArrowLeft" || + e.key === "ArrowRight") + ) { + e.preventDefault(); + ctx.navigateArrowFromInput(e.key, e.shiftKey); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setLocal(strVal); + if (fieldCommitScope(field) === "document") onCommit(strVal); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + ctx.navigateFromEdit("enter-down"); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + ctx.navigateFromEdit(e.shiftKey ? "shift-tab" : "tab"); + } + }; + + return ( + { + const v = e.target.value; + setLocal(v); + onCommit(v); + }} + onFocus={onFocus} + onKeyDown={onKeyDown} + /> + ); +} + +/* ======================================================================== */ +/* Custom cell */ +/* ======================================================================== */ + +function CustomFieldCell({ + field, + lineRef, + row, + value, +}: { + field: LineItemsField; + lineRef: string; + row: T; + value: unknown; +}) { + const ctx = useLineItemsGrid(); + if (!ctx) return null; + const t = field.type; + if (!t || t.kind !== "custom") return null; + + // Latest committed value goes through the standard update path so the hook's + // dirty tracking + change-set work unchanged. + const onCommit = (next: unknown) => { + ctx.hookRef.current.updateField(lineRef, field.key as keyof T, next as T[keyof T]); + }; + // Cancel is a no-op at the hook level; the custom editor decides whether to + // restore the previous value visually. The hook already holds it in `value`. + const onCancel = () => { + ctx.onCellFocused({ lineRef, columnId: field.key }); + }; + + return ( +
ctx.onCellFocused({ lineRef, columnId: field.key })} + > + {t.renderEditor({ value, onCommit, onCancel, row, mode: ctx.mode, field })} +
+ ); +} + /* ======================================================================== */ /* Spreadsheet cell shell (overlays + fill grip) */ /* ======================================================================== */ @@ -323,6 +536,7 @@ function SpreadsheetCellShell({ selected, fillHighlight, showFillGrip, + readonlyTint, children, onPointerDown, onFillGripPointerDown, @@ -332,6 +546,12 @@ function SpreadsheetCellShell({ selected: boolean; fillHighlight: boolean; showFillGrip: boolean; + /** + * When true, paints a subtle muted background to differentiate read-only + * cells from editable ones. Used in `amend` mode for fields that aren't + * editable in amend so users can see at a glance which cells they can touch. + */ + readonlyTint: boolean; children: React.ReactNode; onPointerDown: (e: React.PointerEvent) => void; onFillGripPointerDown: (e: React.PointerEvent) => void; @@ -346,10 +566,14 @@ function SpreadsheetCellShell({ data-slot="line-items-grid-cell" data-line-ref={coord.lineRef} data-column-id={coord.columnId} + data-readonly={readonlyTint ? "true" : undefined} role="gridcell" aria-selected={selected} className={cn( "astw:absolute astw:inset-0 astw:flex astw:min-h-0 astw:min-w-0 astw:items-stretch", + // Read-only base tint sits below selection/fill highlights so the + // active selection visually overrides it. + readonlyTint && "astw:bg-muted/40", selected && !primary && "astw:bg-primary/10", fillHighlight && "astw:bg-primary/15", )} @@ -397,7 +621,7 @@ export function LineItemsFieldCell({ {field.render(row)} @@ -409,6 +633,10 @@ export function LineItemsFieldCell({ const inSel = ctx.isInSelection(coord); const fillHighlight = ctx.isInFillPreview(coord); const showFill = fieldAllowsFill(field, mode) && primary && ctx.fillPreview === null; + // In amend mode, fields that aren't editable in amend get a muted tint so + // users can see at a glance which cells they can touch. Display mode is the + // whole table read-only, so we don't tint there (would just be uniform grey). + const readonlyTint = mode === "amend" && !editable; return ( ({ selected={inSel} fillHighlight={fillHighlight} showFillGrip={showFill && editable} + readonlyTint={readonlyTint} onPointerDown={(e) => ctx.onCellPointerDown(coord, e)} onFillGripPointerDown={(e) => ctx.onFillGripPointerDown(coord, e)} > diff --git a/packages/core/src/components/line-items/line-items-internals.test.ts b/packages/core/src/components/line-items/line-items-internals.test.ts index 677117a9..eba945e9 100644 --- a/packages/core/src/components/line-items/line-items-internals.test.ts +++ b/packages/core/src/components/line-items/line-items-internals.test.ts @@ -67,7 +67,7 @@ describe("buildChangeSet", () => { expect(cs.lineChanges).toEqual([ { action: "update", - lineRef: "a", + lineId: "a", patch: { qty: 2 }, }, ]); @@ -114,6 +114,7 @@ describe("buildChangeSet", () => { "sort", ); - expect(cs.lineChanges.some((x) => x.action === "add" && x.lineRef === "n1")).toBe(true); + expect(cs.lineChanges.some((x) => x.action === "add" && x.tempId === "n1")).toBe(true); + expect(cs.isEmpty).toBe(false); }); }); diff --git a/packages/core/src/components/line-items/line-items-parts.tsx b/packages/core/src/components/line-items/line-items-parts.tsx index f3474075..8b399ae0 100644 --- a/packages/core/src/components/line-items/line-items-parts.tsx +++ b/packages/core/src/components/line-items/line-items-parts.tsx @@ -318,3 +318,347 @@ export function LineItemsSaveActions({ ); } LineItemsSaveActions.displayName = "LineItems.SaveActions"; + +/* ======================================================================== */ +/* TotalsRow */ +/* ======================================================================== */ + +export type LineItemsTotalsRowProps = { + /** + * Render-prop receiving the live `allLines` array. Return one value per + * column key — keys not present in the result render blank in the totals row. + */ + children: (lines: T[]) => Record; +}; + +/** + * Renders a sticky totals row at the bottom of ``. Place it + * as a sibling of the table inside ``; the table picks up the + * render-fn via root context and renders the values aligned to the columns. + * + * @example + * ```tsx + * + * + * + * {(lines) => ({ + * quantity: lines.reduce((s, l) => s + l.quantity, 0), + * amount: `$${lines.reduce((s, l) => s + l.amount, 0).toFixed(2)}`, + * })} + * + * + * ``` + */ +export function LineItemsTotalsRow({ + children, +}: LineItemsTotalsRowProps) { + const root = useLineItemsRoot(); + React.useEffect(() => { + root.setTotalsRowFn(() => children); + return () => root.setTotalsRowFn(null); + }, [children, root]); + return null; +} +LineItemsTotalsRow.displayName = "LineItems.TotalsRow"; + +/* ======================================================================== */ +/* Floating dock (dirty + selection bars) */ +/* ======================================================================== */ + +/* Shared visual primitives for the floating bars. Inline styles reference + theme CSS variables directly so they don't depend on Tailwind utility + generation (some `astw:bg-foreground`-style classes aren't always emitted + when not used elsewhere in scanned source). */ + +const FLOATING_DOCK_KEYFRAMES = ` +@keyframes line-items-floating-jiggle { + 0%, 100% { transform: translateX(0); } + 15% { transform: translateX(-8px); } + 30% { transform: translateX(8px); } + 45% { transform: translateX(-6px); } + 60% { transform: translateX(6px); } + 75% { transform: translateX(-3px); } + 90% { transform: translateX(3px); } +} +`; + +const dockStyle: React.CSSProperties = { + position: "fixed", + bottom: "20px", + left: "50%", + transform: "translateX(-50%)", + zIndex: 60, + pointerEvents: "none", + display: "flex", + flexDirection: "column", + alignItems: "center", + gap: "8px", +}; + +const pillStyle: React.CSSProperties = { + pointerEvents: "auto", + display: "flex", + alignItems: "center", + gap: "12px", + padding: "12px 20px", + borderRadius: "16px", + backgroundColor: "var(--foreground)", + boxShadow: + "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", +}; + +const labelStyle: React.CSSProperties = { + color: "var(--background)", + fontSize: "14px", + fontWeight: 500, + whiteSpace: "nowrap", +}; + +const dividerStyle: React.CSSProperties = { + width: "1px", + height: "24px", + backgroundColor: "var(--muted-foreground)", + opacity: 0.4, +}; + +const primaryButtonStyle: React.CSSProperties = { + backgroundColor: "var(--background)", + color: "var(--foreground)", + padding: "6px 12px", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 500, + border: "none", + cursor: "pointer", +}; + +const secondaryButtonStyle: React.CSSProperties = { + background: "transparent", + color: "var(--muted-foreground)", + padding: "6px 12px", + borderRadius: "6px", + fontSize: "14px", + fontWeight: 500, + border: "none", + cursor: "pointer", +}; + +/** + * Fixed bottom-center container that hosts auto-mounting floating bars + * (``, ``). Stacks children + * vertically. The dock itself is `pointer-events: none` so the surrounding + * area stays click-through; each bar inside flips back to `auto`. + * + * Place inside `` after the table: + * + * ```tsx + * + * + * + * + * {({bulkRemove}) => } + * + * + * ``` + */ +export type LineItemsFloatingDockProps = { + children: React.ReactNode; + className?: string; +}; + +export function LineItemsFloatingDock({ children }: LineItemsFloatingDockProps) { + return ( +
+ + {children} +
+ ); +} +LineItemsFloatingDock.displayName = "LineItems.FloatingDock"; + +/** + * Dirty-state bar that auto-shows when the hook has unsaved changes and + * auto-hides when clean. Discard defaults to `hook.revert()` so the previous + * state is restored. Place inside ``. + * + * `warnOnNav`: when true, captures anchor clicks anywhere on the page and + * cancels them while dirty (and re-fires the jiggle animation), and adds a + * native `beforeunload` prompt for browser-level navigation. Off by default. + */ +export type LineItemsDirtyBarProps = { + /** Called when the user clicks Save. Typically reads `hook.getChangeSet()` and submits. */ + onSave: () => void | Promise; + /** Override the Discard handler. Defaults to `hook.revert()`. */ + onDiscard?: () => void; + /** Status copy on the left of the pill. Default `"Unsaved changes"`. */ + label?: React.ReactNode; + saveLabel?: React.ReactNode; + discardLabel?: React.ReactNode; + /** + * When true, intercepts in-app anchor clicks and the browser's `beforeunload` + * event so the user can't leave the page without saving / discarding — + * and re-fires a jiggle animation on the bar to draw attention. + */ + warnOnNav?: boolean; +}; + +export function LineItemsDirtyBar({ + onSave, + onDiscard, + label = "Unsaved changes", + saveLabel = "Save", + discardLabel = "Discard", + warnOnNav = false, +}: LineItemsDirtyBarProps) { + const { hook } = useLineItemsRoot(); + const isDirty = hook.isDirty; + const handleDiscard = onDiscard ?? (() => hook.revert()); + + // Re-key the pill on each nav-block to restart the jiggle animation. + const [jiggleNonce, setJiggleNonce] = React.useState(0); + const isDirtyRef = React.useRef(isDirty); + React.useEffect(() => { + isDirtyRef.current = isDirty; + }, [isDirty]); + + React.useEffect(() => { + if (!warnOnNav) return undefined; + const triggerJiggle = () => setJiggleNonce((n) => n + 1); + + const onDocumentClick = (e: MouseEvent) => { + if (!isDirtyRef.current) return; + if (e.defaultPrevented) return; + const target = e.target as Element | null; + if (!target) return; + // Don't block clicks inside our own card or the floating bar itself. + if ( + target.closest('[data-slot="line-items-floating-dock"]') || + target.closest('[data-slot="card"]') + ) { + return; + } + const anchor = target.closest("a[href]") as HTMLAnchorElement | null; + if (!anchor) return; + const href = anchor.getAttribute("href") ?? ""; + if (!href || href.startsWith("#")) return; + if (anchor.target && anchor.target !== "" && anchor.target !== "_self") return; + e.preventDefault(); + e.stopPropagation(); + triggerJiggle(); + }; + const onBeforeUnload = (e: BeforeUnloadEvent) => { + if (!isDirtyRef.current) return; + e.preventDefault(); + e.returnValue = ""; + triggerJiggle(); + }; + + document.addEventListener("click", onDocumentClick, { capture: true }); + window.addEventListener("beforeunload", onBeforeUnload); + return () => { + document.removeEventListener("click", onDocumentClick, { capture: true }); + window.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [warnOnNav]); + + if (!isDirty) return null; + + const animatedPill: React.CSSProperties = { + ...pillStyle, + animation: + jiggleNonce > 0 + ? "line-items-floating-jiggle 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97)" + : undefined, + }; + + return ( +
+ {label} + + + +
+ ); +} +LineItemsDirtyBar.displayName = "LineItems.DirtyBar"; + +/** + * Selection bar — render-prop that auto-shows when the hook has rows selected + * and auto-hides when empty. Renders the dark pill chrome (count label + + * divider) and lets the host plug in domain-specific actions (Delete, Export + * PDF, Update price, etc.). Place inside ``. + * + * @example + * ```tsx + * > + * {({ selectedIds, bulkRemove, clear }) => ( + * <> + * + * + * + * )} + *
+ * ``` + */ +export type LineItemsSelectionBarRenderArgs = { + selectedIds: string[]; + bulkUpdate: (patch: Partial) => void; + bulkRemove: () => void; + clear: () => void; +}; + +export type LineItemsSelectionBarProps = { + /** Render-prop receiving the hook's bulk-action callbacks. */ + children: (args: LineItemsSelectionBarRenderArgs) => React.ReactNode; + /** Status copy on the left of the pill. Defaults to `"{N} selected"`. */ + label?: (selectedIds: string[]) => React.ReactNode; +}; + +export function LineItemsSelectionBar({ + children, + label, +}: LineItemsSelectionBarProps) { + const { hook } = useLineItemsRoot(); + if (hook.selectedIds.length === 0) return null; + + const labelText = label + ? label(hook.selectedIds) + : `${hook.selectedIds.length} selected`; + + return ( +
+ {labelText} + + {children({ + selectedIds: hook.selectedIds, + bulkUpdate: hook.bulkUpdate, + bulkRemove: hook.bulkRemove, + clear: hook.clearSelection, + })} +
+ ); +} +(LineItemsSelectionBar as unknown as { displayName: string }).displayName = + "LineItems.SelectionBar"; + +/** + * Re-exported style helpers so consumers can build buttons that visually + * match the dirty-bar's primary / secondary buttons inside their own + * `` render-prop without re-discovering the styles. + */ +export const lineItemsFloatingBarStyles = { + primaryButton: primaryButtonStyle, + secondaryButton: secondaryButtonStyle, + divider: dividerStyle, + label: labelStyle, +} as const; diff --git a/packages/core/src/components/line-items/line-items-root.tsx b/packages/core/src/components/line-items/line-items-root.tsx index bd83346a..34135e30 100644 --- a/packages/core/src/components/line-items/line-items-root.tsx +++ b/packages/core/src/components/line-items/line-items-root.tsx @@ -8,10 +8,18 @@ import type { LineItemsRowData, UseLineItemsReturn } from "./types"; /* Root context (shared between LineItems.* compound parts) */ /* ======================================================================== */ +/** Optional render-fn for a totals row. Returns one value per column key. */ +export type LineItemsTotalsRowFn = ( + lines: T[], +) => Record; + export type LineItemsRootContextValue = { hook: UseLineItemsReturn; fullscreen: boolean; setFullscreen: React.Dispatch>; + /** Set by `` when present; consumed by ``. */ + totalsRowFn: LineItemsTotalsRowFn | null; + setTotalsRowFn: React.Dispatch | null>>; }; const LineItemsRootContext = @@ -45,6 +53,8 @@ export function LineItemsRoot({ children, }: LineItemsRootProps) { const [fullscreen, setFullscreen] = React.useState(false); + const [totalsRowFn, setTotalsRowFn] = + React.useState | null>(null); React.useEffect(() => { if (!fullscreen) return undefined; @@ -59,8 +69,8 @@ export function LineItemsRoot({ }, [fullscreen]); const ctx = React.useMemo>( - () => ({ hook: value, fullscreen, setFullscreen }), - [value, fullscreen], + () => ({ hook: value, fullscreen, setFullscreen, totalsRowFn, setTotalsRowFn }), + [value, fullscreen, totalsRowFn], ); // Click on the backdrop (the wrapper itself, not a child) closes fullscreen. diff --git a/packages/core/src/components/line-items/line-items-table.test.tsx b/packages/core/src/components/line-items/line-items-table.test.tsx new file mode 100644 index 00000000..341167e8 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-table.test.tsx @@ -0,0 +1,103 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { LineItems } from "./LineItems"; +import { createLineItemHelper } from "./field"; +import { useLineItems } from "./use-line-items"; +import type { LineItemsField, LineItemsRowData } from "./types"; + +afterEach(() => { + cleanup(); +}); + +type Row = LineItemsRowData & { sku: string; qty: number; total: number }; + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ key: "sku", label: "SKU", render: (l) => l.sku, width: 120, pinned: "left" }), + f.field({ + key: "qty", + label: "Qty", + render: (l) => l.qty, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 100, + }), + f.field({ + key: "total", + label: "Total", + render: (l) => l.total, + width: 140, + }), +]; + +const data: Row[] = [ + { lineRef: "a", sku: "X", qty: 2, total: 20 }, + { lineRef: "b", sku: "Y", qty: 5, total: 50 }, +]; + +function Harness({ + withTotals = false, + withRowActions = false, +}: { + withTotals?: boolean; + withRowActions?: boolean; +}) { + const lineItems = useLineItems({ fields, data }); + return ( + + + : undefined + } + /> + {withTotals ? ( + > + {(lines) => ({ + qty: lines.reduce((s, l) => s + l.qty, 0), + total: lines.reduce((s, l) => s + l.total, 0), + })} + + ) : null} +
+ ); +} + +describe("LineItems.Table", () => { + it("applies sticky-left positioning to a field with pinned: 'left'", () => { + render(); + // Find the SKU header cell and confirm it has position: sticky on the left. + const skuHeader = screen.getByText("SKU").closest("th"); + expect(skuHeader).toBeTruthy(); + expect(skuHeader!.style.position).toBe("sticky"); + expect(skuHeader!.style.left).toBe("0px"); + // The next non-pinned column ("Qty") should NOT be sticky. + const qtyHeader = screen.getByText("Qty").closest("th"); + expect(qtyHeader!.style.position).toBe(""); + }); + + it("renders the TotalsRow with values aligned to the column keys", () => { + render(); + // Sum of qty = 7, sum of total = 70. + expect(screen.getByText("7")).toBeTruthy(); + expect(screen.getByText("70")).toBeTruthy(); + }); + + it("registers a trailing pinned-right column when rowActions prop is set", () => { + const { container } = render(); + // 3 field columns (SKU + Qty + Total) + 1 actions + 1 trailing spacer = 5 ths. + const ths = container.querySelectorAll("thead th"); + expect(ths.length).toBe(5); + // Second-to-last is the actions column (last is the unsized spacer). + const actions = ths[ths.length - 2] as HTMLElement; + expect(actions.style.position).toBe("sticky"); + expect(actions.style.right).toBe("0px"); + // Trailing spacer should NOT be pinned (so it can absorb leftover space). + const spacer = ths[ths.length - 1] as HTMLElement; + expect(spacer.getAttribute("data-slot")).toBe("line-items-spacer-th"); + }); +}); diff --git a/packages/core/src/components/line-items/line-items-table.tsx b/packages/core/src/components/line-items/line-items-table.tsx index 4f14af1e..5aae76d2 100644 --- a/packages/core/src/components/line-items/line-items-table.tsx +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -44,7 +44,7 @@ const alignClass: Record = { right: "astw:text-right astw:tabular-nums", }; -export type LineItemsTableProps = { +export type LineItemsTableProps = { /** Max body height before vertical scroll kicks in. Defaults to `min(60vh, 480px)`. */ maxBodyHeight?: React.CSSProperties["maxHeight"]; className?: string; @@ -55,9 +55,18 @@ export type LineItemsTableProps = { enableDragReorder?: boolean; /** Empty-state copy when there are no rows. */ emptyMessage?: React.ReactNode; + /** + * Optional render-prop for a trailing per-row actions column (delete, view, + * attach, etc.). The cell is automatically pinned to the right edge so the + * actions stay visible during horizontal scroll. The actions cell is NOT + * part of the spreadsheet selection grid (no fill, no copy/paste). + */ + rowActions?: (line: T) => React.ReactNode; + /** Width in px of the row-actions trailing column. Default `80`. */ + rowActionsWidth?: number; }; -export function LineItemsTable(props: LineItemsTableProps) { +export function LineItemsTable(props: LineItemsTableProps) { const { maxBodyHeight = "min(60vh, 480px)", className, @@ -65,10 +74,16 @@ export function LineItemsTable(props: LineItemsTable renderFullscreenToggle = true, enableDragReorder = false, emptyMessage = "No lines yet.", + rowActions, + /** + * Default 64 — fits two `size-7` icon buttons + a small horizontal gap + * snugly. Bump for more icons or wider buttons; lower for one icon. + */ + rowActionsWidth = 64, } = props; const root = useLineItemsRoot(); - const { hook, fullscreen } = root; + const { hook, fullscreen, totalsRowFn } = root; const { fields, mode, ordering } = hook; const internals = getInternals(hook); @@ -119,6 +134,7 @@ export function LineItemsTable(props: LineItemsTable const allSelected = live.lines.length > 0 && live.selectedIds.length === live.lines.length; return ( +
(props: LineItemsTable }} className="astw:size-4" /> +
); }, cell: ({ row }) => ( @@ -204,8 +221,21 @@ export function LineItemsTable(props: LineItemsTable }); } + if (rowActions) { + cols.push({ + id: "__actions", + header: "", + cell: ({ row }) => ( +
+ {rowActions(row.original)} +
+ ), + size: rowActionsWidth, + }); + } + return cols; - }, [enableDragReorder, fields, mode, ordering, selectionEnabled]); + }, [enableDragReorder, fields, mode, ordering, selectionEnabled, rowActions, rowActionsWidth]); const table = useReactTable({ data, @@ -228,28 +258,6 @@ export function LineItemsTable(props: LineItemsTable const orderedLineRefs = React.useMemo(() => allRows.map((r) => r.original.lineRef), [allRows]); - /* ---- Per-column resting / hover-expand width ------------------------ */ - - const getColumnWidthStyle = React.useCallback( - (colId: string): React.CSSProperties | undefined => { - const f = fieldByKey.get(colId); - if (!f) return undefined; - const expand = f.hoverExpandWidth; - const rest = f.width; - if (expand == null && rest == null) return undefined; - const target = expand != null && hoveredColumnId === colId ? expand : rest; - const style: React.CSSProperties = { - transition: "width 220ms ease-out, min-width 220ms ease-out", - }; - if (target != null) { - style.width = target; - style.minWidth = target; - } - return style; - }, - [fieldByKey, hoveredColumnId], - ); - const onColumnHoverEnter = React.useCallback( (colId: string) => { const f = fieldByKey.get(colId); @@ -262,6 +270,78 @@ export function LineItemsTable(props: LineItemsTable setHoveredColumnId((cur) => (cur === colId ? null : cur)); }, []); + /* ---- Special-column widths -------------------------------------------- + Pinned widths for the bookkeeping columns we add ourselves + (__select / __drag / __actions). Combined with `table-fixed` and the + `` rendering below, every consumer of the component gets the + identical first-column look — without these, browser auto-layout + redistributed leftover horizontal space per page so the same `width: 40` + rendered at slightly different sizes across demos. */ + const SELECT_COL_WIDTH = 40; + const DRAG_COL_WIDTH = 28; + + /* ---- Pinned columns (left + right offsets) -------------------------- */ + + /** + * Compute the pixel offset for each pinned column. Order: + * - Left side: __select (if visible, 36px) → __drag (28px) → + * fields with `pinned: "left"` in declared order. + * - Right side: fields with `pinned: "right"` in declared order, with the + * inserted "__actions" column (when `rowActions` is set) pinned right + * after them. + * + * Pinned data fields require a `width` so we know the offset of the next + * pinned column. Unpinned fields are unaffected. + */ + const pinOffsets = React.useMemo(() => { + const offsets = new Map(); + const selectVisible = selectionEnabled && mode !== "display"; + const dragVisible = enableDragReorder && ordering === "manual" && mode !== "display"; + + let leftAcc = 0; + if (selectVisible) { + offsets.set("__select", { side: "left", offset: leftAcc }); + leftAcc += SELECT_COL_WIDTH; + } + if (dragVisible) { + offsets.set("__drag", { side: "left", offset: leftAcc }); + leftAcc += DRAG_COL_WIDTH; + } + for (const f of fields) { + if (f.pinned === "left") { + offsets.set(f.key, { side: "left", offset: leftAcc }); + leftAcc += f.width ?? 0; + } + } + + let rightAcc = 0; + // Walk right-to-left so the rightmost pinned column has offset 0. + if (rowActions) { + offsets.set("__actions", { side: "right", offset: rightAcc }); + rightAcc += rowActionsWidth; + } + const rightFields = fields.filter((f) => f.pinned === "right").toReversed(); + for (const f of rightFields) { + offsets.set(f.key, { side: "right", offset: rightAcc }); + rightAcc += f.width ?? 0; + } + return offsets; + }, [enableDragReorder, fields, mode, ordering, rowActions, rowActionsWidth, selectionEnabled]); + + const getPinStyle = React.useCallback( + (colId: string): React.CSSProperties | undefined => { + const pin = pinOffsets.get(colId); + if (!pin) return undefined; + return { + position: "sticky", + [pin.side]: pin.offset, + zIndex: 5, + backgroundColor: "var(--card)", + } as React.CSSProperties; + }, + [pinOffsets], + ); + const ssFocusRef = React.useRef(null); React.useEffect(() => { ssFocusRef.current = ssFocus; @@ -480,23 +560,34 @@ export function LineItemsTable(props: LineItemsTable if (mode === "display") return; if (fillGestureSource) return; if (e.button !== 0) return; - const tgt = e.target as HTMLElement | null; - const insideInteractive = !!tgt?.closest?.( - "input,textarea,button,select,[contenteditable=true]", - ); + // Shift-click: extend the existing rectangle to the clicked cell. + // Anchor stays where it was (collapses to current focus on first shift). if (e.shiftKey) { setSsAnchor((a) => a ?? ssFocusRef.current ?? coord); setSsFocus(coord); return; } - if (insideInteractive) return; - + // Normal click: collapse the selection to a single cell. We do this + // unconditionally — even when the click lands on an inside the + // cell — because `onCellFocused` no longer resets the anchor after a + // shift-click (that fix lives in onCellFocused), so we need to do the + // collapse here. setSsAnchor(coord); setSsFocus(coord); - setSsPointerDragActive(true); - scrollParentRef.current?.focus(); + + // Activate drag-select only when the click started on the cell shell + // itself (not inside an input/button) — otherwise we'd fight the input's + // native text-selection drag. + const tgt = e.target as HTMLElement | null; + const insideInteractive = !!tgt?.closest?.( + "input,textarea,button,select,[contenteditable=true]", + ); + if (!insideInteractive) { + setSsPointerDragActive(true); + scrollParentRef.current?.focus(); + } }, [fillGestureSource, mode], ); @@ -506,8 +597,15 @@ export function LineItemsTable(props: LineItemsTable if (mode === "display") return; const cur = ssFocusRef.current; if (cur && sameCoord(cur, coord)) return; - setSsAnchor(coord); + // Only update FOCUS here; the anchor is owned by `onCellPointerDown` + // (which preserves it on shift-click) and the keyboard nav handlers + // (which set both anchor + focus together). Overwriting the anchor on + // every focus event would collapse a shift-click range as soon as the + // newly-focused input fired its native focus event. setSsFocus(coord); + // Initialize anchor if it was previously null (e.g. first focus on the + // table without going through pointerdown). + setSsAnchor((a) => a ?? coord); }, [mode], ); @@ -688,9 +786,46 @@ export function LineItemsTable(props: LineItemsTable /* ---- Render --------------------------------------------------------- */ - const tableWidthClass = "astw:w-full astw:caption-bottom astw:text-sm"; + // `table-fixed` is critical: with `auto` layout the browser distributes any + // leftover horizontal space across columns based on content, which makes the + // 40px checkbox column drift to slightly different widths per page (the + // user reported this). `fixed` honors the explicit per-column widths we set + // via `` below, plus the inline width styles on every . + const tableWidthClass = "astw:w-full astw:table-fixed astw:caption-bottom astw:text-sm"; + + /* + Compute a `min-width` for the table so the flex column never collapses at + narrow viewports. We sum every column's declared width (including the flex + column's `width` or a `240` fallback). At wide viewports the table is at + container width (so the flex column absorbs leftover); at narrow viewports + the table grows beyond the container and horizontal scroll absorbs the + overflow — instead of squeezing the flex column to nothing. + */ + const FLEX_FALLBACK_WIDTH = 240; + const tableMinWidth = React.useMemo(() => { + let total = 0; + if (selectionEnabled && mode !== "display") total += SELECT_COL_WIDTH; + if (enableDragReorder && ordering === "manual" && mode !== "display") + total += DRAG_COL_WIDTH; + for (const f of fields) { + if (f.flex) { + total += f.width ?? FLEX_FALLBACK_WIDTH; + } else { + total += f.width ?? FLEX_FALLBACK_WIDTH; + } + } + if (rowActions) total += rowActionsWidth; + return total; + }, [enableDragReorder, fields, mode, ordering, rowActions, rowActionsWidth, selectionEnabled]); const vItems = rowVirtualizer.getVirtualItems(); - const colCount = Math.max(1, table.getVisibleLeafColumns().length); + // True when no field absorbs leftover horizontal space; in that case the + // table renders a trailing spacer `` + matching cells. Same condition + // is recomputed in the colgroup IIFE above — keep them in sync. + const renderTrailingSpacer = !fields.some((f) => f.flex || f.width == null); + const colCount = Math.max( + 1, + table.getVisibleLeafColumns().length + (renderTrailingSpacer ? 1 : 0), + ); const padTop = vItems.length ? vItems[0]!.start : 0; const padBot = vItems.length ? rowVirtualizer.getTotalSize() - vItems[vItems.length - 1]!.end : 0; @@ -757,25 +892,122 @@ export function LineItemsTable(props: LineItemsTable {emptyMessage} ) : ( - +
+ {/* + `` pins each column to the width we want. This is the + most authoritative way to set column widths in HTML tables — the + browser respects `` widths over per-cell widths, and + combined with `table-fixed` above guarantees the special + columns (checkbox / drag / actions) render at the same pixel + width on every page. + + Field columns without an explicit `width` get an unset `` + so they share remaining space proportionally. + */} + {(() => { + /* + Determine which data column absorbs leftover horizontal space. + Priority: + 1. A field with `flex: true` (explicit opt-in). + 2. Any field without an explicit `width` (legacy behavior). + 3. None — render a trailing spacer column that absorbs + leftover so every real column stays at its declared + width pixel-exact across pages. + + When a column is hover-expanded, the flex column is FROZEN + at its declared `width` (or `FLEX_FALLBACK_WIDTH` if none was + declared). That way the table grows beyond the container and + horizontal scroll absorbs the slack — instead of squeezing + the flex column to nothing on small viewports. + */ + const explicitFlex = fields.find((f) => f.flex); + const hasFlexField = + !!explicitFlex || fields.some((f) => f.width == null && !f.flex); + const flexFieldKey = explicitFlex?.key ?? null; + const isHovering = hoveredColumnId != null; + const flexFrozen = isHovering; + return ( + + {table.getVisibleLeafColumns().map((col) => { + const colId = col.id; + // Special bookkeeping columns get pinned widths. + if (colId === "__select") + return ( + + ); + if (colId === "__drag") + return ; + if (colId === "__actions") + return ( + + ); + const f = fieldByKey.get(colId); + if (!f) return ; + // Flex column: unsized normally; frozen to its declared + // width during hover-expand of any other column so the + // table grows rather than the flex column shrinking. + if (flexFieldKey === colId) { + if (flexFrozen) { + const frozen = f.width ?? FLEX_FALLBACK_WIDTH; + return ( + + ); + } + return ; + } + // Hovered column gets hoverExpandWidth; otherwise the + // declared width (unsized if absent). + const isHovered = hoveredColumnId === colId; + const target = + isHovered && f.hoverExpandWidth != null ? f.hoverExpandWidth : f.width; + if (target == null) return ; + return ( + + ); + })} + {/* + Spacer is only needed when no field is meant to absorb + leftover horizontal space. Without it, browsers + redistribute the leftover inconsistently per page when + every column has an explicit width. + */} + {!hasFlexField ? : null} + + ); + })()} {table.getHeaderGroups().map((hg) => ( {hg.headers.map((header: Header) => { const colId = header.column.columnDef.id ?? header.column.id; - const widthStyle = getColumnWidthStyle(colId); + const pinStyle = getPinStyle(colId); return ( ); })} + {/* Spacer header (matches the trailing above). */} + {renderTrailingSpacer ? ( + ))} @@ -820,12 +1060,12 @@ export function LineItemsTable(props: LineItemsTable > {row.getVisibleCells().map((cell) => { const colId = cell.column.id; - const widthStyle = getColumnWidthStyle(colId); + const pinStyle = getPinStyle(colId); return ( onColumnHoverEnter(colId)} onMouseLeave={() => onColumnHoverLeave(colId)} > @@ -840,6 +1080,10 @@ export function LineItemsTable(props: LineItemsTable ); })} + {/* Trailing spacer cell — matches the trailing in colgroup. */} + {renderTrailingSpacer ? ( + ); })} @@ -849,6 +1093,43 @@ export function LineItemsTable(props: LineItemsTable ) : null} + {totalsRowFn ? ( + + + {table.getVisibleLeafColumns().map((col) => { + const colId = col.id; + const pinStyle = getPinStyle(colId); + const totalsMap = totalsRowFn(hook.allLines); + const value = totalsMap[colId]; + const f = fieldByKey.get(colId); + const align = f ? alignClass[f.align ?? "left"] : "astw:text-left"; + return ( + + ); + })} + {/* Trailing spacer matches the colgroup spacer. */} + {renderTrailingSpacer ? ( + + + ) : null}
onColumnHoverEnter(colId)} onMouseLeave={() => onColumnHoverLeave(colId)} > @@ -783,6 +1015,14 @@ export function LineItemsTable(props: LineItemsTable
+ ) : null}
+ ) : null}
+ {value ?? null} + + ) : null} +
)} diff --git a/packages/core/src/components/line-items/types.ts b/packages/core/src/components/line-items/types.ts index fbec0ea7..9e60fba8 100644 --- a/packages/core/src/components/line-items/types.ts +++ b/packages/core/src/components/line-items/types.ts @@ -5,28 +5,30 @@ import * as React from "react"; /* ======================================================================== */ /** Discriminated line change operations (transport-agnostic; apps map to GraphQL/REST). */ -export type LineItemsLineChangeAction = "add" | "update" | "remove" | "move"; +export type LineItemsLineChangeAction = "add" | "update" | "remove" | "reorder"; export type LineItemsRowPatch = Record; +/** + * One typed line-change op produced by `getChangeSet()`. Shape matches the + * platform PRD ("Generalized Line-Item Component"): + * + * - `add` → `{ tempId, data }` — `tempId` is a client-only id; the server + * should mint the real id on insert. + * - `update` → `{ lineId, patch }` — only changed fields appear in `patch`. + * - `remove` → `{ lineId }`. + * - `reorder` → `{ lineId, position }` — zero-based final index in the document + * order (manual ordering only). + */ export type LineItemsLineChange = - | { - action: "add"; - lineRef: string; - /** `null` inserts at the start of the document order. */ - insertAfterLineRef: string | null; - patch: LineItemsRowPatch; - } - | { action: "update"; lineRef: string; patch: LineItemsRowPatch } - | { action: "remove"; lineRef: string } - | { - action: "move"; - lineRef: string; - /** `null` moves to the front. */ - afterLineRef: string | null; - }; + | { action: "add"; tempId: string; data: LineItemsRowPatch } + | { action: "update"; lineId: string; patch: LineItemsRowPatch } + | { action: "remove"; lineId: string } + | { action: "reorder"; lineId: string; position: number }; +/** Result of `useLineItems().getChangeSet()`. `isEmpty` short-circuits no-op submits. */ export type LineItemsChangeSet = { + isEmpty: boolean; lineChanges: LineItemsLineChange[]; }; @@ -56,17 +58,48 @@ export type LineItemsSelectOption = { description?: string; }; +/** + * Render context handed to a `kind: "custom"` field's `renderEditor`. Mirrors + * the contract every other editor cell follows so the table's keyboard + + * commit + dirty-tracking wiring keeps working. + */ +export type LineItemsCustomEditorContext = { + /** Current cell value (already through `normalize` for the field if provided). */ + value: unknown; + /** Commit a new value back into the line. */ + onCommit: (next: unknown) => void; + /** Cancel the in-flight edit (e.g. Escape). The hook keeps the previous value. */ + onCancel: () => void; + /** The full current row (use for cross-field display). */ + row: T; + /** Active mode at the time of render. */ + mode: LineItemsMode; + /** The field schema entry, including its `type`. */ + field: LineItemsField; +}; + /** * Drives input type, formatting, normalization, and equality for an editable field. * Required when the field is editable; omit for read-only / computed fields. */ -export type LineItemsFieldType = +export type LineItemsFieldType = | { kind: "text" } | { kind: "number"; decimals?: number } | { kind: "select"; options: ReadonlyArray; placeholder?: string; + } + | { kind: "boolean"; trueLabel?: string; falseLabel?: string } + | { kind: "date"; min?: string; max?: string } + | { + kind: "custom"; + /** Renders the in-cell editor. The component handles selection ring + grip; this controls only the editor. */ + renderEditor: (ctx: LineItemsCustomEditorContext) => React.ReactNode; + /** Optional value coercion before equality. Mirrors `LineItemsField.normalize`. */ + normalize?: (value: unknown, row: T) => unknown; + /** Optional dirty-equality. Mirrors `LineItemsField.equals`. */ + equals?: (a: unknown, b: unknown, row: T) => boolean; }; /** @@ -102,7 +135,7 @@ export type LineItemsField = { */ editable?: LineItemsMode[]; /** Required when the field is editable; drives input type and equality semantics. */ - type?: LineItemsFieldType; + type?: LineItemsFieldType; /** "document" (default) bundles into the change-set; "metadata" emits per-cell deltas. */ commit?: LineItemsFieldCommit; /** Adds a sort affordance to the column header; called when the user clicks it. */ @@ -111,6 +144,19 @@ export type LineItemsField = { search?: (line: T, query: string) => boolean; align?: LineItemsColumnAlign; className?: string | ((line: T) => string | undefined); + /** + * Coerce raw values before dirty-equality compare and before they're emitted + * into the change-set. Defaults to trim-strings; numeric fields with + * `decimals` get rounding applied automatically. Override for app-specific + * rules (e.g. money rounding by currency, deep-clone for nested objects). + */ + normalize?: (value: unknown, row: T) => unknown; + /** + * Custom dirty-equality. Defaults to `Object.is` with a 1e-9 epsilon for + * numbers. Override for app-specific rules (e.g. compare two attribute + * objects by `id`, money equality across currencies, deep-equal JSON). + */ + equals?: (a: unknown, b: unknown, row: T) => boolean; /** Resting column width in pixels. Honored when set; otherwise auto-sized. */ width?: number; /** @@ -119,6 +165,28 @@ export type LineItemsField = { * dense columns that show truncated content (e.g. a SKU + product label). */ hoverExpandWidth?: number; + /** + * Pin the column to the left or right edge of the scroll container so it + * stays visible while the user scrolls horizontally. Multiple pinned columns + * stack — their widths are summed to compute the offset. + * + * Pinned columns must declare a `width`; otherwise the offset for subsequent + * pinned columns can't be computed and the layout breaks. + */ + pinned?: "left" | "right"; + /** + * When true, this column absorbs any leftover horizontal space in the table + * — useful for the column with the longest content (e.g. a description or + * product-name column). Implementation: this column's `` is rendered + * without an explicit width, so `table-layout: fixed` routes all leftover + * to it. + * + * If you don't set `flex` on any column AND every column declares an + * explicit `width`, the table renders an invisible trailing spacer column + * to absorb leftover and keep declared widths pixel-exact. Most consumers + * never need to think about this. + */ + flex?: boolean; }; /* ======================================================================== */ diff --git a/packages/core/src/components/line-items/use-line-items-group.test.tsx b/packages/core/src/components/line-items/use-line-items-group.test.tsx new file mode 100644 index 00000000..c89c6b30 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items-group.test.tsx @@ -0,0 +1,96 @@ +import { act, cleanup, renderHook } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; + +import { createLineItemHelper } from "./field"; +import { useLineItems } from "./use-line-items"; +import { useLineItemsGroup } from "./use-line-items-group"; +import type { LineItemsField, LineItemsRowData } from "./types"; + +afterEach(() => { + cleanup(); +}); + +type JLine = LineItemsRowData & { 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 }, + }), +]; + +const seedDebits = (): JLine[] => [{ lineRef: "d1", account: "Cash", amount: 100 }]; +const seedCredits = (): JLine[] => [{ lineRef: "c1", account: "AR", amount: 100 }]; + +describe("useLineItemsGroup", () => { + it("aggregates isDirty across members", () => { + const { result } = renderHook(() => { + const debits = useLineItems({ fields, data: seedDebits() }); + const credits = useLineItems({ fields, data: seedCredits() }); + const group = useLineItemsGroup({ debits, credits }); + return { debits, credits, group }; + }); + + expect(result.current.group.isDirty).toBe(false); + + act(() => { + result.current.debits.updateField("d1", "amount", 200); + }); + expect(result.current.group.isDirty).toBe(true); + }); + + it("getChangeSet returns a keyed bundle with isEmpty rolled up", () => { + const { result } = renderHook(() => { + const debits = useLineItems({ fields, data: seedDebits() }); + const credits = useLineItems({ fields, data: seedCredits() }); + return { debits, credits, group: useLineItemsGroup({ debits, credits }) }; + }); + + let cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(true); + expect(cs.debits.lineChanges).toEqual([]); + expect(cs.credits.lineChanges).toEqual([]); + + act(() => { + result.current.credits.updateField("c1", "amount", 250); + }); + cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(false); + expect(cs.debits.isEmpty).toBe(true); + expect(cs.credits.lineChanges).toEqual([ + { action: "update", lineId: "c1", patch: { amount: 250 } }, + ]); + }); + + it("revert() rolls back every member", () => { + const { result } = renderHook(() => { + const debits = useLineItems({ fields, data: seedDebits() }); + const credits = useLineItems({ fields, data: seedCredits() }); + return { debits, credits, group: useLineItemsGroup({ debits, credits }) }; + }); + + 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); + }); +}); 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 00000000..4b0b47e2 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items-group.ts @@ -0,0 +1,109 @@ +import * as React from "react"; + +import type { LineItemsChangeSet, UseLineItemsReturn } from "./types"; + +/* ======================================================================== */ +/* Group helper for documents with multiple line collections under one header */ +/* ======================================================================== */ + +/** + * Map of `useLineItems` return values keyed by collection name. Each value is + * the full hook return — we type-erase the row generic via `any` so consumers + * can mix collections that have different row shapes (e.g. component lines vs + * operation lines under one Work Order header). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- intentional: heterogeneous row types per member +export type LineItemsGroupInput = Record>; + +/** Aggregate change-set keyed by the same names as the input map. */ +export type LineItemsGroupChangeSet = { + isEmpty: boolean; +} & { + [K in keyof G]: LineItemsChangeSet; +}; + +export type LineItemsGroupReturn = { + /** Pass-through reference to the input — convenient for forwarding into children. */ + members: G; + /** True when ANY member is dirty. Mirrors the hook's `isDirty` semantics. */ + isDirty: boolean; + /** + * Aggregate change-set keyed by collection name. Each member's `getChangeSet()` + * is called once and bundled. `isEmpty` is true iff every member is empty. + * The page-level submit handler typically reads this and dispatches a single + * mutation per document. + */ + getChangeSet: () => LineItemsGroupChangeSet; + /** Snap baselines forward on every member. Call after a successful save. */ + reset: () => void; + /** Restore every member to its baseline. Call on Discard. */ + revert: () => void; +}; + +/** + * Compose multiple `useLineItems` hooks into a single document-level boundary. + * + * Use this when one header record owns more than one ordered list of lines — + * e.g. a Journal Entry with `debits` + `credits` that must balance, or a Work + * Order with `componentLines` + `operationLines`. Each collection still gets + * its own table + selection + dirty tracking; this helper gives you a shared + * `isDirty`, a keyed change-set, and one Discard / Save boundary. + * + * @example + * ```tsx + * const debits = useLineItems({ ... }); + * const credits = useLineItems({ ... }); + * const group = useLineItemsGroup({ debits, credits }); + * + * const onSave = async () => { + * const cs = group.getChangeSet(); + * if (cs.isEmpty) return; + * await api.updateJournalEntry({ + * id, + * debits: cs.debits.lineChanges, + * credits: cs.credits.lineChanges, + * }); + * group.reset(); + * }; + * + * return ( + * <> + * ... + * ... + * + * + * ); + * ``` + */ +export function useLineItemsGroup(group: G): LineItemsGroupReturn { + // Aggregate `isDirty` is just an OR — derive on every render. No memo needed + // because it's an O(N) boolean fold and the React rules-of-hooks don't allow + // dynamic dep arrays anyway. + const isDirty = Object.values(group).some((m) => m.isDirty); + + const getChangeSet = React.useCallback((): LineItemsGroupChangeSet => { + const out = {} as LineItemsGroupChangeSet; + let allEmpty = true; + for (const key of Object.keys(group) as (keyof G)[]) { + const cs = group[key].getChangeSet(); + (out as Record)[key as string] = cs; + if (!cs.isEmpty) allEmpty = false; + } + out.isEmpty = allEmpty; + return out; + }, [group]); + + const reset = React.useCallback(() => { + for (const m of Object.values(group)) m.reset(); + }, [group]); + + const revert = React.useCallback(() => { + for (const m of Object.values(group)) m.revert(); + }, [group]); + + return { members: group, isDirty, getChangeSet, reset, revert }; +} diff --git a/packages/core/src/components/line-items/use-line-items.test.tsx b/packages/core/src/components/line-items/use-line-items.test.tsx index 6de3e916..3329382b 100644 --- a/packages/core/src/components/line-items/use-line-items.test.tsx +++ b/packages/core/src/components/line-items/use-line-items.test.tsx @@ -75,7 +75,8 @@ describe("useLineItems", () => { expect(result.current.allLines).toHaveLength(3); expect(result.current.isDirty).toBe(true); const cs = result.current.getChangeSet(); - expect(cs.lineChanges.some((c) => c.action === "add" && c.lineRef === newRef)).toBe(true); + expect(cs.lineChanges.some((c) => c.action === "add" && c.tempId === newRef)).toBe(true); + expect(cs.isEmpty).toBe(false); }); it("updateField writes a single cell and shows up as an update in the change set", () => { @@ -85,7 +86,7 @@ describe("useLineItems", () => { }); expect(result.current.isDirty).toBe(true); const cs = result.current.getChangeSet(); - expect(cs.lineChanges).toEqual([{ action: "update", lineRef: "a", patch: { qty: 5 } }]); + expect(cs.lineChanges).toEqual([{ action: "update", lineId: "a", patch: { qty: 5 } }]); }); it("updateLines applies batched patches in one render", () => { @@ -98,8 +99,8 @@ describe("useLineItems", () => { }); const cs = result.current.getChangeSet(); expect(cs.lineChanges).toEqual([ - { action: "update", lineRef: "a", patch: { qty: 7 } }, - { action: "update", lineRef: "b", patch: { qty: 9 } }, + { action: "update", lineId: "a", patch: { qty: 7 } }, + { action: "update", lineId: "b", patch: { qty: 9 } }, ]); }); @@ -109,10 +110,10 @@ describe("useLineItems", () => { result.current.removeLine("a"); }); expect(result.current.allLines).toHaveLength(1); - expect(result.current.getChangeSet().lineChanges).toEqual([{ action: "remove", lineRef: "a" }]); + expect(result.current.getChangeSet().lineChanges).toEqual([{ action: "remove", lineId: "a" }]); }); - it("reorderLine emits a move change in manual ordering", () => { + it("reorderLine emits a reorder change in manual ordering", () => { const { result } = renderHook(() => useLineItems({ fields, data: seed(), ordering: "manual" }), ); @@ -121,7 +122,7 @@ describe("useLineItems", () => { }); expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["b", "a"]); const cs = result.current.getChangeSet(); - expect(cs.lineChanges.some((c) => c.action === "move" && c.lineRef === "a")).toBe(true); + expect(cs.lineChanges.some((c) => c.action === "reorder" && c.lineId === "a")).toBe(true); }); it("reorderLine is a no-op in sort ordering", () => { @@ -260,4 +261,64 @@ describe("useLineItems", () => { expect(last.lineRef).toBe(newRef); expect(last.sku).toBe("Y"); }); + + it("revert() restores baseline data after edits and inserts", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + + // Each mutation in its own act so the hook re-derives between calls; otherwise + // closures over `order` from the same render observe the same stale value. + act(() => { + result.current.updateField("a", "qty", 99); + }); + act(() => { + result.current.addLine({ sku: "Z", qty: 3, unitPrice: 5, note: "" }); + }); + act(() => { + result.current.removeLine("b"); + }); + expect(result.current.isDirty).toBe(true); + expect(result.current.allLines).toHaveLength(2); + + act(() => { + result.current.revert(); + }); + expect(result.current.isDirty).toBe(false); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["a", "b"]); + expect(result.current.allLines.find((l) => l.lineRef === "a")!.qty).toBe(1); + }); + + it("getChangeSet().isEmpty mirrors isDirty", () => { + const { result } = renderHook(() => useLineItems({ fields, data: seed() })); + expect(result.current.getChangeSet().isEmpty).toBe(true); + act(() => { + result.current.updateField("a", "qty", 5); + }); + expect(result.current.getChangeSet().isEmpty).toBe(false); + }); + + it("field-level equals override skips spurious updates", () => { + type AttrLine = LineItemsRowData & { attr: { id: string; label: string } }; + const af = createLineItemHelper(); + const attrFields: LineItemsField[] = [ + af.field({ + key: "attr", + label: "Attribute", + render: (l) => l.attr.label, + editable: ["edit"], + type: { kind: "text" }, + // Two attribute objects compare equal when their `id` matches even if + // labels diverge — typical for app-supplied id-based equality. + equals: (a, b) => + (a as { id: string } | null)?.id === (b as { id: string } | null)?.id, + }), + ]; + const initial: AttrLine[] = [{ lineRef: "a", attr: { id: "x", label: "Old label" } }]; + const { result } = renderHook(() => useLineItems({ fields: attrFields, data: initial })); + act(() => { + // Same id, different label — should not be dirty under custom equals. + result.current.updateField("a", "attr", { id: "x", label: "New label" }); + }); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().lineChanges).toEqual([]); + }); }); diff --git a/packages/core/src/components/sidebar/default-sidebar.test.tsx b/packages/core/src/components/sidebar/default-sidebar.test.tsx index 7b1439e9..774dc4e4 100644 --- a/packages/core/src/components/sidebar/default-sidebar.test.tsx +++ b/packages/core/src/components/sidebar/default-sidebar.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, cleanup, waitFor } from "@testing-library/react"; +import { render, screen, cleanup, waitFor, within } from "@testing-library/react"; import { describe, it, expect, afterEach } from "vitest"; import { MemoryRouter } from "react-router"; import { SidebarProvider } from "@/components/sidebar"; @@ -133,12 +133,10 @@ describe("DefaultSidebar auto-generation", () => { expect(screen.getAllByText("Overview").length).toBeGreaterThan(0); }); - const sidebar = document.querySelector('[data-slot="sidebar"]')!; - const links = sidebar.querySelectorAll("a"); - const overviewLink = Array.from(links).find((link) => link.textContent === "Overview"); - - expect(overviewLink).toBeDefined(); - expect(overviewLink!.className).toContain("astw:bg-sidebar-accent"); + const sidebarEl = document.querySelector('[data-slot="sidebar"]'); + expect(sidebarEl).not.toBeNull(); + const overviewLink = within(sidebarEl as HTMLElement).getByRole("link", { name: "Overview" }); + expect(overviewLink.className).toContain("astw:bg-sidebar-accent"); }); it("excludes componentless resources from sidebar links", async () => { diff --git a/packages/core/src/contexts/theme-context.tsx b/packages/core/src/contexts/theme-context.tsx index 77f62dc6..484f5977 100644 --- a/packages/core/src/contexts/theme-context.tsx +++ b/packages/core/src/contexts/theme-context.tsx @@ -22,15 +22,36 @@ const initialState: ThemeProviderState = { const ThemeProviderContext = createContext(initialState); +function safeGetStoredTheme(storageKey: string): Theme | null { + try { + const ls = globalThis.localStorage; + if (ls && typeof ls.getItem === "function") { + return ls.getItem(storageKey) as Theme; + } + } catch { + /* private mode / restricted environment */ + } + return null; +} + +function safeSetStoredTheme(storageKey: string, value: Theme) { + try { + const ls = globalThis.localStorage; + if (ls && typeof ls.setItem === "function") { + ls.setItem(storageKey, value); + } + } catch { + /* ignore */ + } +} + export function ThemeProvider({ children, storageKey, defaultTheme = "system", ...props }: ThemeProviderProps) { - const [theme, setTheme] = useState( - () => (localStorage.getItem(storageKey) as Theme) || defaultTheme, - ); + const [theme, setTheme] = useState(() => safeGetStoredTheme(storageKey) ?? defaultTheme); const resolvedTheme = useMemo(() => { if (theme !== "system") return theme; @@ -47,7 +68,7 @@ export function ThemeProvider({ resolvedTheme, theme, setTheme: (newTheme: Theme) => { - localStorage.setItem(storageKey, newTheme); + safeSetStoredTheme(storageKey, newTheme); setTheme(newTheme); }, }; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ea518b9c..5aa93281 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -129,17 +129,22 @@ export { export { LineItems, useLineItems, + useLineItemsGroup, createLineItemHelper, type LineItemsAddRowProps, type LineItemsBulkActionsProps, type LineItemsBulkActionsRenderArgs, type LineItemsChangeSet, type LineItemsColumnAlign, + type LineItemsCustomEditorContext, type LineItemsField, type LineItemsFieldCommit, type LineItemsFieldType, type LineItemsSelectOption, type LineItemsFullscreenToggleProps, + type LineItemsGroupChangeSet, + type LineItemsGroupInput, + type LineItemsGroupReturn, type LineItemsLineChange, type LineItemsLineChangeAction, type LineItemsMetadataCommit, @@ -148,10 +153,16 @@ export { type LineItemsRootProps, type LineItemsRowData, type LineItemsRowPatch, + type LineItemsDirtyBarProps, + type LineItemsFloatingDockProps, type LineItemsSaveActionsProps, type LineItemsSearchProps, type LineItemsSearchToggleProps, + type LineItemsSelectionBarProps, + type LineItemsSelectionBarRenderArgs, type LineItemsTableProps, + type LineItemsTotalsRowProps, + lineItemsFloatingBarStyles, type UseLineItemsOptions, type UseLineItemsReturn, } from "./components/line-items"; From 37c93f61008c21ec1e0621f0b1692795005d1e8e Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 6 May 2026 16:57:20 +0530 Subject: [PATCH 3/6] feat(line-items): fullscreen open animation, shift-click fix, broadcast paste - Fullscreen modal slides + fades in on open (backdrop 220ms, card translate-from-below 280ms cubic-bezier). Close is instant. - Shift-click range no longer bleeds OS text-selection across editable cells in between: blur active editor, removeAllRanges, focus grid container. - Single-cell paste now broadcasts to every cell in the active selection (Excel parity), skipping read-only columns. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../line-items-fullscreen-shift-paste.md | 9 ++ .../components/line-items/line-items-root.tsx | 19 +++++ .../line-items/line-items-table.tsx | 83 ++++++++++++++----- 3 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 .changeset/line-items-fullscreen-shift-paste.md diff --git a/.changeset/line-items-fullscreen-shift-paste.md b/.changeset/line-items-fullscreen-shift-paste.md new file mode 100644 index 00000000..2829c174 --- /dev/null +++ b/.changeset/line-items-fullscreen-shift-paste.md @@ -0,0 +1,9 @@ +--- +"@tailor-platform/app-shell": patch +--- + +LineItems: fullscreen open animation + shift-click text-selection fix + single-cell broadcast paste. + +- Fullscreen modal now slides + fades in on open (backdrop fade 220ms, card translate-from-below 280ms with a soft cubic-bezier ease-out). Close is instant. +- Shift-click range selection no longer paints the browser's native text-selection across editable cells between the previous focus and the click point. Handler now blurs any focused editor, calls `removeAllRanges()`, and parks focus on the grid container. +- Paste a single copied cell into a multi-cell selection now broadcasts that value to every selected cell (Excel / Sheets parity), skipping read-only columns with the existing toast. diff --git a/packages/core/src/components/line-items/line-items-root.tsx b/packages/core/src/components/line-items/line-items-root.tsx index 34135e30..ac58a846 100644 --- a/packages/core/src/components/line-items/line-items-root.tsx +++ b/packages/core/src/components/line-items/line-items-root.tsx @@ -88,10 +88,16 @@ export function LineItemsRoot({ } > + {fullscreen ? : null}
({ "astw:fixed astw:inset-0 astw:z-50 astw:overflow-hidden astw:bg-black/80 astw:p-6 astw:backdrop-blur-sm", "astw:[&>[data-slot=card]]:h-full astw:[&>[data-slot=card]]:overflow-hidden", "astw:[&_[data-slot=card-content]]:flex astw:[&_[data-slot=card-content]]:flex-1 astw:[&_[data-slot=card-content]]:min-h-0 astw:[&_[data-slot=card-content]]:flex-col", + // Card slides up from below as the backdrop fades in. + "astw:[&>[data-slot=card]]:[animation:line-items-fullscreen-card-in_280ms_cubic-bezier(0.16,1,0.3,1)]", ], className, )} @@ -111,3 +119,14 @@ export function LineItemsRoot({ ); } LineItemsRoot.displayName = "LineItems.Root"; + +const LINE_ITEMS_FULLSCREEN_KEYFRAMES = ` +@keyframes line-items-fullscreen-in { + from { opacity: 0; } + to { opacity: 1; } +} +@keyframes line-items-fullscreen-card-in { + from { transform: translateY(24px); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +`; diff --git a/packages/core/src/components/line-items/line-items-table.tsx b/packages/core/src/components/line-items/line-items-table.tsx index 5aae76d2..263012a1 100644 --- a/packages/core/src/components/line-items/line-items-table.tsx +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -564,8 +564,30 @@ export function LineItemsTable(props: LineItemsTable // Shift-click: extend the existing rectangle to the clicked cell. // Anchor stays where it was (collapses to current focus on first shift). if (e.shiftKey) { + // Stop the browser from extending its native text selection across + // any editable inputs that happen to sit between the previous focus + // anchor and this click — otherwise the OS highlight paints a + // black/white run through cells the user didn't actually select. + e.preventDefault(); + if (typeof document !== "undefined") { + const ae = document.activeElement as HTMLElement | null; + // Blur any currently-focused editor first; without this the browser + // re-extends its selection from inside that input even after we + // clear ranges. + if ( + ae && + ae !== document.body && + ae.matches?.("input,textarea,select,[contenteditable=true]") + ) { + ae.blur(); + } + } + if (typeof window !== "undefined") { + window.getSelection()?.removeAllRanges(); + } setSsAnchor((a) => a ?? ssFocusRef.current ?? coord); setSsFocus(coord); + scrollParentRef.current?.focus(); return; } @@ -694,39 +716,60 @@ export function LineItemsTable(props: LineItemsTable if (startRi < 0 || startCi < 0) return; let skipped = 0; - const updates: { lineRef: string; patch: Partial }[] = []; const existing = new Map>(); - for (let r = 0; r < gridParsed.length; r++) { - const rowLineRef = orderedLineRefs[startRi + r]; - if (!rowLineRef) break; - const rowObj = hook.allLines.find((row) => row.lineRef === rowLineRef); - if (!rowObj) continue; - const line = gridParsed[r]!; - const rowPatch: Partial = {}; - let rowHas = false; - for (let c = 0; c < line.length; c++) { - const colId = schemaColumnIds[startCi + c]; - if (!colId) break; - const field = fieldByKey.get(colId); + // Single-source broadcast: when the clipboard holds exactly one cell and + // the active selection covers more than one cell, fan the single value + // out to every selected cell (Excel / Sheets behaviour). + const isSingleSource = + gridParsed.length === 1 && (gridParsed[0]?.length ?? 0) === 1; + if (isSingleSource && selectionCoordsMemo.length > 1) { + const raw = gridParsed[0]![0]!; + for (const { lineRef, columnId } of selectionCoordsMemo) { + const field = fieldByKey.get(columnId); if (!field || !fieldAllowsPaste(field, mode)) { skipped++; continue; } - const raw = line[c]; const parsed = coerceForField(field, raw); - (rowPatch as Record)[field.key] = parsed as never; - rowHas = true; + const prev = existing.get(lineRef) ?? {}; + (prev as Record)[field.key] = parsed as never; + existing.set(lineRef, prev); + } + } else { + for (let r = 0; r < gridParsed.length; r++) { + const rowLineRef = orderedLineRefs[startRi + r]; + if (!rowLineRef) break; + const rowObj = hook.allLines.find((row) => row.lineRef === rowLineRef); + if (!rowObj) continue; + const line = gridParsed[r]!; + const rowPatch: Partial = {}; + let rowHas = false; + for (let c = 0; c < line.length; c++) { + const colId = schemaColumnIds[startCi + c]; + if (!colId) break; + const field = fieldByKey.get(colId); + if (!field || !fieldAllowsPaste(field, mode)) { + skipped++; + continue; + } + const raw = line[c]; + const parsed = coerceForField(field, raw); + (rowPatch as Record)[field.key] = parsed as never; + rowHas = true; + } + if (!rowHas) continue; + const prev = existing.get(rowLineRef) ?? {}; + existing.set(rowLineRef, { ...prev, ...rowPatch }); } - if (!rowHas) continue; - const prev = existing.get(rowLineRef) ?? {}; - existing.set(rowLineRef, { ...prev, ...rowPatch }); } + + const updates: { lineRef: string; patch: Partial }[] = []; for (const [lineRef, patch] of existing.entries()) updates.push({ lineRef, patch }); if (updates.length) hook.updateLines(updates); if (skipped) toast.info("Some cells were skipped (read-only columns)."); }, - [mode, ssFocus, orderedLineRefs, schemaColumnIds, fieldByKey, hook], + [mode, ssFocus, orderedLineRefs, schemaColumnIds, fieldByKey, hook, selectionCoordsMemo], ); /* ---- Drag-reorder (drop on row / drop on grid background) ----------- */ From 4ba4da722c495f261ee1eb4fb7a621c69c448253 Mon Sep 17 00:00:00 2001 From: itsprade Date: Wed, 6 May 2026 19:23:33 +0530 Subject: [PATCH 4/6] feat(line-items): bulk picker, batch insert, skeleton loader, perf fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New surfaces: - BulkItemPicker (top-level) — generic tree-select dialog with tri-state checkboxes, search, auto-expand, "Add N" CTA. Decoupled from LineItems. - useLineItems().addLines(items) — N rows in one render / one changeset op. - LineItems.Table loading + skeletonRowCount props for initial fetches; an automatic fast-scroll skeleton swap during scroll-flicks (CSS-only, no React re-render so input focus survives). Perf: - Text/number/date cells commit on blur, not per keystroke (1,200×-ish reduction in writes during typing). - rowActions routes through a stable ref so the column memo isn't busted on every render — fixes input focus dropping after first character. - Demo removed a 1,200-row sync useEffect; total column is computed live. Bug fixes: - Shift-click no longer bleeds OS text-selection across editable cells. - Single-cell paste now broadcasts to a multi-cell selection (Excel parity). Polish: - Fullscreen modal: backdrop fade + card slide-up on open. Demo wiring: - line-items-demo: CATALOG extended with variants on two SKUs, BulkItemPicker wired to both Bulk-add buttons, loading state with 600ms timer, mode toggles moved to a sidebar ActionPanel, note column replaced with rowActions column. Verification: type-check, lint, 731 tests passing, build clean for both @tailor-platform/app-shell and the app-module example. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/bulk-item-picker.md | 27 ++ .changeset/line-items-add-lines.md | 11 + .changeset/line-items-floating-dock.md | 8 +- .changeset/line-items-row-actions.md | 8 +- .changeset/line-items-skeleton.md | 15 + .../src/pages/goods-receipt-demo.tsx | 51 ++- .../src/pages/journal-entry-demo.tsx | 4 +- .../app-module/src/pages/line-items-demo.tsx | 317 ++++++++++---- .../src/pages/sales-invoice-demo.tsx | 45 +- .../src/pages/stock-transfer-demo.tsx | 30 +- .../app-module/src/pages/work-order-demo.tsx | 64 ++- .../src/components/bulk-item-picker.test.tsx | 152 +++++++ .../core/src/components/bulk-item-picker.tsx | 398 ++++++++++++++++++ .../line-items/line-items-default-cell.tsx | 14 +- .../line-items/line-items-parts.tsx | 7 +- .../components/line-items/line-items-root.tsx | 9 +- .../line-items/line-items-skeleton-row.tsx | 42 ++ .../line-items/line-items-table.test.tsx | 18 + .../line-items/line-items-table.tsx | 272 ++++++++---- .../core/src/components/line-items/types.ts | 5 + .../line-items/use-line-items-group.ts | 4 +- .../line-items/use-line-items.test.tsx | 35 +- .../components/line-items/use-line-items.ts | 33 ++ packages/core/src/index.ts | 5 + 24 files changed, 1337 insertions(+), 237 deletions(-) create mode 100644 .changeset/bulk-item-picker.md create mode 100644 .changeset/line-items-add-lines.md create mode 100644 .changeset/line-items-skeleton.md create mode 100644 packages/core/src/components/bulk-item-picker.test.tsx create mode 100644 packages/core/src/components/bulk-item-picker.tsx create mode 100644 packages/core/src/components/line-items/line-items-skeleton-row.tsx diff --git a/.changeset/bulk-item-picker.md b/.changeset/bulk-item-picker.md new file mode 100644 index 00000000..484d1a87 --- /dev/null +++ b/.changeset/bulk-item-picker.md @@ -0,0 +1,27 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `BulkItemPicker` — a generic tree-select dialog for bulk choosing from a hierarchical list. + +Designed for "Bulk add" flows on documents that pick from a catalog with optional sub-items (products with variants, accounts with sub-accounts, categories with tags, etc.). Decoupled from `LineItems` so it can be used anywhere a multi-select-from-tree is needed. + +- Generic via render-props: consumer supplies `items[]` + `renderRow` + optional `renderMetric` for the right-aligned column. The picker draws the checkbox column, expand/collapse caret, search, and Add-N-items CTA. +- Selection is leaf-only. Parent rows show a tri-state checkbox derived from descendant leaves; clicking a parent selects/deselects all descendants. +- Built-in case-insensitive search (overridable via `matchesSearch`); parents auto-expand when a descendant matches the query. +- Selection state resets on close so each open starts fresh. +- Exports: `BulkItemPicker`, `BulkItemPickerProps`, `BulkItemPickerNode`. + +```tsx + + open={open} + onOpenChange={setOpen} + title="Bulk picker" + rowLabel="Product Name" + metricLabel="Total available" + items={tree} + renderRow={(node) => {node.data.name}} + renderMetric={(node) => node.data.available} + onCommit={(leaves) => addLines(leaves.map(toLine))} +/> +``` diff --git a/.changeset/line-items-add-lines.md b/.changeset/line-items-add-lines.md new file mode 100644 index 00000000..78ca921d --- /dev/null +++ b/.changeset/line-items-add-lines.md @@ -0,0 +1,11 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: `useLineItems` now exposes `addLines(items, opts?)` for batched inserts. + +Same shape as `addLine` but takes an array and funnels through a single state mutation, so committing N picks from a bulk picker is one render and one logical operation in the change set instead of N. Returns the new `lineRef`s in input order. + +```tsx +const refs = lineItems.addLines(selectedVariants.map((v) => ({ sku: v.id, quantity: 1, ...rest }))); +``` diff --git a/.changeset/line-items-floating-dock.md b/.changeset/line-items-floating-dock.md index 4c94ce00..2d87aeed 100644 --- a/.changeset/line-items-floating-dock.md +++ b/.changeset/line-items-floating-dock.md @@ -19,8 +19,12 @@ Three new compound parts hosted at the bottom-center of the viewport when the ho {({ bulkRemove, clear }) => ( <> - - + + )} diff --git a/.changeset/line-items-row-actions.md b/.changeset/line-items-row-actions.md index 25f9edb6..058be334 100644 --- a/.changeset/line-items-row-actions.md +++ b/.changeset/line-items-row-actions.md @@ -10,8 +10,12 @@ Renders a trailing per-row actions column (delete, view, attach, etc.) auto-pinn ( <> - - + + )} rowActionsWidth={84} diff --git a/.changeset/line-items-skeleton.md b/.changeset/line-items-skeleton.md new file mode 100644 index 00000000..edc1f0c5 --- /dev/null +++ b/.changeset/line-items-skeleton.md @@ -0,0 +1,15 @@ +--- +"@tailor-platform/app-shell": minor +--- + +LineItems: skeleton loader for initial fetch + fast-scroll. + +Two new opt-in behaviours on `LineItems.Table`: + +- **`loading?: boolean`** + **`skeletonRowCount?: number`** (default 12) — when `loading` is true, the tbody renders shimmering placeholder rows in place of real data. All other table chrome (header, search, fullscreen toggle, floating dock) keeps rendering normally so the layout doesn't flash. + + ```tsx + + ``` + +- **Fast-scroll skeleton** (automatic, no opt-in) — during a fast scroll-flick, the visible cells visually swap to a pulse bar via a CSS attribute toggle on the scroll container; once scroll settles (~120ms idle), real cells reappear. The React tree doesn't change between transitions, so input focus survives a scroll-flick that returns to the editing cell. diff --git a/examples/app-module/src/pages/goods-receipt-demo.tsx b/examples/app-module/src/pages/goods-receipt-demo.tsx index 6ff6fda2..7c76cc37 100644 --- a/examples/app-module/src/pages/goods-receipt-demo.tsx +++ b/examples/app-module/src/pages/goods-receipt-demo.tsx @@ -70,9 +70,7 @@ const fields: LineItemsField[] = [ width: 110, // 🎨 Highlight the cell red when received quantity differs from expected. className: (l) => - l.receivedQty !== l.expectedQty - ? "astw:bg-destructive/10 astw:text-destructive" - : undefined, + l.receivedQty !== l.expectedQty ? "astw:bg-destructive/10 astw:text-destructive" : undefined, }), f.field({ key: "condition", @@ -108,10 +106,46 @@ const GR_CATALOG: ReadonlyArray<{ sku: string; productName: string }> = [ ]; const seed = (): GRLine[] => [ - { lineRef: "GR1", sku: "SKU-1001", productName: "Indigo Denim Roll", expectedQty: 50, receivedQty: 50, condition: "OK", lotNumber: "L-0612", expiryDate: "2027-06-30" }, - { lineRef: "GR2", sku: "SKU-2040", productName: "Copper Rivet Pack", expectedQty: 100, receivedQty: 92, condition: "SHORT", lotNumber: "L-0613", expiryDate: "2028-01-15" }, - { lineRef: "GR3", sku: "SKU-3300", productName: "Organic Cotton Jersey", expectedQty: 200, receivedQty: 200, condition: "OK", lotNumber: "L-0614", expiryDate: "2027-03-10" }, - { lineRef: "GR4", sku: "SKU-4412", productName: "Leather Patch Kit", expectedQty: 30, receivedQty: 28, condition: "DAMAGED", lotNumber: "L-0615", expiryDate: "2026-11-22" }, + { + lineRef: "GR1", + sku: "SKU-1001", + productName: "Indigo Denim Roll", + expectedQty: 50, + receivedQty: 50, + condition: "OK", + lotNumber: "L-0612", + expiryDate: "2027-06-30", + }, + { + lineRef: "GR2", + sku: "SKU-2040", + productName: "Copper Rivet Pack", + expectedQty: 100, + receivedQty: 92, + condition: "SHORT", + lotNumber: "L-0613", + expiryDate: "2028-01-15", + }, + { + lineRef: "GR3", + sku: "SKU-3300", + productName: "Organic Cotton Jersey", + expectedQty: 200, + receivedQty: 200, + condition: "OK", + lotNumber: "L-0614", + expiryDate: "2027-03-10", + }, + { + lineRef: "GR4", + sku: "SKU-4412", + productName: "Leather Patch Kit", + expectedQty: 30, + receivedQty: 28, + condition: "DAMAGED", + lotNumber: "L-0615", + expiryDate: "2026-11-22", + }, ]; /* ======================================================================== */ @@ -160,7 +194,8 @@ export function GoodsReceiptDemoPage() {

Receipt lines

- {lineItems.allLines.length} lines · scroll horizontally — SKU + Product stay pinned + {lineItems.allLines.length} lines · scroll horizontally — SKU + Product stay + pinned

diff --git a/examples/app-module/src/pages/journal-entry-demo.tsx b/examples/app-module/src/pages/journal-entry-demo.tsx index f70ac1b9..04bca187 100644 --- a/examples/app-module/src/pages/journal-entry-demo.tsx +++ b/examples/app-module/src/pages/journal-entry-demo.tsx @@ -269,7 +269,9 @@ function CollectionSection({
{p.value} {p.description ? ( - {p.description} + + {p.description} + ) : null}
), diff --git a/examples/app-module/src/pages/line-items-demo.tsx b/examples/app-module/src/pages/line-items-demo.tsx index 189d04d6..f26055ba 100644 --- a/examples/app-module/src/pages/line-items-demo.tsx +++ b/examples/app-module/src/pages/line-items-demo.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { ActionPanel, ActivityCard, + BulkItemPicker, Button, Card, Combobox, @@ -12,6 +13,7 @@ import { defineResource, lineItemsFloatingBarStyles, useLineItems, + type BulkItemPickerNode, type LineItemsField, type LineItemsMode, type LineItemsRowData, @@ -21,6 +23,24 @@ import { activityCardDemoActivities } from "./activity-card-demo"; import { ExternalLinkIcon, FileTextIcon, ReceiptIcon } from "./action-panel-demo"; import { mockPurchaseOrder } from "./purchase-order-demo"; +const ModeIcon = (props: React.SVGProps) => ( + + + + +); + /* ======================================================================== */ /* Domain */ /* ======================================================================== */ @@ -32,22 +52,84 @@ type POLine = LineItemsRowData & { unitPrice: number; total: number; expectedReady: string; - note: string; }; type CatalogItem = { sku: string; productName: string; unitPrice: number; + /** Optional per-variant rows. When present, the bulk picker treats this + product as a parent and lets the user pick individual variants. */ + variants?: CatalogVariant[]; + /** Available stock for flat products (no variants). */ + available?: number; +}; + +type CatalogVariant = { + sku: string; + label: string; + unitPrice: number; + available: number; }; const CATALOG: CatalogItem[] = [ - { sku: "SKU-1001", productName: "Indigo Denim Roll", unitPrice: 24.5 }, - { sku: "SKU-2040", productName: "Copper Rivet Pack", unitPrice: 8.25 }, - { sku: "SKU-3300", productName: "Organic Cotton Jersey", unitPrice: 15.0 }, - { sku: "SKU-4412", productName: "Leather Patch Kit", unitPrice: 12.75 }, + { + sku: "SKU-1001", + productName: "Indigo Denim Roll", + unitPrice: 24.5, + available: 18, + }, + { + sku: "SKU-2040", + productName: "Copper Rivet Pack", + unitPrice: 8.25, + available: 42, + }, + { + sku: "SKU-3300", + productName: "Organic Cotton Jersey", + unitPrice: 15.0, + variants: [ + { sku: "SKU-3300-NAT-S", label: "Natural / S", unitPrice: 15.0, available: 9 }, + { sku: "SKU-3300-NAT-M", label: "Natural / M", unitPrice: 15.0, available: 12 }, + { sku: "SKU-3300-NAT-L", label: "Natural / L", unitPrice: 15.0, available: 7 }, + { sku: "SKU-3300-CHA-M", label: "Charcoal / M", unitPrice: 15.0, available: 5 }, + ], + }, + { + sku: "SKU-4412", + productName: "Leather Patch Kit", + unitPrice: 12.75, + variants: [ + { sku: "SKU-4412-BRN", label: "Brown", unitPrice: 12.75, available: 14 }, + { sku: "SKU-4412-BLK", label: "Black", unitPrice: 13.5, available: 11 }, + ], + }, ]; +/** Each leaf carries enough info to build a line via `addLines`. */ +type BulkPickerLeaf = { sku: string; productName: string; unitPrice: number }; + +function catalogToTree(catalog: ReadonlyArray): BulkItemPickerNode[] { + return catalog.map((item) => { + const node: BulkItemPickerNode = { + id: item.sku, + data: { sku: item.sku, productName: item.productName, unitPrice: item.unitPrice }, + }; + if (item.variants?.length) { + node.children = item.variants.map((v) => ({ + id: v.sku, + data: { + sku: v.sku, + productName: `${item.productName} — ${v.label}`, + unitPrice: v.unitPrice, + }, + })); + } + return node; + }); +} + const round2 = (n: number) => Math.round(n * 100) / 100; const fmtCurrency = (n: number) => n.toFixed(2); @@ -85,7 +167,7 @@ const fields: LineItemsField[] = [ type: { kind: "text" }, sort: { comparator: (a, b) => a.productName.localeCompare(b.productName) }, search: (l, q) => l.productName.toLowerCase().includes(q.toLowerCase()), - flex: true, + width: 240, }), f.field({ key: "quantity", @@ -110,7 +192,9 @@ const fields: LineItemsField[] = [ f.field({ key: "total", label: "Total", - render: (l) => fmtCurrency(l.total), + // Computed live from the row — no data-sync effect, no per-keystroke + // 1200-row scan. The stored `total` is unused for display. + render: (l) => fmtCurrency(round2(l.quantity * l.unitPrice)), align: "right", width: 110, }), @@ -123,15 +207,6 @@ const fields: LineItemsField[] = [ sort: { comparator: (a, b) => a.expectedReady.localeCompare(b.expectedReady) }, width: 140, }), - f.field({ - key: "note", - label: "Note", - render: (l) => l.note, - editable: ["edit", "amend"], - type: { kind: "text" }, - commit: "metadata", - width: 200, - }), ]; /* ======================================================================== */ @@ -162,7 +237,6 @@ function buildInitialLines(): POLine[] { unitPrice, total: round2(quantity * unitPrice), expectedReady: iso, - note: i % 50 === 0 ? "Highlight row" : "", }); } return lines; @@ -185,9 +259,37 @@ export const lineItemsDemoResource = defineResource({ * containing demo mode controls + the LineItems card + the floating action * dock. Pass `initialData={[]}` to start from empty and exercise the add-row. */ -export function LineItemsSection({ initialData }: { initialData?: POLine[] } = {}) { +export type LineItemsSectionApi = { + mode: LineItemsMode; + setMode: (m: LineItemsMode) => void; +}; + +export function LineItemsSection({ + initialData, + hideDemoControlsCard = false, + onApiChange, +}: { + initialData?: POLine[]; + /** Hide the inline "Mode" demo card (e.g. when those controls have been + hoisted into the page's ActionPanel). */ + hideDemoControlsCard?: boolean; + /** Fires whenever the section's mode changes so the parent page can mirror + it into a sidebar ActionPanel. */ + onApiChange?: (api: LineItemsSectionApi) => void; +} = {}) { const initialLines = React.useMemo(() => initialData ?? buildInitialLines(), [initialData]); const [mode, setMode] = React.useState("edit"); + const [bulkOpen, setBulkOpen] = React.useState(false); + const tree = React.useMemo(() => catalogToTree(CATALOG), []); + + // 🧪 Demo Dummy: simulate an initial async data fetch so the + // `LineItems.Table loading` skeleton is visible on page load. Real apps + // would derive `loading` from their query/fetcher state. + const [loading, setLoading] = React.useState(true); + React.useEffect(() => { + const t = window.setTimeout(() => setLoading(false), 600); + return () => window.clearTimeout(t); + }, []); const lineItems = useLineItems({ fields, @@ -196,21 +298,10 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { selection: true, }); - // Keep `total` in sync with quantity / unitPrice so the read-only column - // reflects the latest cell edits without an external compute step. Depends - // on `allLines` (whose reference only changes when row state actually - // changes) instead of the whole hook return — otherwise this effect would - // re-fire on every render. - const allLines = lineItems.allLines; - const updateLines = lineItems.updateLines; React.useEffect(() => { - const updates: { lineRef: string; patch: Partial }[] = []; - for (const l of allLines) { - const expected = round2(l.quantity * l.unitPrice); - if (expected !== l.total) updates.push({ lineRef: l.lineRef, patch: { total: expected } }); - } - if (updates.length) updateLines(updates); - }, [allLines, updateLines]); + if (!onApiChange) return; + onApiChange({ mode, setMode }); + }, [mode, onApiChange]); const handleSave = React.useCallback(() => { const cs = lineItems.getChangeSet(); @@ -224,36 +315,29 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { return ( <> - {/* 🧪 Demo Dummy: mode + duplicate-last are demo-only knobs to flip the - table into different states. They are NOT part of the LineItems - component itself — kept in a separate card just for the demo. */} - - - Mode - {(["edit", "display", "amend"] as const).map((m) => ( - - ))} - - - + {/* 🧪 Demo Dummy: mode toggles flip the table into different states. + They are NOT part of the LineItems component itself — kept in a + separate card just for the demo. The line-items-demo page hides this + card and surfaces the same controls inside its ActionPanel sidebar + via `onApiChange`. */} + {hideDemoControlsCard ? null : ( + + + Mode + {(["edit", "display", "amend"] as const).map((m) => ( + + ))} + + + )} {/* overflow-hidden clips the edge-to-edge table to the Card's rounded @@ -284,12 +368,7 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { > Import from CSV - @@ -301,6 +380,35 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { maxBodyHeight={600} renderFullscreenToggle={false} tableContainerClassName="astw:rounded-none astw:border-0" + rowActionsWidth={68} + loading={loading} + skeletonRowCount={14} + rowActions={(line) => ( + <> + + + + )} /> {mode !== "display" ? ( @@ -313,15 +421,55 @@ export function LineItemsSection({ initialData }: { initialData?: POLine[] } = { unitPrice: picked.unitPrice, total: round2(picked.unitPrice), expectedReady: new Date().toISOString().slice(0, 10), - note: "", }); }} + onBulkAdd={() => setBulkOpen(true)} disabled={mode !== "edit"} /> ) : null} + {/* ✅ Reusable Pattern: BulkItemPicker is a generic tree-select dialog. + Tree shape comes from the consumer's catalog; selected leaves are + mapped 1-to-1 to lines and committed via the new `addLines` batch. */} + + open={bulkOpen} + onOpenChange={setBulkOpen} + title="Bulk line item picker" + rowLabel="Product Name" + metricLabel="Total available" + searchPlaceholder="Search products" + items={tree} + renderRow={(node) => {node.data.productName}} + renderMetric={(node) => { + const item = CATALOG.find((c) => c.sku === node.id); + const variant = CATALOG.flatMap((c) => c.variants ?? []).find((v) => v.sku === node.id); + const total = + item?.variants?.reduce((s, v) => s + v.available, 0) ?? + item?.available ?? + variant?.available ?? + 0; + return total; + }} + matchesSearch={(node, q) => + node.data.productName.toLowerCase().includes(q.toLowerCase()) || + node.data.sku.toLowerCase().includes(q.toLowerCase()) + } + onCommit={(picked) => { + lineItems.addLines( + picked.map((n) => ({ + sku: n.data.sku, + productName: n.data.productName, + quantity: 1, + unitPrice: n.data.unitPrice, + total: round2(n.data.unitPrice), + expectedReady: new Date().toISOString().slice(0, 10), + })), + ); + }} + /> + {/* ✅ Library pattern: dirty + selection bars auto-mount/unmount based on hook state. Discard defaults to lineItems.revert(); warnOnNav blocks anchor clicks + window unload while dirty. */} @@ -360,6 +508,11 @@ export function LineItemsDemoPage() { alert("Page-level Save changes clicked"); }, []); + // 🧪 Demo Dummy: capture the LineItemsSection's mode + duplicate handlers + // so the page-level ActionPanel can host them as sidebar actions instead of + // the inline demo card sitting above the table. + const [sectionApi, setSectionApi] = React.useState(null); + const headerActions = [
); } - diff --git a/examples/app-module/src/pages/sales-invoice-demo.tsx b/examples/app-module/src/pages/sales-invoice-demo.tsx index 9c5a2810..3eed2769 100644 --- a/examples/app-module/src/pages/sales-invoice-demo.tsx +++ b/examples/app-module/src/pages/sales-invoice-demo.tsx @@ -108,10 +108,42 @@ const SERVICE_CATALOG: ReadonlyArray<{ description: string; rate: number; taxCod ]; const seed = (): InvoiceLine[] => [ - { lineRef: "L1", description: "Consulting hours — June", quantity: 24, rate: 150, discountPct: 0, taxCode: "STD", amount: 0 }, - { lineRef: "L2", description: "Premium support — Q2", quantity: 1, rate: 1200, discountPct: 10, taxCode: "STD", amount: 0 }, - { lineRef: "L3", description: "Travel reimbursement", quantity: 1, rate: 480, discountPct: 0, taxCode: "EXM", amount: 0 }, - { lineRef: "L4", description: "Training session — half day", quantity: 2, rate: 600, discountPct: 5, taxCode: "RED", amount: 0 }, + { + lineRef: "L1", + description: "Consulting hours — June", + quantity: 24, + rate: 150, + discountPct: 0, + taxCode: "STD", + amount: 0, + }, + { + lineRef: "L2", + description: "Premium support — Q2", + quantity: 1, + rate: 1200, + discountPct: 10, + taxCode: "STD", + amount: 0, + }, + { + lineRef: "L3", + description: "Travel reimbursement", + quantity: 1, + rate: 480, + discountPct: 0, + taxCode: "EXM", + amount: 0, + }, + { + lineRef: "L4", + description: "Training session — half day", + quantity: 2, + rate: 600, + discountPct: 5, + taxCode: "RED", + amount: 0, + }, ]; /* ======================================================================== */ @@ -283,10 +315,7 @@ function AddInvoiceLineRow({ }) { const [resetKey, setResetKey] = React.useState(0); return ( -
+
key={resetKey} items={SERVICE_CATALOG as Array<{ description: string; rate: number; taxCode: string }>} diff --git a/examples/app-module/src/pages/stock-transfer-demo.tsx b/examples/app-module/src/pages/stock-transfer-demo.tsx index 9f8113c3..8ca5bcf0 100644 --- a/examples/app-module/src/pages/stock-transfer-demo.tsx +++ b/examples/app-module/src/pages/stock-transfer-demo.tsx @@ -103,9 +103,33 @@ const ST_CATALOG: ReadonlyArray<{ sku: string; productName: string }> = [ ]; const seed = (): TransferLine[] => [ - { lineRef: "T1", sku: "SKU-1001", productName: "Indigo Denim Roll", fromWarehouse: "WH-NYC", toWarehouse: "WH-LAX", quantity: 20, lotNumber: "L-0701" }, - { lineRef: "T2", sku: "SKU-2040", productName: "Copper Rivet Pack", fromWarehouse: "WH-NYC", toWarehouse: "WH-CHI", quantity: 50, lotNumber: "L-0702" }, - { lineRef: "T3", sku: "SKU-3300", productName: "Organic Cotton Jersey", fromWarehouse: "WH-DAL", toWarehouse: "WH-LAX", quantity: 80, lotNumber: "L-0703" }, + { + lineRef: "T1", + sku: "SKU-1001", + productName: "Indigo Denim Roll", + fromWarehouse: "WH-NYC", + toWarehouse: "WH-LAX", + quantity: 20, + lotNumber: "L-0701", + }, + { + lineRef: "T2", + sku: "SKU-2040", + productName: "Copper Rivet Pack", + fromWarehouse: "WH-NYC", + toWarehouse: "WH-CHI", + quantity: 50, + lotNumber: "L-0702", + }, + { + lineRef: "T3", + sku: "SKU-3300", + productName: "Organic Cotton Jersey", + fromWarehouse: "WH-DAL", + toWarehouse: "WH-LAX", + quantity: 80, + lotNumber: "L-0703", + }, ]; /* ======================================================================== */ diff --git a/examples/app-module/src/pages/work-order-demo.tsx b/examples/app-module/src/pages/work-order-demo.tsx index b92632e1..76d9b986 100644 --- a/examples/app-module/src/pages/work-order-demo.tsx +++ b/examples/app-module/src/pages/work-order-demo.tsx @@ -130,30 +130,37 @@ const PARTS_CATALOG: ReadonlyArray<{ partSku: string; partName: string; uom: str { partSku: "FAB-002", partName: "Cotton fabric", uom: "M" }, { partSku: "TR-220", partName: "Cotton thread", uom: "M" }, { partSku: "BTN-010", partName: "Brass buttons", uom: "EA" }, - { partSku: "ZIP-007", partName: "YKK zipper 7\"", uom: "EA" }, + { partSku: "ZIP-007", partName: 'YKK zipper 7"', uom: "EA" }, ]; -const STEP_CATALOG: ReadonlyArray<{ step: string; workstation: string; durationMinutes: number }> = [ - { step: "Cut fabric", workstation: "WS-01", durationMinutes: 15 }, - { step: "Sew panels", workstation: "WS-02", durationMinutes: 45 }, - { step: "Attach zipper", workstation: "WS-02", durationMinutes: 12 }, - { step: "Press seams", workstation: "WS-03", durationMinutes: 8 }, - { step: "Final QA inspect", workstation: "WS-04", durationMinutes: 6 }, -]; +const STEP_CATALOG: ReadonlyArray<{ step: string; workstation: string; durationMinutes: number }> = + [ + { step: "Cut fabric", workstation: "WS-01", durationMinutes: 15 }, + { step: "Sew panels", workstation: "WS-02", durationMinutes: 45 }, + { step: "Attach zipper", workstation: "WS-02", durationMinutes: 12 }, + { step: "Press seams", workstation: "WS-03", durationMinutes: 8 }, + { step: "Final QA inspect", workstation: "WS-04", durationMinutes: 6 }, + ]; const seedComponents = (): ComponentLine[] => [ - { lineRef: "C1", partSku: "FAB-001", partName: "Indigo denim", qtyRequired: 2.5, uom: "M" }, - { lineRef: "C2", partSku: "TR-220", partName: "Cotton thread", qtyRequired: 200, uom: "M" }, - { lineRef: "C3", partSku: "BTN-010", partName: "Brass buttons", qtyRequired: 12, uom: "EA" }, - { lineRef: "C4", partSku: "ZIP-007", partName: "YKK zipper 7\"", qtyRequired: 1, uom: "EA" }, + { lineRef: "C1", partSku: "FAB-001", partName: "Indigo denim", qtyRequired: 2.5, uom: "M" }, + { lineRef: "C2", partSku: "TR-220", partName: "Cotton thread", qtyRequired: 200, uom: "M" }, + { lineRef: "C3", partSku: "BTN-010", partName: "Brass buttons", qtyRequired: 12, uom: "EA" }, + { lineRef: "C4", partSku: "ZIP-007", partName: 'YKK zipper 7"', qtyRequired: 1, uom: "EA" }, ]; const seedOperations = (): OperationLine[] => [ - { lineRef: "O1", sequence: 10, step: "Cut fabric", workstation: "WS-01", durationMinutes: 15 }, - { lineRef: "O2", sequence: 20, step: "Sew panels", workstation: "WS-02", durationMinutes: 45 }, - { lineRef: "O3", sequence: 30, step: "Attach zipper", workstation: "WS-02", durationMinutes: 12 }, - { lineRef: "O4", sequence: 40, step: "Press seams", workstation: "WS-03", durationMinutes: 8 }, - { lineRef: "O5", sequence: 50, step: "Final QA inspect", workstation: "WS-04", durationMinutes: 6 }, + { lineRef: "O1", sequence: 10, step: "Cut fabric", workstation: "WS-01", durationMinutes: 15 }, + { lineRef: "O2", sequence: 20, step: "Sew panels", workstation: "WS-02", durationMinutes: 45 }, + { lineRef: "O3", sequence: 30, step: "Attach zipper", workstation: "WS-02", durationMinutes: 12 }, + { lineRef: "O4", sequence: 40, step: "Press seams", workstation: "WS-03", durationMinutes: 8 }, + { + lineRef: "O5", + sequence: 50, + step: "Final QA inspect", + workstation: "WS-04", + durationMinutes: 6, + }, ]; /* ======================================================================== */ @@ -208,12 +215,7 @@ export function WorkOrderDemoPage() { > Discard , - , ]} @@ -225,7 +227,9 @@ export function WorkOrderDemoPage() { {group.isDirty ? ( - Unsaved changes across both collections + + Unsaved changes across both collections + ) : null} @@ -342,11 +346,7 @@ function CollectionSection({ type PartItem = { partSku: string; partName: string; uom: string }; type StepItem = { step: string; workstation: string; durationMinutes: number }; -function AddComponentLineRow({ - onPick, -}: { - onPick: (p: PartItem) => void; -}) { +function AddComponentLineRow({ onPick }: { onPick: (p: PartItem) => void }) { const [resetKey, setResetKey] = React.useState(0); return (
@@ -384,11 +384,7 @@ function AddComponentLineRow({ ); } -function AddOperationLineRow({ - onPick, -}: { - onPick: (p: StepItem) => void; -}) { +function AddOperationLineRow({ onPick }: { onPick: (p: StepItem) => void }) { const [resetKey, setResetKey] = React.useState(0); return (
diff --git a/packages/core/src/components/bulk-item-picker.test.tsx b/packages/core/src/components/bulk-item-picker.test.tsx new file mode 100644 index 00000000..08ad4389 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker.test.tsx @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; + +import { BulkItemPicker, type BulkItemPickerNode } from "./bulk-item-picker"; + +afterEach(() => { + cleanup(); +}); + +type Item = { name: string; available: number }; + +const TREE: BulkItemPickerNode[] = [ + { id: "p1", data: { name: "Nike Vomero 18", available: 3 } }, + { + id: "p2", + data: { name: "Adidas Ultraboost 22", available: 0 }, + children: [ + { id: "p2-uk8w", data: { name: "UK 8 / Black / Women", available: 9 } }, + { id: "p2-uk9m", data: { name: "UK 9 / Black / Men", available: 9 } }, + { id: "p2-uk10m", data: { name: "UK 10 / Black / Men", available: 9 } }, + ], + }, + { id: "p3", data: { name: "Hoka One One Bondi 8", available: 15 } }, +]; + +function Harness({ + initialOpen = true, + onCommit = vi.fn(), +}: { + initialOpen?: boolean; + onCommit?: (items: BulkItemPickerNode[]) => void; +}) { + const [open, setOpen] = React.useState(initialOpen); + return ( + + open={open} + onOpenChange={setOpen} + title="Bulk picker" + items={TREE} + rowLabel="Product Name" + metricLabel="Total available" + renderRow={(node) => {node.data.name}} + renderMetric={(node) => {node.data.available}} + matchesSearch={(node, q) => node.data.name.toLowerCase().includes(q.toLowerCase())} + onCommit={onCommit} + /> + ); +} + +describe("BulkItemPicker", () => { + it("commits selected leaves in tree order and closes", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + // Expand the parent so its variants are visible. + await user.click(screen.getByRole("button", { name: /expand/i })); + + // Select two leaves out of three under the parent + one flat leaf. + const checkboxes = screen.getAllByRole("checkbox"); + // Layout: [Nike, Adidas-parent, UK8/W, UK9/M, UK10/M, Hoka] + await user.click(checkboxes[2]!); // UK 8 / Women + await user.click(checkboxes[4]!); // UK 10 / Men + await user.click(checkboxes[5]!); // Hoka + + const cta = screen.getByRole("button", { name: /add 3 items/i }) as HTMLButtonElement; + expect(cta.disabled).toBe(false); + await user.click(cta); + + expect(onCommit).toHaveBeenCalledTimes(1); + const selected = onCommit.mock.calls[0]![0] as BulkItemPickerNode[]; + expect(selected.map((n) => n.id)).toEqual(["p2-uk8w", "p2-uk10m", "p3"]); + }); + + it("parent toggle selects/deselects all descendant leaves", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + const expandBtn = screen.getByRole("button", { name: /expand/i }); + await user.click(expandBtn); + + let checkboxes = screen.getAllByRole("checkbox"); + const parentCheckbox = checkboxes[1]!; + await user.click(parentCheckbox); + + // After parent toggle, all 3 children are checked. + checkboxes = screen.getAllByRole("checkbox"); + expect((checkboxes[2] as HTMLInputElement).checked).toBe(true); + expect((checkboxes[3] as HTMLInputElement).checked).toBe(true); + expect((checkboxes[4] as HTMLInputElement).checked).toBe(true); + expect((checkboxes[1] as HTMLInputElement).checked).toBe(true); + + await user.click(screen.getByRole("button", { name: /add 3 items/i })); + const selected = onCommit.mock.calls[0]![0] as BulkItemPickerNode[]; + expect(selected.map((n) => n.id)).toEqual(["p2-uk8w", "p2-uk9m", "p2-uk10m"]); + }); + + it("partial child selection drives parent into indeterminate state", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: /expand/i })); + const checkboxes = screen.getAllByRole("checkbox"); + await user.click(checkboxes[2]!); // pick one variant + + const updated = screen.getAllByRole("checkbox"); + const parent = updated[1] as HTMLInputElement; + expect(parent.indeterminate).toBe(true); + expect(parent.checked).toBe(false); + }); + + it("search filters subtrees and auto-expands parents whose descendants match", async () => { + const user = userEvent.setup(); + render(); + + // Initially Adidas variants are NOT shown (not auto-expanded). + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + + const search = screen.getByLabelText("Search"); + await user.type(search, "UK 9"); + + // Parent now visible (it's an ancestor of a match) and auto-expanded. + expect(screen.getByText("Adidas Ultraboost 22")).toBeTruthy(); + expect(screen.getByText("UK 9 / Black / Men")).toBeTruthy(); + // Non-matching products are hidden. + expect(screen.queryByText("Nike Vomero 18")).toBeNull(); + expect(screen.queryByText("Hoka One One Bondi 8")).toBeNull(); + }); + + it("CTA is disabled with no selection and Cancel does not commit", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + const cta = screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement; + expect(cta.disabled).toBe(true); + + await user.click(screen.getByRole("button", { name: /^cancel$/i })); + expect(onCommit).not.toHaveBeenCalled(); + }); + + it("renders the empty text when search has no matches", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Search"), "zzz no match"); + expect(screen.getByText(/no matching items/i)).toBeTruthy(); + }); +}); diff --git a/packages/core/src/components/bulk-item-picker.tsx b/packages/core/src/components/bulk-item-picker.tsx new file mode 100644 index 00000000..f733d5f9 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker.tsx @@ -0,0 +1,398 @@ +import * as React from "react"; +import { ChevronDownIcon, ChevronRightIcon, SearchIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +import { Button } from "./button"; +import { Dialog } from "./dialog"; +import { Input } from "./input"; + +/* ======================================================================== */ +/* Types */ +/* ======================================================================== */ + +export type BulkItemPickerNode = { + id: string; + data: T; + children?: BulkItemPickerNode[]; +}; + +export type BulkItemPickerProps = { + open: boolean; + onOpenChange(open: boolean): void; + /** Dialog title. Default: "Select items". */ + title?: React.ReactNode; + /** Hierarchical items. Selection is leaf-only (a node with no `children`). */ + items: ReadonlyArray>; + /** Render the row body — checkbox + metric column are drawn by the picker. */ + renderRow(node: BulkItemPickerNode, depth: number): React.ReactNode; + /** Optional right-aligned metric (e.g. "Total available"). */ + renderMetric?(node: BulkItemPickerNode, depth: number): React.ReactNode; + /** Header label above `renderRow` output. Default: "Item". */ + rowLabel?: React.ReactNode; + /** Header label above `renderMetric` output. */ + metricLabel?: React.ReactNode; + /** + * Substring search predicate. Default: case-insensitive match against + * `String(node.data)` — fine when `data` is a primitive, but consumers + * should override for object payloads. + */ + matchesSearch?(node: BulkItemPickerNode, query: string): boolean; + /** Optional render slot to the right of the search input (e.g. a filter button). */ + filterSlot?: React.ReactNode; + /** Commit handler — receives the selected LEAF nodes in tree order. */ + onCommit(selected: BulkItemPickerNode[]): void; + /** Customise the primary CTA label. Default: count > 0 ? `Add ${count} items` : `Add items`. */ + ctaLabel?(count: number): string; + emptyText?: React.ReactNode; + searchPlaceholder?: string; +}; + +/* ======================================================================== */ +/* Internal helpers */ +/* ======================================================================== */ + +const isLeaf = (n: BulkItemPickerNode): boolean => !n.children || n.children.length === 0; + +/** Walk the subtree rooted at `n` and yield every leaf id. */ +function leafIds(n: BulkItemPickerNode, into: string[] = []): string[] { + if (isLeaf(n)) { + into.push(n.id); + return into; + } + for (const c of n.children!) leafIds(c, into); + return into; +} + +/** Walk every leaf in the input forest in DFS order. */ +function allLeaves( + nodes: ReadonlyArray>, + into: BulkItemPickerNode[] = [], +): BulkItemPickerNode[] { + for (const n of nodes) { + if (isLeaf(n)) into.push(n); + else allLeaves(n.children!, into); + } + return into; +} + +/** + * Filter the forest by `matches`. A node survives if it matches OR any of its + * descendants does. Returns the filtered forest plus the set of node ids that + * should auto-expand (any ancestor whose descendant matched). + */ +function filterTree( + nodes: ReadonlyArray>, + matches: (n: BulkItemPickerNode) => boolean, +): { kept: BulkItemPickerNode[]; autoExpand: Set } { + const autoExpand = new Set(); + const walk = (list: ReadonlyArray>): BulkItemPickerNode[] => { + const out: BulkItemPickerNode[] = []; + for (const n of list) { + if (isLeaf(n)) { + if (matches(n)) out.push(n); + continue; + } + const childKept = walk(n.children!); + if (childKept.length || matches(n)) { + if (childKept.length) autoExpand.add(n.id); + out.push({ ...n, children: childKept.length ? childKept : n.children }); + } + } + return out; + }; + return { kept: walk(nodes), autoExpand }; +} + +const defaultMatcher = (n: BulkItemPickerNode, q: string): boolean => + String(n.data ?? "") + .toLowerCase() + .includes(q.toLowerCase()); + +/* ======================================================================== */ +/* Tri-state checkbox */ +/* ======================================================================== */ + +function TriCheckbox({ + checked, + indeterminate, + onChange, + ariaLabel, +}: { + checked: boolean; + indeterminate: boolean; + onChange(next: boolean): void; + ariaLabel?: string; +}) { + const ref = React.useRef(null); + React.useEffect(() => { + if (ref.current) ref.current.indeterminate = indeterminate; + }, [indeterminate]); + return ( + onChange(e.target.checked)} + onClick={(e) => e.stopPropagation()} + className="astw:size-4 astw:cursor-pointer astw:accent-foreground" + /> + ); +} + +/* ======================================================================== */ +/* Row */ +/* ======================================================================== */ + +function Row({ + node, + depth, + expanded, + onToggleExpand, + selectedLeaves, + onToggleNode, + renderRow, + renderMetric, +}: { + node: BulkItemPickerNode; + depth: number; + expanded: boolean; + onToggleExpand(id: string): void; + selectedLeaves: ReadonlySet; + onToggleNode(node: BulkItemPickerNode, next: boolean): void; + renderRow: BulkItemPickerProps["renderRow"]; + renderMetric: BulkItemPickerProps["renderMetric"]; +}) { + const leaf = isLeaf(node); + const descendantLeaves = React.useMemo(() => leafIds(node), [node]); + const checkedCount = descendantLeaves.reduce((n, id) => n + (selectedLeaves.has(id) ? 1 : 0), 0); + const allChecked = descendantLeaves.length > 0 && checkedCount === descendantLeaves.length; + const anyChecked = checkedCount > 0; + + return ( +
0 && "astw:bg-muted/30", + )} + style={{ paddingLeft: 16 + depth * 24 }} + > + onToggleNode(node, next)} + /> + + {!leaf ? ( + + ) : ( + + )} + +
+ {renderRow(node, depth)} +
+ + {renderMetric ? ( +
+ {renderMetric(node, depth)} +
+ ) : null} +
+ ); +} + +/* ======================================================================== */ +/* BulkItemPicker */ +/* ======================================================================== */ + +export function BulkItemPicker({ + open, + onOpenChange, + title = "Select items", + items, + renderRow, + renderMetric, + rowLabel = "Item", + metricLabel, + matchesSearch, + filterSlot, + onCommit, + ctaLabel, + emptyText = "No matching items.", + searchPlaceholder = "Search", +}: BulkItemPickerProps) { + const [query, setQuery] = React.useState(""); + const [selected, setSelected] = React.useState>(() => new Set()); + const [expanded, setExpanded] = React.useState>(() => new Set()); + + // Reset on close so the dialog opens empty next time. + React.useEffect(() => { + if (!open) { + setQuery(""); + setSelected(new Set()); + setExpanded(new Set()); + } + }, [open]); + + const matcher = matchesSearch ?? defaultMatcher; + const { kept, autoExpand } = React.useMemo(() => { + if (!query.trim()) { + return { kept: [...items], autoExpand: new Set() }; + } + return filterTree(items, (n) => matcher(n, query.trim())); + }, [items, query, matcher]); + + const isExpanded = (id: string): boolean => autoExpand.has(id) || expanded.has(id); + + const toggleExpand = (id: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const onToggleNode = (node: BulkItemPickerNode, nextChecked: boolean) => { + setSelected((prev) => { + const next = new Set(prev); + const ids = leafIds(node); + if (nextChecked) { + for (const id of ids) next.add(id); + } else { + for (const id of ids) next.delete(id); + } + return next; + }); + }; + + const flatLeavesInOrder = React.useMemo(() => allLeaves(items), [items]); + const selectedNodes = React.useMemo( + () => flatLeavesInOrder.filter((n) => selected.has(n.id)), + [flatLeavesInOrder, selected], + ); + const count = selectedNodes.length; + + const handleCommit = () => { + if (count === 0) return; + onCommit(selectedNodes); + onOpenChange(false); + }; + + const cta = ctaLabel + ? ctaLabel(count) + : count > 0 + ? `Add ${count} item${count === 1 ? "" : "s"}` + : "Add items"; + + /* ---- Render ----------------------------------------------------------- */ + + const renderForest = ( + list: ReadonlyArray>, + depth: number, + ): React.ReactNode => + list.map((node) => { + const leaf = isLeaf(node); + const showChildren = !leaf && isExpanded(node.id); + return ( + + + {showChildren ? renderForest(node.children!, depth + 1) : null} + + ); + }); + + return ( + + { + if (e.key === "Enter" && count > 0) { + const tgt = e.target as HTMLElement; + // Don't intercept Enter inside the search input — let it stay there + // until the user explicitly hits the CTA. (Prevents accidental commits + // while typing.) + if (tgt.tagName === "INPUT" && tgt.getAttribute("type") !== "checkbox") return; + e.preventDefault(); + handleCommit(); + } + }} + > + + {title} + + +
+
+ + setQuery(e.target.value)} + placeholder={searchPlaceholder} + className="astw:border-0 astw:bg-transparent astw:px-0 astw:shadow-none astw:focus-visible:ring-0" + aria-label="Search" + /> +
+ {filterSlot} +
+ +
+ {/* Spacer aligns "rowLabel" with the row content (checkbox + caret = 16 + 16 + 16 + 12 = 60 ish). + Keep it pixel-cheap: 16px outer padding handled by px-4, then 36px gap before rowLabel. */} + + +
{rowLabel}
+ {metricLabel ?
{metricLabel}
: null} +
+ +
+ {kept.length === 0 ? ( +
+ {emptyText} +
+ ) : ( + renderForest(kept, 0) + )} +
+ + + + + +
+
+ ); +} +BulkItemPicker.displayName = "BulkItemPicker"; diff --git a/packages/core/src/components/line-items/line-items-default-cell.tsx b/packages/core/src/components/line-items/line-items-default-cell.tsx index 3e8a50e9..d6d87860 100644 --- a/packages/core/src/components/line-items/line-items-default-cell.tsx +++ b/packages/core/src/components/line-items/line-items-default-cell.tsx @@ -304,11 +304,8 @@ function EditableFieldCell(p: { className, )} value={local} - onChange={(e) => { - const v = e.target.value; - setLocal(v); - onCommit(parseLocalToCommit(v)); - }} + onChange={(e) => setLocal(e.target.value)} + onBlur={() => onCommit(parseLocalToCommit(local))} onFocus={onFocus} onKeyDown={onKeyDown} /> @@ -462,11 +459,8 @@ function DateFieldCell({ value={local} min={t.min} max={t.max} - onChange={(e) => { - const v = e.target.value; - setLocal(v); - onCommit(v); - }} + onChange={(e) => setLocal(e.target.value)} + onBlur={() => onCommit(local)} onFocus={onFocus} onKeyDown={onKeyDown} /> diff --git a/packages/core/src/components/line-items/line-items-parts.tsx b/packages/core/src/components/line-items/line-items-parts.tsx index 8b399ae0..6baa4732 100644 --- a/packages/core/src/components/line-items/line-items-parts.tsx +++ b/packages/core/src/components/line-items/line-items-parts.tsx @@ -403,8 +403,7 @@ const pillStyle: React.CSSProperties = { padding: "12px 20px", borderRadius: "16px", backgroundColor: "var(--foreground)", - boxShadow: - "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", + boxShadow: "0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)", }; const labelStyle: React.CSSProperties = { @@ -631,9 +630,7 @@ export function LineItemsSelectionBar({ const { hook } = useLineItemsRoot(); if (hook.selectedIds.length === 0) return null; - const labelText = label - ? label(hook.selectedIds) - : `${hook.selectedIds.length} selected`; + const labelText = label ? label(hook.selectedIds) : `${hook.selectedIds.length} selected`; return (
diff --git a/packages/core/src/components/line-items/line-items-root.tsx b/packages/core/src/components/line-items/line-items-root.tsx index ac58a846..4a2a2ed1 100644 --- a/packages/core/src/components/line-items/line-items-root.tsx +++ b/packages/core/src/components/line-items/line-items-root.tsx @@ -53,8 +53,7 @@ export function LineItemsRoot({ children, }: LineItemsRootProps) { const [fullscreen, setFullscreen] = React.useState(false); - const [totalsRowFn, setTotalsRowFn] = - React.useState | null>(null); + const [totalsRowFn, setTotalsRowFn] = React.useState | null>(null); React.useEffect(() => { if (!fullscreen) return undefined; @@ -93,11 +92,7 @@ export function LineItemsRoot({ data-slot="line-items" data-fullscreen={fullscreen ? "true" : undefined} onPointerDown={onBackdropPointerDown} - style={ - fullscreen - ? { animation: "line-items-fullscreen-in 220ms ease-out" } - : undefined - } + style={fullscreen ? { animation: "line-items-fullscreen-in 220ms ease-out" } : undefined} className={cn( "astw:flex astw:w-full astw:flex-col astw:gap-1", // Fullscreen overlay: viewport-filling with a dark backdrop. Descendant 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 00000000..b1debc65 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-skeleton-row.tsx @@ -0,0 +1,42 @@ +/** + * Internal skeleton row for `LineItems.Table` — renders a `` with one + * `` per column whose width is constrained by the table's ``. + * Each cell hosts a `bg-muted` bar with `animate-pulse`. Bar widths are + * deterministically varied per column index so a stack of skeleton rows feels + * less mechanical than identical bars. + */ +export function LineItemsSkeletonRow({ + colCount, + rowHeight, + rowIndex, +}: { + colCount: number; + rowHeight: number; + /** 0-indexed row position; used to vary the deterministic bar widths so + stacked skeleton rows don't all align identically. */ + rowIndex: number; +}) { + return ( + + {Array.from({ length: colCount }, (_, i) => { + // Pseudo-random but stable: vary bar width by (row, col) so the + // skeleton doesn't look like a perfectly aligned grid. + const variance = ((rowIndex * 13 + i * 17) % 40) - 20; // -20..+19 + const widthPct = 55 + variance; // 35%..74% + return ( + +
+ + ); + })} + + ); +} +LineItemsSkeletonRow.displayName = "LineItems.SkeletonRow"; diff --git a/packages/core/src/components/line-items/line-items-table.test.tsx b/packages/core/src/components/line-items/line-items-table.test.tsx index 341167e8..5b347ef5 100644 --- a/packages/core/src/components/line-items/line-items-table.test.tsx +++ b/packages/core/src/components/line-items/line-items-table.test.tsx @@ -40,15 +40,21 @@ const data: Row[] = [ function Harness({ withTotals = false, withRowActions = false, + loading = false, + skeletonRowCount, }: { withTotals?: boolean; withRowActions?: boolean; + loading?: boolean; + skeletonRowCount?: number; }) { const lineItems = useLineItems({ fields, data }); return ( @@ -87,6 +93,18 @@ describe("LineItems.Table", () => { expect(screen.getByText("70")).toBeTruthy(); }); + it("renders skeleton rows in place of data rows when loading is true", () => { + const { container, rerender } = render(); + const skeletons = container.querySelectorAll('[data-slot="line-items-skeleton-row"]'); + expect(skeletons.length).toBe(5); + // No real data rows while loading (virtualizer rows would be `data-slot="table-row"`). + expect(container.querySelectorAll('[data-slot="table-row"]').length).toBe(0); + + // Toggling loading off removes the skeleton rows. + rerender(); + expect(container.querySelectorAll('[data-slot="line-items-skeleton-row"]').length).toBe(0); + }); + it("registers a trailing pinned-right column when rowActions prop is set", () => { const { container } = render(); // 3 field columns (SKU + Qty + Total) + 1 actions + 1 trailing spacer = 5 ths. diff --git a/packages/core/src/components/line-items/line-items-table.tsx b/packages/core/src/components/line-items/line-items-table.tsx index 263012a1..eba3c2e5 100644 --- a/packages/core/src/components/line-items/line-items-table.tsx +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -26,6 +26,7 @@ import { } from "./line-items-grid-context"; import { LineItemsFullscreenToggle } from "./line-items-parts"; import { useLineItemsRoot } from "./line-items-root"; +import { LineItemsSkeletonRow } from "./line-items-skeleton-row"; import { getInternals } from "./use-line-items"; import { coordsToRowsMatrix, @@ -64,6 +65,15 @@ export type LineItemsTableProps = rowActions?: (line: T) => React.ReactNode; /** Width in px of the row-actions trailing column. Default `80`. */ rowActionsWidth?: number; + /** + * When true, the tbody renders shimmering skeleton rows in place of real + * data rows. All other table chrome (header, search, fullscreen toggle, + * floating dock) keeps rendering normally so the layout doesn't flash. Use + * for an initial async fetch. + */ + loading?: boolean; + /** Number of skeleton rows to render while `loading` is true. Default `12`. */ + skeletonRowCount?: number; }; export function LineItemsTable(props: LineItemsTableProps) { @@ -80,6 +90,8 @@ export function LineItemsTable(props: LineItemsTable * snugly. Bump for more icons or wider buttons; lower for one icon. */ rowActionsWidth = 64, + loading = false, + skeletonRowCount = 12, } = props; const root = useLineItemsRoot(); @@ -100,6 +112,12 @@ export function LineItemsTable(props: LineItemsTable // every input, dropping focus after a single character. const hookRef = React.useRef(hook); hookRef.current = hook; + // `rowActions` is typically inlined in JSX (`rowActions={(line) => ...}`), + // so its identity changes every render. Route through a ref so the column + // memo below isn't busted on every keystroke (which would re-mount every + // cell input and drop focus mid-typing). + const rowActionsRef = React.useRef(rowActions); + rowActionsRef.current = rowActions; const selectionEnabled = hook.selectionEnabled; /* ---- Cell-selection state ------------------------------------------- */ @@ -115,6 +133,44 @@ export function LineItemsTable(props: LineItemsTable const scrollParentRef = React.useRef(null); const rowElRefs = React.useRef(new Map()); + /* ---- Fast-scroll detection ---------------------------------------------- + Toggles a `data-line-items-fast-scroll` attribute on the scroll container + when the user is scroll-flicking, and removes it 120ms after the last + scroll event. Cells are visually swapped to a pulse skeleton via CSS so + no React re-render fires on transitions. */ + const lastScrollTopRef = React.useRef(0); + const lastScrollAtRef = React.useRef(0); + const scrollIdleTimerRef = React.useRef(null); + const [isFastScrolling, setIsFastScrolling] = React.useState(false); + React.useEffect(() => { + const el = scrollParentRef.current; + if (!el) return undefined; + lastScrollTopRef.current = el.scrollTop; + lastScrollAtRef.current = performance.now(); + const onScroll = () => { + const now = performance.now(); + const dt = Math.max(now - lastScrollAtRef.current, 1); + const dy = Math.abs(el.scrollTop - lastScrollTopRef.current); + const velocity = dy / dt; // px/ms + lastScrollTopRef.current = el.scrollTop; + lastScrollAtRef.current = now; + if (velocity > 0.6) setIsFastScrolling(true); + if (scrollIdleTimerRef.current != null) window.clearTimeout(scrollIdleTimerRef.current); + scrollIdleTimerRef.current = window.setTimeout(() => { + setIsFastScrolling(false); + scrollIdleTimerRef.current = null; + }, 120); + }; + el.addEventListener("scroll", onScroll, { passive: true }); + return () => { + el.removeEventListener("scroll", onScroll); + if (scrollIdleTimerRef.current != null) { + window.clearTimeout(scrollIdleTimerRef.current); + scrollIdleTimerRef.current = null; + } + }; + }, []); + /* ---- Schema ids + Tanstack column defs ------------------------------ */ const schemaColumnIds = React.useMemo(() => fields.map((f) => f.key), [fields]); @@ -135,16 +191,16 @@ export function LineItemsTable(props: LineItemsTable live.lines.length > 0 && live.selectedIds.length === live.lines.length; return (
- { - if (e.target.checked) hookRef.current.selectAllVisible(); - else hookRef.current.clearSelection(); - }} - className="astw:size-4" - /> + { + if (e.target.checked) hookRef.current.selectAllVisible(); + else hookRef.current.clearSelection(); + }} + className="astw:size-4" + />
); }, @@ -225,9 +281,13 @@ export function LineItemsTable(props: LineItemsTable cols.push({ id: "__actions", header: "", + // Read from `rowActionsRef.current` so a parent passing a fresh inline + // function each render doesn't bust this memo. Presence/absence of the + // column itself is still tracked via `!!rowActions` in the memo deps + // (next line) — only the per-row content swaps freely. cell: ({ row }) => ( -
- {rowActions(row.original)} +
+ {rowActionsRef.current?.(row.original)}
), size: rowActionsWidth, @@ -235,7 +295,11 @@ export function LineItemsTable(props: LineItemsTable } return cols; - }, [enableDragReorder, fields, mode, ordering, selectionEnabled, rowActions, rowActionsWidth]); + // `rowActions` -> `!!rowActions` so adding/removing the prop still + // rebuilds the columns; identity changes within the same on/off state are + // absorbed by `rowActionsRef`. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableDragReorder, fields, mode, ordering, selectionEnabled, !!rowActions, rowActionsWidth]); const table = useReactTable({ data, @@ -326,7 +390,9 @@ export function LineItemsTable(props: LineItemsTable rightAcc += f.width ?? 0; } return offsets; - }, [enableDragReorder, fields, mode, ordering, rowActions, rowActionsWidth, selectionEnabled]); + // `rowActions` -> `!!rowActions`: only its presence affects the offset map. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableDragReorder, fields, mode, ordering, !!rowActions, rowActionsWidth, selectionEnabled]); const getPinStyle = React.useCallback( (colId: string): React.CSSProperties | undefined => { @@ -721,8 +787,7 @@ export function LineItemsTable(props: LineItemsTable // Single-source broadcast: when the clipboard holds exactly one cell and // the active selection covers more than one cell, fan the single value // out to every selected cell (Excel / Sheets behaviour). - const isSingleSource = - gridParsed.length === 1 && (gridParsed[0]?.length ?? 0) === 1; + const isSingleSource = gridParsed.length === 1 && (gridParsed[0]?.length ?? 0) === 1; if (isSingleSource && selectionCoordsMemo.length > 1) { const raw = gridParsed[0]![0]!; for (const { lineRef, columnId } of selectionCoordsMemo) { @@ -848,8 +913,7 @@ export function LineItemsTable(props: LineItemsTable const tableMinWidth = React.useMemo(() => { let total = 0; if (selectionEnabled && mode !== "display") total += SELECT_COL_WIDTH; - if (enableDragReorder && ordering === "manual" && mode !== "display") - total += DRAG_COL_WIDTH; + if (enableDragReorder && ordering === "manual" && mode !== "display") total += DRAG_COL_WIDTH; for (const f of fields) { if (f.flex) { total += f.width ?? FLEX_FALLBACK_WIDTH; @@ -859,7 +923,9 @@ export function LineItemsTable(props: LineItemsTable } if (rowActions) total += rowActionsWidth; return total; - }, [enableDragReorder, fields, mode, ordering, rowActions, rowActionsWidth, selectionEnabled]); + // `rowActions` -> `!!rowActions`: only its presence affects the total width. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableDragReorder, fields, mode, ordering, !!rowActions, rowActionsWidth, selectionEnabled]); const vItems = rowVirtualizer.getVirtualItems(); // True when no field absorbs leftover horizontal space; in that case the // table renders a trailing spacer `` + matching cells. Same condition @@ -893,9 +959,11 @@ export function LineItemsTable(props: LineItemsTable } > + {isFastScrolling ? : null}
(props: LineItemsTable // Special bookkeeping columns get pinned widths. if (colId === "__select") return ( - + ); if (colId === "__drag") - return ; + return ( + + ); if (colId === "__actions") return ( - + ); const f = fieldByKey.get(colId); if (!f) return ; @@ -1046,9 +1128,7 @@ export function LineItemsTable(props: LineItemsTable // drop the inner padding so their content can // center pixel-perfect against the cell box — // matching the body cells which use `p-0`. - colId.startsWith("__") - ? "astw:px-0" - : "astw:px-2", + colId.startsWith("__") ? "astw:px-0" : "astw:px-2", )} style={pinStyle} onMouseEnter={() => onColumnHoverEnter(colId)} @@ -1070,67 +1150,78 @@ export function LineItemsTable(props: LineItemsTable ))} - {padTop > 0 ? ( + {loading + ? Array.from({ length: skeletonRowCount }, (_, i) => ( + + )) + : null} + {!loading && padTop > 0 ? ( ) : null} - {vItems.map((vi) => { - const row = allRows[vi.index] as Row | undefined; - if (!row) return null; - return ( - { - rowElRefs.current.set(row.original.lineRef, el); - }} - className={cn( - "astw:data-[state=selected]:bg-muted astw:border-b astw:border-border", - )} - style={{ height: vi.size }} - onDragOver={(e) => { - if (!enableDragReorder || ordering !== "manual") return; - e.preventDefault(); - e.stopPropagation(); - e.dataTransfer.dropEffect = "move"; - }} - onDrop={(e) => { - e.stopPropagation(); - handleDropOnRow(row.original.lineRef, e); - }} - > - {row.getVisibleCells().map((cell) => { - const colId = cell.column.id; - const pinStyle = getPinStyle(colId); - return ( - onColumnHoverEnter(colId)} - onMouseLeave={() => onColumnHoverLeave(colId)} - > - {/* + {!loading && + vItems.map((vi) => { + const row = allRows[vi.index] as Row | undefined; + if (!row) return null; + return ( + { + rowElRefs.current.set(row.original.lineRef, el); + }} + className={cn( + "astw:data-[state=selected]:bg-muted astw:border-b astw:border-border", + )} + style={{ height: vi.size }} + onDragOver={(e) => { + if (!enableDragReorder || ordering !== "manual") return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={(e) => { + e.stopPropagation(); + handleDropOnRow(row.original.lineRef, e); + }} + > + {row.getVisibleCells().map((cell) => { + const colId = cell.column.id; + const pinStyle = getPinStyle(colId); + return ( + onColumnHoverEnter(colId)} + onMouseLeave={() => onColumnHoverLeave(colId)} + > + {/* Cell content renders directly inside the as a relative flex box. No absolute-positioned wrapper → the cell box, the input, and the selection overlay all share identical bounds. Selection rectangle is painted via an inset box-shadow on the shell so it matches edges pixel-for-pixel without any layout displacement. */} - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ); - })} - {/* Trailing spacer cell — matches the trailing in colgroup. */} - {renderTrailingSpacer ? ( - - ) : null} - - ); - })} - {padBot > 0 ? ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + {/* Trailing spacer cell — matches the trailing in colgroup. */} + {renderTrailingSpacer ? ( + + ) : null} + + ); + })} + {!loading && padBot > 0 ? ( @@ -1201,3 +1292,32 @@ function coerceForField( /* text + select: commit trimmed string (select options validated in UI, not here) */ return raw.trim(); } + +/* ======================================================================== */ +/* Fast-scroll skeleton CSS */ +/* */ +/* Applied via an inline `