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-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-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-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..2d87aeed --- /dev/null +++ b/.changeset/line-items-floating-dock.md @@ -0,0 +1,35 @@ +--- +"@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-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-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/.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-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-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-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-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-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-row-actions.md b/.changeset/line-items-row-actions.md new file mode 100644 index 00000000..058be334 --- /dev/null +++ b/.changeset/line-items-row-actions.md @@ -0,0 +1,25 @@ +--- +"@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-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-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/.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/.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 e79641bd..01e443a1 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,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/docs/components/bulk-item-picker.md b/docs/components/bulk-item-picker.md new file mode 100644 index 00000000..8140bd3a --- /dev/null +++ b/docs/components/bulk-item-picker.md @@ -0,0 +1,196 @@ +--- +title: BulkItemPicker +description: Generic tree-select dialog for multi-selecting from a hierarchical list — products with variants, accounts with sub-accounts, categories with tags. Decoupled from LineItems. +--- + +# BulkItemPicker + +`BulkItemPicker` is a render-prop dialog that lets the user multi-select leaves from a tree of items, with a search input, tri-state checkboxes on parents, and an "Add N items" CTA. It is **decoupled from `LineItems`** — it can drive any flow that needs a multi-select-from-tree (bulk-add line items, bulk-tag, wizard step picker, etc.). + +The picker owns selection state, search filtering, expand / collapse, and the dialog chrome. The consumer fully controls per-row visuals via `renderRow` and `renderMetric`. + +## Import + +```tsx +import { + BulkItemPicker, + type BulkItemPickerNode, + type BulkItemPickerProps, +} from "@tailor-platform/app-shell"; +``` + +## Basic usage + +```tsx +type Product = { 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 } }, + ], + }, +]; + +function PickerExample() { + const [open, setOpen] = React.useState(false); + + return ( + <> + + + open={open} + onOpenChange={setOpen} + title="Bulk line item picker" + rowLabel="Product Name" + metricLabel="Total available" + items={tree} + renderRow={(node) => ( + {node.data.name} + )} + renderMetric={(node) => node.data.available} + matchesSearch={(node, q) => + node.data.name.toLowerCase().includes(q.toLowerCase()) + } + onCommit={(leaves) => { + // leaves is the selected leaf nodes in tree order. + lineItems.addLines(leaves.map((n) => ({ sku: n.id, ... }))); + }} + /> + + ); +} +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------- | --------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `open` | `boolean` | **Required** | Controls dialog visibility. | +| `onOpenChange` | `(open: boolean) => void` | **Required** | Fires on user-driven open / close (Esc, backdrop click, Cancel, after commit). | +| `items` | `ReadonlyArray>` | **Required** | Hierarchical items. A node is a leaf if `children` is `undefined` or empty. | +| `renderRow` | `(node, depth) => ReactNode` | **Required** | Renders the row content (the picker draws the checkbox + metric). | +| `onCommit` | `(selected: BulkItemPickerNode[]) => void` | **Required** | Receives the selected **leaf** nodes in tree order. Picker closes itself after the call. | +| `title` | `ReactNode` | `"Select items"` | Dialog title. | +| `rowLabel` | `ReactNode` | `"Item"` | Header label above the row content column. | +| `metricLabel` | `ReactNode` | — | Header label above the right-aligned metric column. | +| `renderMetric` | `(node, depth) => ReactNode` | — | Optional right-aligned metric (e.g. "Total available"). | +| `matchesSearch` | `(node, query) => boolean` | Default: case-insensitive substring on `String(node.data)` | Search predicate. Override for object payloads. | +| `filterSlot` | `ReactNode` | — | Render-prop slot to the right of the search input — host a category / tag filter UI here. | +| `ctaLabel` | `(count: number) => string` | `"Add ${count} items"` | Customise the primary CTA label. | +| `emptyText` | `ReactNode` | `"No matching items."` | Shown when search has no matches. | +| `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. | + +### `BulkItemPickerNode` + +```ts +type BulkItemPickerNode = { + id: string; + data: T; + children?: BulkItemPickerNode[]; +}; +``` + +- `id` must be unique across the entire tree (it doubles as the selection key). +- `data` is opaque — the picker only passes it to `renderRow`, `renderMetric`, and `matchesSearch`. +- A node with `undefined` or empty `children` is a **leaf**. Selection is leaf-only. + +## Behaviour + +### Selection model + +- Selection is stored as a `Set` of leaf ids. +- Clicking a parent's checkbox toggles every descendant leaf — selects all if any are unselected, deselects all if all are selected. +- Parents render a tri-state checkbox: unchecked / `indeterminate` / checked, derived from descendants. +- Selection resets when the dialog closes — each open starts fresh. + +### Search + +- Empty query → full tree visible. +- Non-empty query → a node survives if it matches OR any of its descendants does. +- Parents whose descendants match auto-expand (so the matching variants are reachable without manual expand). +- Override `matchesSearch` when `data` is an object (the default matcher stringifies, which is rarely what you want). + +### Keyboard + +- `Esc` — closes (Dialog default). +- `Enter` — commits if any leaves are selected. Suppressed inside the search input so typing doesn't accidentally submit. + +### Commit + +- `onCommit` fires with leaf nodes in **DFS tree order** (matches what the user visually selected top-to-bottom). +- The dialog closes itself after `onCommit`. The consumer should not call `onOpenChange(false)` separately. +- The CTA is disabled while no leaves are selected. + +## Common pairings + +### With `useLineItems().addLines` + +The canonical pairing. Each leaf becomes one line, committed in a single render: + +```tsx + + lineItems.addLines( + picks.map((n) => ({ + sku: n.id, + productName: n.data.productName, + quantity: 1, + unitPrice: n.data.unitPrice, + })), + ) + } +/> +``` + +### Async catalog + +For server-paginated catalogs, fetch upstream and pass the static `items` once loaded: + +```tsx +const { data: tree, isLoading } = useCatalog(); + + +``` + +The picker doesn't bake in fetching so consumers can use whatever data layer they already have. + +### Custom filter button + +Use `filterSlot` to host a filter trigger next to the search input. The picker doesn't dictate the filter UI: + +```tsx + + + + } +/> +``` + +Apply the filter outside the picker by narrowing the `items` prop accordingly. + +## Tips + +- The picker's height is capped at `80vh`. For very deep trees, prefer flattening with `searchableText` over forcing the user to expand many parents — the auto-expand on search already handles most discovery. +- If you need single-select instead, gate `onCommit` to the first leaf or use `Combobox` directly. +- `id` collisions across the tree silently break selection. If your data has duplicate keys at different levels, prefix them on construction (e.g. `${parentId}-${leafId}`). + +## Related + +- `Dialog` — used internally for the modal chrome. +- `LineItems` — `addLines` is the canonical sink for picker output. +- `Combobox` — for single-select-from-flat-list. diff --git a/docs/components/data-table.md b/docs/components/data-table.md index e20d138e..c0dd302e 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -229,24 +229,24 @@ A column definition passed to `useDataTable`. The `filter` property on a column accepts a `FilterConfig` object. When set, the column appears as an option in `DataTable.Filters` and the filter chip renders an input editor appropriate for the type. -| Property | Type | Description | -| --------- | ---------------- | ---------------------------------------------------------------------------- | -| `field` | `string` | API field name used in the generated query input. | -| `type` | `FilterType` | Filter editor type (see table below). | -| `options` | `SelectOption[]` | Required when `type` is `"enum"`. List of selectable values. | +| Property | Type | Description | +| --------- | ---------------- | ------------------------------------------------------------ | +| `field` | `string` | API field name used in the generated query input. | +| `type` | `FilterType` | Filter editor type (see table below). | +| `options` | `SelectOption[]` | Required when `type` is `"enum"`. List of selectable values. | ### Filter Types and Operators -| Type | Input editor | Supported operators | -| ---------- | -------------------- | -------------------------------------------------------------------------------------------------------------- | -| `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | -| `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `date` | Date | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | -| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | -| `boolean` | Toggle | `eq`, `ne` | -| `uuid` | Text | `eq`, `ne`, `in`, `nin` | +| Type | Input editor | Supported operators | +| ---------- | -------------- | ------------------------------------------------------------------------------------------------------------ | +| `string` | Text | `eq`, `ne`, `contains`, `notContains`, `hasPrefix`, `hasSuffix`, `notHasPrefix`, `notHasSuffix`, `in`, `nin` | +| `number` | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `datetime` | Datetime-local | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `date` | Date | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `time` | Time | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, **`between`**, `in`, `nin` | +| `enum` | Dropdown | `eq`, `ne`, `in`, `nin` | +| `boolean` | Toggle | `eq`, `ne` | +| `uuid` | Text | `eq`, `ne`, `in`, `nin` | When the user selects the `between` operator on a `number`, `datetime`, `date`, or `time` column, the filter chip renders a range input with **min** and **max** bounds. diff --git a/docs/components/line-items.md b/docs/components/line-items.md new file mode 100644 index 00000000..9d5ebca2 --- /dev/null +++ b/docs/components/line-items.md @@ -0,0 +1,356 @@ +--- +title: LineItems +description: Spreadsheet-grade table for editing rows of a document — purchase orders, invoices, journals, work orders. One hook owns state; a compound family renders the UI. +--- + +# LineItems + +`LineItems` is a document-line editing surface designed for ERP-shaped data: purchase orders, sales orders, invoices, journal entries, work orders, stock transfers. State lives in a single `useLineItems` hook; UI is rendered by a compound family (`LineItems.Root`, `LineItems.Table`, etc.) that consumes the hook through context. + +The component is intentionally **transport-agnostic** — it does not couple to GraphQL, REST, or any specific server contract. The hook produces a normalised `ChangeSet` that the consumer translates into their own document mutation. + +## Import + +```tsx +import { + LineItems, + useLineItems, + useLineItemsGroup, + createLineItemHelper, + lineItemsFloatingBarStyles, + type LineItemsField, + type LineItemsRowData, + type LineItemsMode, + type UseLineItemsReturn, + type LineItemsChangeSet, +} from "@tailor-platform/app-shell"; +``` + +## Basic usage + +Two pieces: declare a row shape + field schema, then mount a `Root` + `Table`. + +```tsx +type POLine = LineItemsRowData & { + sku: string; + productName: string; + quantity: number; + unitPrice: number; +}; + +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" }, + width: 240, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 100, + }), + f.field({ + key: "unitPrice", + label: "Unit price", + render: (l) => l.unitPrice.toFixed(2), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + width: 120, + }), +]; + +function PoLines() { + const lineItems = useLineItems({ + fields, + data: initialLines, + mode: "edit", + selection: true, + }); + + const onSave = () => { + const cs = lineItems.getChangeSet(); + if (cs.isEmpty) return; + // dispatch cs.lineChanges to your mutation, then: + lineItems.reset(); + }; + + return ( + + + + + > + {({ bulkRemove, clear }) => ( + <> + + + + )} + + + + ); +} +``` + +## `useLineItems(options)` + +| Option | Type | Default | Description | +| ------------------ | -------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| `fields` | `LineItemsField[]` | **Required** | Schema — declares columns, types, editors, validators. | +| `data` | `T[]` | `[]` | Initial row data. Becomes the dirty-tracking baseline. | +| `mode` | `"edit" \| "display" \| "amend"` | `"edit"` | Controls editability per field. `"amend"` paints a read-only tint on rows already saved. | +| `selection` | `boolean` | `false` | When true, a checkbox column renders and `selectedIds` / `bulkUpdate` / `bulkRemove` become available. | +| `ordering` | `"sort" \| "manual"` | `"sort"` | `"manual"` enables drag-to-reorder and emits `reorder` ops in the change set. | +| `lines` | `T[]` | — | Optional controlled mode: provide both `lines` and `onLinesChange` to drive state externally. | +| `onLinesChange` | `(lines: T[]) => void` | — | Required when `lines` is set. | +| `onMetadataCommit` | `(event) => void` | — | Fires when a `commit: "metadata"` field changes; lets the consumer fire a side-effect mutation outside the document change-set. | + +### Return shape (selected fields) + +| Field | Description | +| ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | +| `lines` | Filtered + sorted rows (the rendered set). | +| `allLines` | Unfiltered rows in current order. | +| `isDirty` | `true` when current state differs from baseline. | +| `getChangeSet()` | Returns `{ isEmpty, lineChanges: [...] }` — the platform-spec change set. | +| `revert()` | Restores all rows to baseline (use for "Discard"). | +| `reset()` | Snaps baseline forward (use after a successful save). | +| `addLine(partial, opts?)` | Insert a single row; returns the new `lineRef`. | +| `addLines(items, opts?)` | Batch insert N rows in one render. Returns `lineRef[]`. | +| `removeLine(lineRef)` | Mark a row removed (or drop it if it was inserted client-side). | +| `updateField(lineRef, key, value)` | Single-cell typed update. | +| `updateLines(patches)` | Batched updates (one render). | +| `reorderLine(lineRef, after)` | Manual ordering only. | +| `selectedIds`, `toggleSelect`, `selectAllVisible`, `clearSelection`, `bulkUpdate`, `bulkRemove` | Selection API — only meaningful with `selection: true`. | +| `setFilter(query)`, `filter` | In-component search — fields with a `search` callback contribute. | + +### `LineItemsField` + +| Prop | Type | Description | +| ------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `key` | `keyof T` | The data field this column renders / commits. | +| `label` | `ReactNode` | Header text. | +| `render` | `(line: T) => ReactNode` | Read-only display in non-edit modes. Also used for the live cell view in `display` mode. | +| `editable` | `("edit" \| "amend")[]` | Modes where the cell is editable. Omit for a read-only column. | +| `type` | `{ kind: "text" \| "number" \| "select" \| "boolean" \| "date" \| "custom"; ... }` | Editor flavour + per-kind options. | +| `width` | `number` | Pixel width. Pinned and right-aligned columns require an explicit width. | +| `flex` | `boolean` | Column absorbs leftover horizontal space. At most one `flex` per schema. | +| `pinned` | `"left" \| "right"` | Sticky position during horizontal scroll. | +| `hoverExpandWidth` | `number` | When the column is hovered, it grows to this width and the table expands rather than shrinking other columns. | +| `align` | `"left" \| "right" \| "center"` | Cell alignment. Numerics default to right. | +| `className` | `string \| (line: T) => string` | Per-cell class — useful for invalid-state highlighting. | +| `commit` | `"document" \| "metadata"` | `"metadata"` skips the change-set (use for fields like a per-row note that submits via a separate mutation). | +| `equals` | `(a: T[K], b: T[K]) => boolean` | Override equality for dirty-tracking (useful for arrays / id-keyed objects). | +| `normalize` | `(value: T[K]) => T[K]` | Coerces stored value before equality (trim, round, etc.). | +| `sort` | `{ comparator: (a: T, b: T) => number }` | Enable header-click sorting. | +| `search` | `(line: T, query: string) => boolean` | Contribute to the in-table search filter. | + +### Field types + +| `type.kind` | Editor | Notes | +| ----------- | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| `"text"` | `` | Default. `select()` on focus. Commits on blur. | +| `"number"` | `` | `decimals?` rounds + drives tolerance equality. Default `align: "right"`. Native spinner buttons hidden. | +| `"select"` | `Combobox` | `options: { value, label, description? }[]` and optional `placeholder`. Two-line render via `description`. | +| `"boolean"` | `` | `trueLabel?` / `falseLabel?` for the read-only span. Default `align: "center"`. | +| `"date"` | `` | ISO `yyyy-mm-dd`. Empty value commits as `null`. `min?` / `max?`. | +| `"custom"` | `field.type.renderEditor(ctx)` | Escape hatch. Pair with `normalize` / `equals` to keep dirty-tracking honest. | + +## Compound parts + +### `` + +Required wrapper. Pass the hook return value as `value`. Provides context for every other compound part. + +| Prop | Type | Description | +| ----------- | ----------------------- | --------------------- | +| `value` | `UseLineItemsReturn` | The hook's return. | +| `className` | `string` | Optional outer class. | + +### `` + +The data grid. Renders header, virtualised rows, sticky-pinned columns, the cell selection rectangle, fill-drag handle, copy/paste, and the trailing actions column. + +| Prop | Type | Default | Description | +| ------------------------- | ------------------------ | -------------------- | ----------------------------------------------------------- | +| `maxBodyHeight` | `CSSValue` | `"min(60vh, 480px)"` | Vertical scroll threshold. | +| `renderFullscreenToggle` | `boolean` | `true` | Shows the built-in expand button at the table's top-right. | +| `enableDragReorder` | `boolean` | `false` | Drag-to-reorder rows (only when hook `ordering: "manual"`). | +| `emptyMessage` | `ReactNode` | `"No lines yet."` | Empty-state copy. | +| `rowActions` | `(line: T) => ReactNode` | — | Trailing per-row actions. Auto-pinned right. | +| `rowActionsWidth` | `number` | `64` | Pixel width of the actions column. | +| `loading` | `boolean` | `false` | When true, tbody renders skeleton rows in place of data. | +| `skeletonRowCount` | `number` | `12` | Number of skeleton rows while `loading` is true. | +| `tableContainerClassName` | `string` | — | Applied to the outer scroll container. | +| `className` | `string` | — | Applied to the inner table root. | + +### `` / `` + +Type-to-filter input. `SearchToggle` is the collapsible variant for card headers; `Search` is the always-open variant. + +| Prop | Type | Description | +| --------------------------------------------------- | -------- | -------------------------------- | +| `placeholder` | `string` | Input placeholder. | +| `variant`, `triggerSizeClassName`, `collapsedWidth` | — | Visual props for `SearchToggle`. | + +### `` + +Standalone trigger for the same fullscreen mode that the built-in toggle on `Table` exposes. Useful when you want it placed elsewhere. + +### `` + +Convenience row at the bottom of the table that wraps ``. For richer add-flows (variant pickers, custom catalog), build the row inline using your own `` plus `lineItems.addLine(...)`. + +### `` + +Discard + Save buttons reading from `isDirty`. Discard defaults to `revert()`; Save calls the consumer's `onSave`. Use this when the page-level header doesn't already host the actions. + +### `` + +Sticky footer row; render-prop receives the live `allLines` array and returns one value per column key. + +```tsx +> + {(lines) => ({ + quantity: lines.reduce((s, l) => s + l.quantity, 0), + amount: ${lines.reduce((s, l) => s + l.amount, 0).toFixed(2)}, + })} + +``` + +### Floating dock parts + +Bottom-center fixed dock for dirty + selection states. Auto-mounts when the relevant hook state is non-empty. + +```tsx + + + > + {({ selectedIds, bulkRemove, clear }) => ( + <> + + + + )} + + +``` + +| Part | Behaviour | +| ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FloatingDock` | Fixed bottom-center container. `pointer-events: none` outer, `auto` inner so click-through works around the bars. | +| `DirtyBar` | Shows when `isDirty` is true. Discard defaults to `revert()`; Save calls `onSave`. `warnOnNav` intercepts in-app anchor clicks + `beforeunload` and jiggles the bar to draw attention. | +| `SelectionBar` | Render-prop. Shows when `selectedIds.length > 0`. Library renders the count pill + divider; consumer plugs in domain actions. | + +`lineItemsFloatingBarStyles` exports `{ primaryButton, secondaryButton, divider, label }` — use these so consumer-built buttons inside `SelectionBar` visually match the `DirtyBar` defaults. + +## Modes + +| Mode | Behaviour | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `"edit"` | Default. All editable fields render their editors. | +| `"display"` | Read-only. Cells render `field.render(line)`. No editors mount. | +| `"amend"` | Editable rows that exist in the baseline are tinted; only fields with `editable: ["amend"]` are mutable. Use for post-confirmation correction flows. | + +## Pinned columns + +```tsx +f.field({ key: "sku", pinned: "left", width: 160, ... }), +f.field({ key: "productName", pinned: "left", width: 240, ... }), +``` + +- Pinned columns require an explicit `width`. +- Order in the schema is preserved; pinning only affects positioning. +- The `__select` checkbox column auto-pins left when present. +- The `__actions` column (from `rowActions`) auto-pins right. + +## Multi-collection documents — `useLineItemsGroup` + +Composes multiple `useLineItems` hooks under one document-level submit boundary. Used for Journal Entry (debits + credits) and Work Order (components + operations). + +```tsx +const debits = useLineItems({ fields, data: seedDebits }); +const credits = useLineItems({ fields, data: seedCredits }); + +const group = useLineItemsGroup({ debits, credits }); + +const onSave = () => { + const cs = group.getChangeSet(); + if (cs.isEmpty) return; + // cs = { isEmpty, debits: ChangeSet, credits: ChangeSet } + // fan out to your mutations, then: + group.reset(); +}; +``` + +`group.isDirty` = OR across members. `group.revert()` / `group.reset()` fan out to every member. + +## Change-set shape + +`getChangeSet()` returns the platform PRD-aligned shape: + +```ts +type LineItemsChangeSet = { + isEmpty: boolean; + lineChanges: Array< + | { action: "add"; tempId: string; data: Partial } + | { action: "update"; lineId: string; patch: Partial } + | { action: "remove"; lineId: string } + | { action: "reorder"; lineId: string; position: number } + >; +}; +``` + +The consumer translates this into their own mutation shape — the component does not assume GraphQL, REST, or any specific server contract. + +## Skeleton loader + +Two scenarios: + +**Initial fetch** — opt-in via the `loading` prop: + +```tsx + +``` + +When `loading` is true, the tbody renders shimmering placeholder rows. Header, search, fullscreen toggle, and the floating dock keep rendering normally so the layout doesn't flash. + +**Fast-scroll** (automatic) — during a scroll-flick (>600 px/sec), cells visually swap to a pulse bar via a CSS attribute 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 flick that returns to the editing cell. + +## Spreadsheet behaviours + +- **Range selection** — click + drag, shift-click to extend, Ctrl/Cmd-A to select all visible. +- **Fill-drag** — drag the small handle on the focused cell down/up to fill the column. +- **TSV copy/paste** — copy selected cells with `Cmd/Ctrl+C`, paste tab-separated values with `Cmd/Ctrl+V`. A 1-cell paste broadcasts to every cell in the active selection (Excel parity). +- **Keyboard nav** — arrow keys, Enter (commit + move down), Tab (commit + move right with row-wrap), Esc (revert local edit). + +## Tips + +- Cells **commit on blur**, not on every keystroke. Don't expect `getChangeSet()` to reflect a partially-typed value mid-edit. +- For a derived column (like `total = qty × unitPrice`), compute it inside the field's `render` rather than syncing it back into the row data via a `useEffect`. The latter regresses typing performance with large datasets. +- For "Discard" buttons, call `revert()`. `reset()` is for after a successful save. +- Pinned columns and `flex` columns are mutually exclusive concepts but can coexist in one schema — pin the leftmost identity columns, let one wide column flex, leave the rest at fixed widths. +- The `kind: "custom"` editor is the right tool for async pickers (server-backed combobox), composite editors (qty + uom), and inline sub-tables. Pair with `normalize` and `equals` so dirty-tracking stays honest. + +## Related + +- `BulkItemPicker` — generic tree-select dialog often paired with `addLines` for bulk-add flows. +- `Combobox` — used inside the default `select`-kind cell editor. +- `Card` — the recommended frame for hosting a `LineItems` table on a detail page. 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/nextjs-app/src/modules/custom-module.tsx b/examples/nextjs-app/src/modules/custom-module.tsx index a0223964..3466fa57 100644 --- a/examples/nextjs-app/src/modules/custom-module.tsx +++ b/examples/nextjs-app/src/modules/custom-module.tsx @@ -20,6 +20,12 @@ 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"; +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"; import { dataTableDemoResource } from "./pages/data-table-demo"; export const customPageModule = defineModule({ @@ -194,6 +200,60 @@ export const customPageModule = defineModule({ CSV Importer Demo

+

+ + Line items (document lines) + +

+

+ + Journal entry (group helper) + +

+

+ + Sales invoice (totals row) + +

+

+ + Goods receipt (pinned columns) + +

+

+ + Work order (multi-collection) + +

+

+ + Stock transfer (row actions) + +

(); + +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/nextjs-app/src/modules/pages/journal-entry-demo.tsx b/examples/nextjs-app/src/modules/pages/journal-entry-demo.tsx new file mode 100644 index 00000000..04bca187 --- /dev/null +++ b/examples/nextjs-app/src/modules/pages/journal-entry-demo.tsx @@ -0,0 +1,299 @@ +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/nextjs-app/src/modules/pages/layout-demos.tsx b/examples/nextjs-app/src/modules/pages/layout-demos.tsx index ed84b70d..f2cb52d5 100644 --- a/examples/nextjs-app/src/modules/pages/layout-demos.tsx +++ b/examples/nextjs-app/src/modules/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/nextjs-app/src/modules/pages/line-items-demo.tsx b/examples/nextjs-app/src/modules/pages/line-items-demo.tsx new file mode 100644 index 00000000..f26055ba --- /dev/null +++ b/examples/nextjs-app/src/modules/pages/line-items-demo.tsx @@ -0,0 +1,673 @@ +import * as React from "react"; +import { + ActionPanel, + ActivityCard, + BulkItemPicker, + Button, + Card, + Combobox, + DescriptionCard, + Layout, + LineItems, + createLineItemHelper, + defineResource, + lineItemsFloatingBarStyles, + useLineItems, + type BulkItemPickerNode, + 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"; + +const ModeIcon = (props: React.SVGProps) => ( + + + + +); + +/* ======================================================================== */ +/* Domain */ +/* ======================================================================== */ + +type POLine = LineItemsRowData & { + sku: string; + productName: string; + quantity: number; + unitPrice: number; + total: number; + expectedReady: 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, + 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); + +/* ======================================================================== */ +/* 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()), + width: 240, + }), + 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 }, + width: 90, + }), + 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 }, + width: 110, + }), + f.field({ + key: "total", + label: "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, + }), + 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, + }), +]; + +/* ======================================================================== */ +/* 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)); + // 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, + productName: base.productName, + quantity, + unitPrice, + total: round2(quantity * unitPrice), + expectedReady: iso, + }); + } + 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 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, + data: initialLines, + mode, + selection: true, + }); + + React.useEffect(() => { + if (!onApiChange) return; + onApiChange({ mode, setMode }); + }, [mode, onApiChange]); + + const handleSave = React.useCallback(() => { + const cs = lineItems.getChangeSet(); + 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(); + }, [lineItems]); + + return ( + <> + {/* 🧪 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 + 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

+

+ {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), + expectedReady: new Date().toISOString().slice(0, 10), + }); + }} + 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. */} + + void handleSave()} /> + > + {({ bulkRemove, clear }) => ( + <> + + + + )} + + +
+ + ); +} + +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"); + }, []); + + // 🧪 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 = [ + , + , + ]; + + 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"), + }, + ]; + + const modeActions = sectionApi + ? (["edit", "display", "amend"] as const).map((m) => ({ + key: `mode-${m}`, + label: `${sectionApi.mode === m ? "✓ " : ""}${m[0]!.toUpperCase()}${m.slice(1)} mode`, + icon: , + onClick: () => sectionApi.setMode(m), + disabled: sectionApi.mode === m, + })) + : []; + + const demoActions = sectionApi ? modeActions : []; + + return ( + + + + + + + + + + {demoActions.length ? : null} + + + + ); +} + +/* ======================================================================== */ +/* Inline catalogue add-row */ +/* ======================================================================== */ + +function InlineCatalogueAddRow({ + onPick, + onBulkAdd, + disabled, +}: { + onPick: (item: CatalogItem) => void; + onBulkAdd: () => 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" + /> + +
+ ); +} diff --git a/examples/nextjs-app/src/modules/pages/sales-invoice-demo.tsx b/examples/nextjs-app/src/modules/pages/sales-invoice-demo.tsx new file mode 100644 index 00000000..3eed2769 --- /dev/null +++ b/examples/nextjs-app/src/modules/pages/sales-invoice-demo.tsx @@ -0,0 +1,351 @@ +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/nextjs-app/src/modules/pages/stock-transfer-demo.tsx b/examples/nextjs-app/src/modules/pages/stock-transfer-demo.tsx new file mode 100644 index 00000000..8ca5bcf0 --- /dev/null +++ b/examples/nextjs-app/src/modules/pages/stock-transfer-demo.tsx @@ -0,0 +1,351 @@ +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/nextjs-app/src/modules/pages/work-order-demo.tsx b/examples/nextjs-app/src/modules/pages/work-order-demo.tsx new file mode 100644 index 00000000..76d9b986 --- /dev/null +++ b/examples/nextjs-app/src/modules/pages/work-order-demo.tsx @@ -0,0 +1,423 @@ +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/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 a6dc2852..bbb877cc 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=@tailor-platform/app-shell", + "dev:examples": "turbo watch dev --filter='./examples/*'", "build": "turbo build", "type-check": "turbo type-check", "lint": "turbo lint", 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 db474a74..7cdc295b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -52,6 +52,8 @@ "@standard-schema/spec": "^1.1.0", "@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/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/LineItems.tsx b/packages/core/src/components/line-items/LineItems.tsx new file mode 100644 index 00000000..94af6d45 --- /dev/null +++ b/packages/core/src/components/line-items/LineItems.tsx @@ -0,0 +1,49 @@ +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"; + +/** + * 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, + 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 new file mode 100644 index 00000000..ae0661b9 --- /dev/null +++ b/packages/core/src/components/line-items/field.ts @@ -0,0 +1,144 @@ +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). + * + * 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, +): 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; + }; + } + + // 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[], +): 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..cb50375a --- /dev/null +++ b/packages/core/src/components/line-items/index.ts @@ -0,0 +1,46 @@ +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, + 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..8ac24e13 --- /dev/null +++ b/packages/core/src/components/line-items/internals.ts @@ -0,0 +1,258 @@ +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", lineId: 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 data = pickDocumentPatch(cols, row, documentKeys); + lines.push({ action: "add", tempId: id, data }); + } + + /** 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", lineId: id, patch }); + } + + if (orderingMode === "manual") { + lines.push( + ...diffPersistedMoves( + baseline.order, + currentOrder, + baseline.rows, + insertedRefs, + removedRefs, + currentByRef, + ), + ); + } + + return lines; +} + +/** Emit reorder ops for persisted ids whose position 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 baseIndex = new Map(); + for (let i = 0; i < baseIds.length; i++) baseIndex.set(baseIds[i]!, i); + + 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]!; + if (baseIndex.get(id) !== i) { + reorders.push({ action: "reorder", lineId: id, position: i }); + } + } + return reorders; +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const x of a) if (!b.has(x)) return false; + return true; +} + +function columnsByScope( + cols: LineItemsColumnDef[], + scope: LineItemsMutationScope, +): string[] { + const keys: string[] = []; + for (const c of cols) { + 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 { isEmpty: lineChanges.length === 0, lineChanges }; +} + +export function isChangeSetEmpty(cs: LineItemsChangeSet): boolean { + return cs.isEmpty; +} + +/** 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..d6d87860 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-default-cell.tsx @@ -0,0 +1,649 @@ +import * as React from "react"; + +import { + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxList, + ComboboxRoot, + ComboboxTrigger, +} from "@/components/combobox"; +import { cn } from "@/lib/utils"; + +import { + defaultAlignForField, + 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 ; + } + if (fieldType?.kind === "boolean") { + return ; + } + if (fieldType?.kind === "date") { + return ; + } + if (fieldType?.kind === "custom") { + 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 ( + setLocal(e.target.value)} + onBlur={() => onCommit(parseLocalToCommit(local))} + onFocus={onFocus} + onKeyDown={onKeyDown} + /> + ); +} + +/* ======================================================================== */ +/* 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 ( + setLocal(e.target.value)} + onBlur={() => onCommit(local)} + 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) */ +/* ======================================================================== */ + +/** + * 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, + readonlyTint, + children, + onPointerDown, + onFillGripPointerDown, +}: { + coord: GridCoord; + primary: boolean; + 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; +}) { + // 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; + // 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 ( + 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..eba945e9 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-internals.test.ts @@ -0,0 +1,120 @@ +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", + lineId: "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.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 new file mode 100644 index 00000000..6baa4732 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-parts.tsx @@ -0,0 +1,661 @@ +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"; + +/* ======================================================================== */ +/* 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 new file mode 100644 index 00000000..4a2a2ed1 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-root.tsx @@ -0,0 +1,127 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +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 = + 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); + const [totalsRowFn, setTotalsRowFn] = React.useState | null>(null); + + 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, totalsRowFn, setTotalsRowFn }), + [value, fullscreen, totalsRowFn], + ); + + // 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 ( + } + > + {fullscreen ? : null} +
[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, + )} + > + {children} +
+
+ ); +} +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-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 new file mode 100644 index 00000000..5b347ef5 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-table.test.tsx @@ -0,0 +1,121 @@ +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, + loading = false, + skeletonRowCount, +}: { + withTotals?: boolean; + withRowActions?: boolean; + loading?: boolean; + skeletonRowCount?: number; +}) { + 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("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. + 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 new file mode 100644 index 00000000..1bb978fc --- /dev/null +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -0,0 +1,1328 @@ +/* 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 { 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 { LineItemsSkeletonRow } from "./line-items-skeleton-row"; +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; + /** + * 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; + /** + * 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) { + const { + maxBodyHeight = "min(60vh, 480px)", + className, + tableContainerClassName, + 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, + loading = false, + skeletonRowCount = 12, + } = props; + + const root = useLineItemsRoot(); + const { hook, fullscreen, totalsRowFn } = 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; + // `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 ------------------------------------------- */ + + 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()); + + /* ---- 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]); + 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), + }); + } + + if (rowActions) { + 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 }) => ( +
+ {rowActionsRef.current?.(row.original)} +
+ ), + size: rowActionsWidth, + }); + } + + return cols; + // `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, + 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]); + + 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)); + }, []); + + /* ---- 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; + // `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 => { + 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; + }, [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; + + // 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; + } + + // 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); + + // 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], + ); + + const onCellFocused = React.useCallback( + (coord: GridCoord) => { + if (mode === "display") return; + const cur = ssFocusRef.current; + if (cur && sameCoord(cur, coord)) return; + // 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], + ); + + 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 existing = new Map>(); + + // 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 parsed = coerceForField(field, raw); + 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 }); + } + } + + 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, selectionCoordsMemo], + ); + + /* ---- 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 --------------------------------------------------------- */ + + // `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; + // `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 + // 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; + + return ( +
+ {renderFullscreenToggle ? ( +
+ +
+ ) : null} + + } + > + {isFastScrolling ? : 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} +
+ ) : ( + + {/* + `` 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 pinStyle = getPinStyle(colId); + return ( + + ); + })} + {/* Spacer header (matches the trailing above). */} + {renderTrailingSpacer ? ( + + ))} + + + {loading + ? Array.from({ length: skeletonRowCount }, (_, i) => ( + + )) + : null} + {!loading && padTop > 0 ? ( + + + ) : null} + {!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 ( + in colgroup. */} + {renderTrailingSpacer ? ( + + ); + })} + {!loading && padBot > 0 ? ( + + + ) : 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)} + > + {flexRender(header.column.columnDef.header, header.getContext())} +
+ ) : null} +
+
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. + + Uses a raw instead of so the table primitive's + first:pl-6 / last:pr-6 outer padding (PR #186) doesn't break our + grid alignment. Headers in this table are also raw ; both sides + stay symmetric. + */} + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ); + })} + {/* Trailing spacer cell — matches the trailing
+ ) : null} +
+
+ {value ?? null} + + ) : null} +
+ )} +
+
+
+ ); +} +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(); +} + +/* ======================================================================== */ +/* Fast-scroll skeleton CSS */ +/* */ +/* Applied via an inline `