From 2f2dadfe00b20f7c268cfc9a0f02353ee22fecdd Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:10:40 +0530 Subject: [PATCH 01/25] feat(line-items): add LineItems, useLineItems and BulkItemPicker Port of the from-scratch rebuild (platform-planning#1161) onto current main. Supersedes #238. Snapshots refreshed for the Base UI upgrade. Co-Authored-By: Claude Opus 5.5 --- .changeset/brave-pickers-gather.md | 24 + .changeset/wise-lines-gather.md | 48 + ...-item-picker__BulkItemPicker.test.tsx.snap | 13 + ...ents__line-items__line-items.test.tsx.snap | 61 + .../bulk-item-picker/BulkItemPicker.test.tsx | 487 ++++++++ .../bulk-item-picker/BulkItemPicker.tsx | 247 ++++ .../bulk-item-picker/BulkItemPickerRow.tsx | 149 +++ .../src/components/bulk-item-picker/index.ts | 2 + .../bulk-item-picker/tree-utils.test.ts | 147 +++ .../components/bulk-item-picker/tree-utils.ts | 117 ++ .../src/components/bulk-item-picker/types.ts | 64 + .../bulk-item-picker/use-tree-focus.ts | 148 +++ .../bulk-item-picker/use-tree-selection.ts | 116 ++ .../line-items/column-layout.test.ts | 191 +++ .../components/line-items/column-layout.ts | 170 +++ .../core/src/components/line-items/field.ts | 150 +++ .../core/src/components/line-items/index.ts | 38 + .../components/line-items/internals.test.ts | 249 ++++ .../src/components/line-items/internals.ts | 237 ++++ .../components/line-items/line-items-cell.tsx | 380 ++++++ .../line-items/line-items-context.tsx | 41 + .../line-items/line-items-parts.tsx | 534 +++++++++ .../line-items/line-items-skeleton-row.tsx | 35 + .../line-items/line-items-table.tsx | 1025 +++++++++++++++++ .../components/line-items/line-items.test.tsx | 817 +++++++++++++ .../src/components/line-items/line-items.tsx | 186 +++ .../line-items/spreadsheet-logic.test.ts | 190 +++ .../line-items/spreadsheet-logic.ts | 120 ++ .../core/src/components/line-items/types.ts | 267 +++++ .../line-items/use-grid-selection.ts | 157 +++ .../line-items/use-line-items.test.ts | 464 ++++++++ .../components/line-items/use-line-items.ts | 497 ++++++++ .../line-items/use-row-virtualizer.ts | 81 ++ .../line-items/virtual-window.test.ts | 73 ++ .../components/line-items/virtual-window.ts | 57 + packages/core/src/index.ts | 27 + 36 files changed, 7609 insertions(+) create mode 100644 .changeset/brave-pickers-gather.md create mode 100644 .changeset/wise-lines-gather.md create mode 100644 packages/core/__snapshots__/src__components__bulk-item-picker__BulkItemPicker.test.tsx.snap create mode 100644 packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap create mode 100644 packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx create mode 100644 packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx create mode 100644 packages/core/src/components/bulk-item-picker/BulkItemPickerRow.tsx create mode 100644 packages/core/src/components/bulk-item-picker/index.ts create mode 100644 packages/core/src/components/bulk-item-picker/tree-utils.test.ts create mode 100644 packages/core/src/components/bulk-item-picker/tree-utils.ts create mode 100644 packages/core/src/components/bulk-item-picker/types.ts create mode 100644 packages/core/src/components/bulk-item-picker/use-tree-focus.ts create mode 100644 packages/core/src/components/bulk-item-picker/use-tree-selection.ts create mode 100644 packages/core/src/components/line-items/column-layout.test.ts create mode 100644 packages/core/src/components/line-items/column-layout.ts create mode 100644 packages/core/src/components/line-items/field.ts create mode 100644 packages/core/src/components/line-items/index.ts create mode 100644 packages/core/src/components/line-items/internals.test.ts create mode 100644 packages/core/src/components/line-items/internals.ts create mode 100644 packages/core/src/components/line-items/line-items-cell.tsx create mode 100644 packages/core/src/components/line-items/line-items-context.tsx create mode 100644 packages/core/src/components/line-items/line-items-parts.tsx create mode 100644 packages/core/src/components/line-items/line-items-skeleton-row.tsx create mode 100644 packages/core/src/components/line-items/line-items-table.tsx create mode 100644 packages/core/src/components/line-items/line-items.test.tsx create mode 100644 packages/core/src/components/line-items/line-items.tsx create mode 100644 packages/core/src/components/line-items/spreadsheet-logic.test.ts create mode 100644 packages/core/src/components/line-items/spreadsheet-logic.ts create mode 100644 packages/core/src/components/line-items/types.ts create mode 100644 packages/core/src/components/line-items/use-grid-selection.ts create mode 100644 packages/core/src/components/line-items/use-line-items.test.ts create mode 100644 packages/core/src/components/line-items/use-line-items.ts create mode 100644 packages/core/src/components/line-items/use-row-virtualizer.ts create mode 100644 packages/core/src/components/line-items/virtual-window.test.ts create mode 100644 packages/core/src/components/line-items/virtual-window.ts diff --git a/.changeset/brave-pickers-gather.md b/.changeset/brave-pickers-gather.md new file mode 100644 index 000000000..c03022c20 --- /dev/null +++ b/.changeset/brave-pickers-gather.md @@ -0,0 +1,24 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `BulkItemPicker` — a tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch. + +Selection is leaf-only: a parent checkbox is derived from its descendants (checked, or indeterminate when partial), and ticking it selects every leaf beneath. Searching keeps any node that matches or has a matching descendant, and auto-expands the ancestors of matches. The tree is keyboard-navigable (`role="tree"` with roving focus, arrow keys to move and expand, Space to toggle, Enter to commit). + +```tsx +import { BulkItemPicker } from "@tailor-platform/app-shell"; + + + open={open} + onOpenChange={setOpen} + title="Add items from catalog" + items={catalog} + renderRow={(node) => node.data.name} + renderMetric={(node) => (node.children ? null : `$${node.data.price.toFixed(2)}`)} + matchesSearch={(node, q) => node.data.name.toLowerCase().includes(q.toLowerCase())} + onCommit={(picked) => lineItems.addLines(picked.map((n) => ({ sku: n.data.sku, quantity: 1 })))} +/>; +``` + +It is decoupled from `LineItems` and works for any multi-select-from-tree flow. Pair it with `useLineItems().addLines()` to insert every pick in a single render. diff --git a/.changeset/wise-lines-gather.md b/.changeset/wise-lines-gather.md new file mode 100644 index 000000000..23c504844 --- /dev/null +++ b/.changeset/wise-lines-gather.md @@ -0,0 +1,48 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `LineItems` — a spreadsheet-grade editable table for the lines of a document (purchase orders, invoices, journals, stock transfers), plus the `useLineItems` hook and the `createLineItemHelper` field-schema builder. + +State lives in the hook; the compound parts render it. `getChangeSet()` returns a transport-agnostic description of what changed, so only real edits are submitted. + +```tsx +import { LineItems, useLineItems, createLineItemHelper } from "@tailor-platform/app-shell"; + +const f = createLineItemHelper(); +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + }), +]; + +function Lines({ lines }: { lines: POLine[] }) { + const lineItems = useLineItems({ + fields, + data: lines, + ordering: "manual", + selection: true, + }); + + return ( + + + + submit(changeSet.lineChanges)} /> + + ); +} +``` + +Includes view and edit modes, inline cell editors that commit on blur or Enter, range selection with TSV copy/paste, pinned columns, a sticky totals row, row selection with bulk actions, row virtualization for long documents, and drag reordering with a keyboard fallback on the grip handle. diff --git a/packages/core/__snapshots__/src__components__bulk-item-picker__BulkItemPicker.test.tsx.snap b/packages/core/__snapshots__/src__components__bulk-item-picker__BulkItemPicker.test.tsx.snap new file mode 100644 index 000000000..0a77de756 --- /dev/null +++ b/packages/core/__snapshots__/src__components__bulk-item-picker__BulkItemPicker.test.tsx.snap @@ -0,0 +1,13 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`BulkItemPicker > snapshots > closed 1`] = `""`; + +exports[`BulkItemPicker > snapshots > empty state 1`] = `"
"`; + +exports[`BulkItemPicker > snapshots > expanded parent with a partial selection 1`] = `"
"`; + +exports[`BulkItemPicker > snapshots > open with a filter slot 1`] = `"
"`; + +exports[`BulkItemPicker > snapshots > open with collapsed tree 1`] = `"
"`; + +exports[`BulkItemPicker > snapshots > open without the metric column 1`] = `"
"`; diff --git a/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap b/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap new file mode 100644 index 000000000..cdf008f30 --- /dev/null +++ b/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap @@ -0,0 +1,61 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`LineItems > snapshots > amend mode 1`] = ` +"
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
" +`; + +exports[`LineItems > snapshots > edit mode with selection and row actions 1`] = ` +"
SKUUoMNoteTotalActions
10.00
25.00
4.00
" +`; + +exports[`LineItems > snapshots > manual ordering renders drag handles 1`] = ` +"
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
" +`; + +exports[`LineItems > snapshots > view mode 1`] = ` +"
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
" +`; diff --git a/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx b/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx new file mode 100644 index 000000000..85096d873 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx @@ -0,0 +1,487 @@ +import * as React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { BulkItemPicker } from "./BulkItemPicker"; +import type { BulkItemPickerNode } from "./types"; + +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 } }, +]; + +type HarnessProps = { + initialOpen?: boolean; + onCommit?: (items: BulkItemPickerNode[]) => void; + ctaLabel?: (count: number) => string; + filterSlot?: React.ReactNode; + withMetric?: boolean; + items?: BulkItemPickerNode[]; +}; + +function Harness({ + initialOpen = true, + onCommit = vi.fn(), + ctaLabel, + filterSlot, + withMetric = true, + items = TREE, +}: HarnessProps) { + const [open, setOpen] = React.useState(initialOpen); + return ( + <> + + + open={open} + onOpenChange={setOpen} + title="Bulk picker" + items={items} + rowLabel="Product Name" + metricLabel={withMetric ? "Total available" : undefined} + renderRow={(node) => {node.data.name}} + renderMetric={withMetric ? (node) => {node.data.available} : undefined} + matchesSearch={(node, q) => node.data.name.toLowerCase().includes(q.toLowerCase())} + onCommit={onCommit} + ctaLabel={ctaLabel} + filterSlot={filterSlot} + /> + + ); +} + +/** Row (`treeitem`) for a given visible label. */ +const rowFor = (label: string): HTMLElement => screen.getByText(label).closest("[role=treeitem]")!; + +/** The checkbox drawn inside the row for a given visible label. */ +const checkboxFor = (label: string): HTMLElement => + rowFor(label).querySelector("[role=checkbox]") as HTMLElement; + +const expandButton = () => screen.getByRole("button", { name: /expand/i }); + +describe("BulkItemPicker", () => { + // ========================================================================== + // Snapshots + // ========================================================================== + + describe("snapshots", () => { + it("closed", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("open with collapsed tree", async () => { + const { baseElement } = render(); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("open without the metric column", async () => { + const { baseElement } = render(); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("open with a filter slot", async () => { + const { baseElement } = render(Filters} />); + await waitFor(() => expect(screen.getByText("Filters")).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("expanded parent with a partial selection", async () => { + const user = userEvent.setup(); + const { baseElement } = render(); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + await user.click(expandButton()); + await user.click(checkboxFor("UK 9 / Black / Men")); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + + it("empty state", async () => { + const user = userEvent.setup(); + const { baseElement } = render(); + await user.type(screen.getByLabelText("Search"), "zzz"); + await waitFor(() => expect(screen.getByText(/no matching items/i)).toBeDefined()); + expect(baseElement.innerHTML).toMatchSnapshot(); + }); + }); + + // ========================================================================== + // Selection + commit + // ========================================================================== + + it("commits selected leaves in tree order and closes", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 8 / Black / Women")); + await user.click(checkboxFor("UK 10 / Black / Men")); + await user.click(checkboxFor("Hoka One One Bondi 8")); + + 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"]); + await waitFor(() => expect(screen.queryByText("Nike Vomero 18")).toBeNull()); + }); + + it("parent toggle selects every descendant leaf", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("Adidas Ultraboost 22")); + + for (const label of ["UK 8 / Black / Women", "UK 9 / Black / Men", "UK 10 / Black / Men"]) { + expect(checkboxFor(label).getAttribute("aria-checked")).toBe("true"); + } + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-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("parent toggle deselects every descendant leaf", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("Adidas Ultraboost 22")); + expect(screen.getByRole("button", { name: /add 3 items/i })).toBeDefined(); + + await user.click(checkboxFor("Adidas Ultraboost 22")); + expect((screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement).disabled).toBe( + true, + ); + expect(checkboxFor("UK 8 / Black / Women").getAttribute("aria-checked")).toBe("false"); + }); + + it("partial child selection drives the parent into the mixed state", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 8 / Black / Women")); + + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("mixed"); + expect(checkboxFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("mixed"); + }); + + it("never stores parent ids in the selection — commit yields leaves only", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await user.click(checkboxFor("Adidas Ultraboost 22")); + await user.click(screen.getByRole("button", { name: /add 3 items/i })); + + const selected = onCommit.mock.calls[0]![0] as BulkItemPickerNode[]; + expect(selected.some((n) => n.id === "p2")).toBe(false); + expect(selected.every((n) => n.children === undefined)).toBe(true); + }); + + it("disables the CTA 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(); + await waitFor(() => expect(screen.queryByText("Nike Vomero 18")).toBeNull()); + }); + + it("supports a custom ctaLabel", async () => { + const user = userEvent.setup(); + render( `Import ${count}`} />); + + expect(screen.getByRole("button", { name: "Import 0" })).toBeDefined(); + await user.click(checkboxFor("Nike Vomero 18")); + expect(screen.getByRole("button", { name: "Import 1" })).toBeDefined(); + }); + + it("resets query, selection and expansion when reopened", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + await user.click(checkboxFor("UK 8 / Black / Women")); + await user.type(screen.getByLabelText("Search"), "UK"); + + await user.click(screen.getByRole("button", { name: /^cancel$/i })); + await waitFor(() => expect(screen.queryByLabelText("Search")).toBeNull()); + + await user.click(screen.getByRole("button", { name: "Reopen" })); + await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); + + expect((screen.getByLabelText("Search") as HTMLInputElement).value).toBe(""); + expect((screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement).disabled).toBe( + true, + ); + // Collapsed again — the variants are not rendered. + expect(screen.queryByText("UK 8 / Black / Women")).toBeNull(); + }); + + // ========================================================================== + // Search + // ========================================================================== + + it("filters subtrees and auto-expands parents whose descendants match", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + + await user.type(screen.getByLabelText("Search"), "UK 9"); + + expect(screen.getByText("Adidas Ultraboost 22")).toBeDefined(); + expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); + expect(screen.queryByText("Nike Vomero 18")).toBeNull(); + expect(screen.queryByText("Hoka One One Bondi 8")).toBeNull(); + // The pruned siblings are gone too. + expect(screen.queryByText("UK 8 / Black / Women")).toBeNull(); + }); + + it("keeps manual expansion after the query is cleared", async () => { + const user = userEvent.setup(); + render(); + + await user.click(expandButton()); + expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); + + const search = screen.getByLabelText("Search"); + await user.type(search, "Hoka"); + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + + await user.clear(search); + expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); + }); + + it("renders the empty text when the query matches nothing", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByLabelText("Search"), "zzz no match"); + expect(screen.getByText(/no matching items/i)).toBeDefined(); + expect(screen.queryByRole("tree")).toBeNull(); + }); + + it("renders the empty text for an empty catalog", async () => { + render(); + await waitFor(() => expect(screen.getByText(/no matching items/i)).toBeDefined()); + }); + + it("renders the filter slot next to the search input", async () => { + render(Filters} />); + await waitFor(() => expect(screen.getByRole("button", { name: "Filters" })).toBeDefined()); + }); + + // ========================================================================== + // Accessibility + // ========================================================================== + + it("exposes a multiselectable tree labelled by the dialog title", async () => { + render(); + const tree = await waitFor(() => screen.getByRole("tree")); + + expect(tree.getAttribute("aria-multiselectable")).toBe("true"); + const labelId = tree.getAttribute("aria-labelledby"); + expect(labelId).toBeTruthy(); + expect(document.getElementById(labelId!)?.textContent).toBe("Bulk picker"); + }); + + it("marks rows as treeitems with aria-level and aria-expanded only on branches", async () => { + const user = userEvent.setup(); + render(); + + const parent = rowFor("Adidas Ultraboost 22"); + expect(parent.getAttribute("aria-level")).toBe("1"); + expect(parent.getAttribute("aria-expanded")).toBe("false"); + // Leaves must not advertise an expanded state. + expect(rowFor("Nike Vomero 18").hasAttribute("aria-expanded")).toBe(false); + + await user.click(expandButton()); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-expanded")).toBe("true"); + expect(rowFor("UK 9 / Black / Men").getAttribute("aria-level")).toBe("2"); + }); + + it("keeps exactly one row in the tab order", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + const tabStops = () => + screen.getAllByRole("treeitem").filter((row) => row.getAttribute("tabindex") === "0"); + + expect(tabStops()).toHaveLength(1); + expect(tabStops()[0]).toBe(rowFor("Nike Vomero 18")); + + rowFor("Hoka One One Bondi 8").focus(); + await waitFor(() => expect(tabStops()).toHaveLength(1)); + expect(tabStops()[0]).toBe(rowFor("Hoka One One Bondi 8")); + + // The checkbox inside a row is never its own tab stop. + expect(checkboxFor("Hoka One One Bondi 8").getAttribute("tabindex")).toBe("-1"); + await user.click(checkboxFor("Nike Vomero 18")); + await waitFor(() => expect(tabStops()).toHaveLength(1)); + }); + + // ========================================================================== + // Keyboard + // ========================================================================== + + it("moves focus between visible rows with ArrowDown and ArrowUp", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Nike Vomero 18").focus(); + + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(rowFor("Adidas Ultraboost 22")); + + // The collapsed parent's children are not visible, so they are skipped. + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(rowFor("Hoka One One Bondi 8")); + + // Already at the end — focus stays put. + await user.keyboard("{ArrowDown}"); + expect(document.activeElement).toBe(rowFor("Hoka One One Bondi 8")); + + await user.keyboard("{ArrowUp}{ArrowUp}"); + expect(document.activeElement).toBe(rowFor("Nike Vomero 18")); + }); + + it("expands with ArrowRight and then steps into the first child", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Adidas Ultraboost 22").focus(); + + await user.keyboard("{ArrowRight}"); + expect(screen.getByText("UK 8 / Black / Women")).toBeDefined(); + expect(document.activeElement).toBe(rowFor("Adidas Ultraboost 22")); + + await user.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe(rowFor("UK 8 / Black / Women")); + + // ArrowRight on a leaf is inert. + await user.keyboard("{ArrowRight}"); + expect(document.activeElement).toBe(rowFor("UK 8 / Black / Women")); + }); + + it("steps out with ArrowLeft and then collapses the parent", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + await user.click(expandButton()); + rowFor("UK 9 / Black / Men").focus(); + + await user.keyboard("{ArrowLeft}"); + expect(document.activeElement).toBe(rowFor("Adidas Ultraboost 22")); + + await user.keyboard("{ArrowLeft}"); + expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-expanded")).toBe("false"); + }); + + it("jumps to the first and last visible rows with Home and End", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Adidas Ultraboost 22").focus(); + + await user.keyboard("{End}"); + expect(document.activeElement).toBe(rowFor("Hoka One One Bondi 8")); + + await user.keyboard("{Home}"); + expect(document.activeElement).toBe(rowFor("Nike Vomero 18")); + }); + + it("toggles the focused row with Space without moving focus", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Nike Vomero 18").focus(); + + await user.keyboard(" "); + expect(rowFor("Nike Vomero 18").getAttribute("aria-checked")).toBe("true"); + expect(document.activeElement).toBe(rowFor("Nike Vomero 18")); + + await user.keyboard(" "); + expect(rowFor("Nike Vomero 18").getAttribute("aria-checked")).toBe("false"); + }); + + it("cascades a Space toggle on a parent to all descendants", async () => { + const user = userEvent.setup(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Adidas Ultraboost 22").focus(); + + await user.keyboard(" "); + expect(screen.getByRole("button", { name: /add 3 items/i })).toBeDefined(); + expect(rowFor("Adidas Ultraboost 22").getAttribute("aria-checked")).toBe("true"); + }); + + it("commits on Enter from a row but not while typing in the search box", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + await user.click(checkboxFor("Nike Vomero 18")); + + await user.click(screen.getByLabelText("Search")); + await user.keyboard("{Enter}"); + expect(onCommit).not.toHaveBeenCalled(); + + rowFor("Nike Vomero 18").focus(); + await user.keyboard("{Enter}"); + expect(onCommit).toHaveBeenCalledTimes(1); + expect((onCommit.mock.calls[0]![0] as BulkItemPickerNode[]).map((n) => n.id)).toEqual([ + "p1", + ]); + }); + + it("does not commit on Enter when nothing is selected", async () => { + const user = userEvent.setup(); + const onCommit = vi.fn(); + render(); + + await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); + rowFor("Nike Vomero 18").focus(); + await user.keyboard("{Enter}"); + + expect(onCommit).not.toHaveBeenCalled(); + expect(screen.getByText("Nike Vomero 18")).toBeDefined(); + }); +}); diff --git a/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx b/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx new file mode 100644 index 000000000..950e58673 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx @@ -0,0 +1,247 @@ +import * as React from "react"; +import { SearchIcon } from "lucide-react"; + +import { Button } from "@/components/button"; +import { Dialog } from "@/components/dialog"; +import { Input } from "@/components/input"; + +import { BulkItemPickerRow } from "./BulkItemPickerRow"; +import { flattenVisible, leafIds } from "./tree-utils"; +import type { BulkItemPickerNode, BulkItemPickerProps } from "./types"; +import { useTreeFocus } from "./use-tree-focus"; +import { useTreeSelection } from "./use-tree-selection"; + +/** + * A generic tree-select dialog for picking many items out of a hierarchical + * catalog and committing them in one go — products with variants, accounts with + * sub-accounts, categories with tags. + * + * The picker owns the dialog chrome, the search box, expand/collapse, and the + * selection model. The consumer owns the row visuals (`renderRow` / + * `renderMetric`) and what happens on commit (`onCommit`). + * + * ### Selection model + * Selection is **leaf-only**: internally a `Set` of leaf ids, never parent ids. + * A parent's `checked` / `indeterminate` state is derived from its descendants + * on every render, and ticking a parent adds or removes all of its descendant + * leaves at once. `onCommit` therefore always receives leaf nodes, in DFS tree + * order, and the picker closes itself afterwards. + * + * ### Search + * A node survives the query if it matches or any descendant matches; every + * parent with a surviving descendant auto-expands so matches are reachable + * without clicking carets. Manual expand state is tracked separately, so it + * survives the query being cleared. The default matcher is a case-insensitive + * substring test on `String(node.data)` — **override `matchesSearch` whenever + * `data` is an object**, or the query will never match. + * + * ### Accessibility + * The list is a `role="tree"` with `aria-multiselectable`; each row is a + * `treeitem` with `aria-level` and `aria-checked` (`"mixed"` when partially + * selected), following the ARIA checkbox-tree pattern. Exactly one visible row + * is in the tab order; `ArrowUp`/`ArrowDown` move between visible rows, + * `ArrowRight`/`ArrowLeft` expand/collapse and step in/out, `Home`/`End` jump to + * the ends, `Space` toggles the focused row, and `Enter` commits (except while + * typing in the search box). + * + * @example + * ### Bulk-adding document lines + * The picker stays decoupled from any line-items model — the consumer's own + * glue maps the picked leaves into whatever their document needs: + * ```tsx + * type Variant = { name: string; sku: string; unitPrice: number; available: number }; + * + * + * open={open} + * onOpenChange={setOpen} + * title="Add items" + * rowLabel="Product name" + * metricLabel="Total available" + * items={catalog} + * renderRow={(node) => {node.data.name}} + * renderMetric={(node) => node.data.available} + * matchesSearch={(node, query) => + * node.data.name.toLowerCase().includes(query.toLowerCase()) + * } + * onCommit={(picks) => lineItems.addLines(picks.map(toLine))} + * /> + * ``` + * + * @example + * ### Catalog fetched from a server + * There is deliberately no async variant: auto-expand needs the whole tree up + * front. Fetch once before (or while) opening and gate `emptyText` on your own + * loading flag: + * ```tsx + * const { data, isLoading } = useCatalog(); + * + * node.data.name} + * onCommit={handleCommit} + * /> + * ``` + */ +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 titleId = React.useId(); + + const { + query, + setQuery, + kept, + isExpanded, + toggleExpand, + setExpanded, + selectedLeaves, + toggleNode, + selectedNodes, + } = useTreeSelection({ open, items, matchesSearch }); + + const count = selectedNodes.length; + + const commit = React.useCallback(() => { + if (selectedNodes.length === 0) return; + onCommit(selectedNodes); + onOpenChange(false); + }, [selectedNodes, onCommit, onOpenChange]); + + const isChecked = React.useCallback( + (node: BulkItemPickerNode) => { + const ids = leafIds(node); + return ids.length > 0 && ids.every((id) => selectedLeaves.has(id)); + }, + [selectedLeaves], + ); + + // Rendering is recursive but keyboard navigation needs the flat, visible + // order — both derive from the same `isExpanded` predicate, so they agree. + const visibleRows = React.useMemo(() => flattenVisible(kept, isExpanded), [kept, isExpanded]); + + const { activeRowId, registerRow, onRowFocus, handleKeyDown } = useTreeFocus({ + open, + rows: visibleRows, + setExpanded, + toggleNode, + isChecked, + commit, + }); + + const defaultCta = count > 0 ? `Add ${count} item${count === 1 ? "" : "s"}` : "Add items"; + const cta = ctaLabel ? ctaLabel(count) : defaultCta; + + return ( + + + + {title} + + +
+
+ + setQuery(event.target.value)} + placeholder={searchPlaceholder} + aria-label="Search" + // `dark:bg-transparent` is not redundant: Input ships `dark:bg-input/30`, + // which tailwind-merge will not dedupe against a bare `bg-transparent`, + // so in dark mode it paints a second fill over the wrapper's own. + className="astw:focus-visible:ring-0 astw:border-0 astw:bg-transparent astw:dark:bg-transparent astw:px-0 astw:shadow-none" + /> +
+ {filterSlot} +
+ +
+ {/* Mirrors the row's checkbox + caret so the labels line up. */} + + +
{rowLabel}
+ {metricLabel ?
{metricLabel}
: null} +
+ + {visibleRows.length === 0 ? ( +
+ {emptyText} +
+ ) : ( +
+ {kept.map((node) => ( + + ))} +
+ )} + + + + + +
+
+ ); +} +BulkItemPicker.displayName = "BulkItemPicker"; + +export { BulkItemPicker }; diff --git a/packages/core/src/components/bulk-item-picker/BulkItemPickerRow.tsx b/packages/core/src/components/bulk-item-picker/BulkItemPickerRow.tsx new file mode 100644 index 000000000..9bd4a41ae --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/BulkItemPickerRow.tsx @@ -0,0 +1,149 @@ +import * as React from "react"; +import { ChevronDownIcon, ChevronRightIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { Checkbox } from "@/components/checkbox"; + +import { isLeaf, leafIds } from "./tree-utils"; +import type { BulkItemPickerNode, BulkItemPickerProps } from "./types"; + +/** + * @internal + * One `treeitem` row plus — recursively — its visible descendants. Rows are + * emitted as flat DOM siblings inside the `role="tree"` container and convey + * their nesting through `aria-level`, which is the flattened form of the ARIA + * tree pattern. + * + * Parent state (`checked` / `indeterminate`) is always **derived** from the + * leaf-only selection set; nothing about a parent is ever stored. + */ + +type BulkItemPickerRowProps = { + node: BulkItemPickerNode; + depth: number; + isExpanded(id: string): boolean; + onToggleExpand(id: string): void; + selectedLeaves: ReadonlySet; + onToggleNode(node: BulkItemPickerNode, next: boolean): void; + renderRow: BulkItemPickerProps["renderRow"]; + renderMetric: BulkItemPickerProps["renderMetric"]; + activeRowId: string | null; + registerRow(id: string, element: HTMLElement | null): void; + onRowFocus(id: string): void; +}; + +function BulkItemPickerRow({ + node, + depth, + isExpanded, + onToggleExpand, + selectedLeaves, + onToggleNode, + renderRow, + renderMetric, + activeRowId, + registerRow, + onRowFocus, +}: BulkItemPickerRowProps) { + const leaf = isLeaf(node); + const expanded = !leaf && isExpanded(node.id); + + const descendantLeaves = React.useMemo(() => leafIds(node), [node]); + const checkedCount = descendantLeaves.reduce( + (total, id) => total + (selectedLeaves.has(id) ? 1 : 0), + 0, + ); + const checked = leaf + ? selectedLeaves.has(node.id) + : descendantLeaves.length > 0 && checkedCount === descendantLeaves.length; + const indeterminate = !leaf && checkedCount > 0 && !checked; + + return ( + +
{ + registerRow(node.id, element); + }} + role="treeitem" + aria-level={depth + 1} + aria-expanded={leaf ? undefined : expanded} + aria-checked={indeterminate ? "mixed" : checked} + tabIndex={activeRowId === node.id ? 0 : -1} + onFocus={() => onRowFocus(node.id)} + data-slot="bulk-item-picker-row" + data-node-id={node.id} + data-depth={depth} + className={cn( + "astw:border-border/60 astw:flex astw:items-center astw:gap-3 astw:border-b astw:px-4 astw:py-2.5 astw:text-sm", + "astw:focus-visible:ring-ring/50 astw:focus-visible:ring-2 astw:focus-visible:outline-none astw:focus-visible:ring-inset", + depth > 0 && "astw:bg-muted/30", + )} + > + {/* Indentation as real spacers — keeps every style in a Tailwind class. */} + {Array.from({ length: depth }, (_, level) => ( + + ))} + + onToggleNode(node, next)} + /> + + {leaf ? ( + + ) : ( + + )} + +
+ {renderRow(node, depth)} +
+ + {renderMetric ? ( +
+ {renderMetric(node, depth)} +
+ ) : null} +
+ + {expanded + ? (node.children ?? []).map((child) => ( + + )) + : null} +
+ ); +} +BulkItemPickerRow.displayName = "BulkItemPicker.Row"; + +export { BulkItemPickerRow }; diff --git a/packages/core/src/components/bulk-item-picker/index.ts b/packages/core/src/components/bulk-item-picker/index.ts new file mode 100644 index 000000000..754d5d8c9 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/index.ts @@ -0,0 +1,2 @@ +export { BulkItemPicker } from "./BulkItemPicker"; +export type { BulkItemPickerNode, BulkItemPickerProps } from "./types"; diff --git a/packages/core/src/components/bulk-item-picker/tree-utils.test.ts b/packages/core/src/components/bulk-item-picker/tree-utils.test.ts new file mode 100644 index 000000000..7a3b17463 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/tree-utils.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { + allLeaves, + defaultMatcher, + filterTree, + flattenVisible, + isLeaf, + leafIds, +} from "./tree-utils"; +import type { BulkItemPickerNode } from "./types"; + +type Item = { name: string }; + +const node = (id: string, name: string, children?: BulkItemPickerNode[]) => + ({ id, data: { name }, ...(children ? { children } : {}) }) as BulkItemPickerNode; + +const TREE: BulkItemPickerNode[] = [ + node("p1", "Nike Vomero 18"), + node("p2", "Adidas Ultraboost 22", [ + node("p2-a", "UK 8 / Black / Women"), + node("p2-b", "UK 9 / Black / Men"), + node("p2-c", "UK 10 / Black / Men", [node("p2-c-1", "UK 10 / Wide")]), + ]), + node("p3", "Hoka Bondi 8"), +]; + +const byName = (n: BulkItemPickerNode, q: string) => + n.data.name.toLowerCase().includes(q.toLowerCase()); + +describe("tree-utils", () => { + describe("isLeaf", () => { + it("treats a node with no children key as a leaf", () => { + expect(isLeaf(node("a", "A"))).toBe(true); + }); + + it("treats an empty children array as a leaf", () => { + expect(isLeaf({ id: "a", data: { name: "A" }, children: [] })).toBe(true); + }); + + it("treats a node with children as a branch", () => { + expect(isLeaf(TREE[1]!)).toBe(false); + }); + }); + + describe("leafIds", () => { + it("returns the node's own id for a leaf", () => { + expect(leafIds(TREE[0]!)).toEqual(["p1"]); + }); + + it("collects nested leaf ids in DFS order and skips branch ids", () => { + expect(leafIds(TREE[1]!)).toEqual(["p2-a", "p2-b", "p2-c-1"]); + }); + }); + + describe("allLeaves", () => { + it("flattens the whole forest to leaves in DFS order", () => { + expect(allLeaves(TREE).map((n) => n.id)).toEqual(["p1", "p2-a", "p2-b", "p2-c-1", "p3"]); + }); + + it("returns an empty list for an empty forest", () => { + expect(allLeaves([])).toEqual([]); + }); + }); + + describe("filterTree", () => { + it("keeps a matching leaf and drops the rest", () => { + const { kept } = filterTree(TREE, (n) => byName(n, "hoka")); + expect(kept.map((n) => n.id)).toEqual(["p3"]); + }); + + it("keeps ancestors of a match and prunes their non-matching children", () => { + const { kept } = filterTree(TREE, (n) => byName(n, "UK 9")); + expect(kept.map((n) => n.id)).toEqual(["p2"]); + expect(kept[0]!.children?.map((n) => n.id)).toEqual(["p2-b"]); + }); + + it("auto-expands every ancestor on the path to a match", () => { + const { autoExpand } = filterTree(TREE, (n) => byName(n, "wide")); + expect([...autoExpand].toSorted()).toEqual(["p2", "p2-c"]); + }); + + it("keeps a matching parent's full child list when no descendant matches", () => { + const { kept, autoExpand } = filterTree(TREE, (n) => byName(n, "adidas")); + expect(kept.map((n) => n.id)).toEqual(["p2"]); + expect(kept[0]!.children?.map((n) => n.id)).toEqual(["p2-a", "p2-b", "p2-c"]); + // Nothing below matched, so the parent is not force-expanded. + expect(autoExpand.size).toBe(0); + }); + + it("does not mutate the input forest", () => { + const before = JSON.stringify(TREE); + filterTree(TREE, (n) => byName(n, "UK 9")); + expect(JSON.stringify(TREE)).toBe(before); + }); + + it("returns nothing when the query matches nothing", () => { + const { kept, autoExpand } = filterTree(TREE, (n) => byName(n, "zzzz")); + expect(kept).toEqual([]); + expect(autoExpand.size).toBe(0); + }); + }); + + describe("defaultMatcher", () => { + it("matches primitive payloads case-insensitively", () => { + expect(defaultMatcher({ id: "a", data: "Nike Vomero" }, "vomero")).toBe(true); + expect(defaultMatcher({ id: "a", data: "Nike Vomero" }, "adidas")).toBe(false); + }); + + it("treats a nullish payload as an empty string", () => { + expect(defaultMatcher({ id: "a", data: null }, "x")).toBe(false); + }); + + it("stringifies object payloads (why consumers must override it)", () => { + expect(defaultMatcher({ id: "a", data: { name: "Nike" } }, "nike")).toBe(false); + expect(defaultMatcher({ id: "a", data: { name: "Nike" } }, "object")).toBe(true); + }); + }); + + describe("flattenVisible", () => { + it("hides the children of a collapsed parent", () => { + const rows = flattenVisible(TREE, () => false); + expect(rows.map((r) => r.node.id)).toEqual(["p1", "p2", "p3"]); + }); + + it("includes the children of an expanded parent with depth and parent id", () => { + const rows = flattenVisible(TREE, (id) => id === "p2"); + expect(rows.map((r) => r.node.id)).toEqual(["p1", "p2", "p2-a", "p2-b", "p2-c", "p3"]); + expect(rows.map((r) => r.depth)).toEqual([0, 0, 1, 1, 1, 0]); + expect(rows.find((r) => r.node.id === "p2-a")?.parentId).toBe("p2"); + expect(rows.find((r) => r.node.id === "p1")?.parentId).toBeNull(); + }); + + it("flags leaf and expanded state per row", () => { + const rows = flattenVisible(TREE, (id) => id === "p2" || id === "p2-c"); + const p2c = rows.find((r) => r.node.id === "p2-c")!; + expect(p2c.leaf).toBe(false); + expect(p2c.expanded).toBe(true); + expect(rows.find((r) => r.node.id === "p2-c-1")?.leaf).toBe(true); + }); + + it("never reports a leaf as expanded", () => { + const rows = flattenVisible(TREE, () => true); + expect(rows.filter((r) => r.leaf).every((r) => r.expanded === false)).toBe(true); + }); + }); +}); diff --git a/packages/core/src/components/bulk-item-picker/tree-utils.ts b/packages/core/src/components/bulk-item-picker/tree-utils.ts new file mode 100644 index 000000000..9f2cfcc3f --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/tree-utils.ts @@ -0,0 +1,117 @@ +import type { BulkItemPickerNode } from "./types"; + +/** + * @internal + * Pure tree helpers for the picker. Kept free of React so they can be unit + * tested directly and reused by both the selection and focus hooks. + */ + +/** A node is a leaf when it has no children — leaves are the selectable units. */ +export function isLeaf(node: BulkItemPickerNode): boolean { + return !node.children || node.children.length === 0; +} + +/** + * Collect the ids of every leaf in the subtree rooted at `node`, in DFS order. + * A leaf node yields its own id. + */ +export function leafIds(node: BulkItemPickerNode, into: string[] = []): string[] { + if (isLeaf(node)) { + into.push(node.id); + return into; + } + for (const child of node.children ?? []) leafIds(child, into); + return into; +} + +/** Collect every leaf node in the forest, in DFS order. */ +export function allLeaves( + nodes: ReadonlyArray>, + into: BulkItemPickerNode[] = [], +): BulkItemPickerNode[] { + for (const node of nodes) { + if (isLeaf(node)) into.push(node); + else allLeaves(node.children ?? [], into); + } + return into; +} + +/** + * Filter the forest by `matches`. + * + * A leaf survives only if it matches. A parent survives if it matches OR any of + * its descendants survive — in which case it is returned as a *clone* holding + * only the surviving children (a parent that matches on its own but has no + * surviving descendant keeps its full child list, so the user can still drill + * into it). + * + * `autoExpand` collects every parent that has a surviving descendant, so the + * matching rows are reachable without manual expansion. + */ +export function filterTree( + nodes: ReadonlyArray>, + matches: (node: BulkItemPickerNode) => boolean, +): { kept: BulkItemPickerNode[]; autoExpand: Set } { + const autoExpand = new Set(); + + const walk = (list: ReadonlyArray>): BulkItemPickerNode[] => { + const out: BulkItemPickerNode[] = []; + for (const node of list) { + if (isLeaf(node)) { + if (matches(node)) out.push(node); + continue; + } + const keptChildren = walk(node.children ?? []); + if (keptChildren.length > 0) { + autoExpand.add(node.id); + out.push({ ...node, children: keptChildren }); + } else if (matches(node)) { + out.push(node); + } + } + return out; + }; + + return { kept: walk(nodes), autoExpand }; +} + +/** + * Default search predicate: case-insensitive substring match on + * `String(node.data)`. Only meaningful for primitive payloads — consumers with + * object payloads must supply their own `matchesSearch`. + */ +export function defaultMatcher(node: BulkItemPickerNode, query: string): boolean { + return String(node.data ?? "") + .toLowerCase() + .includes(query.toLowerCase()); +} + +/** One row as it appears on screen, flattened for roving-tabindex keyboard nav. */ +export type VisibleRow = { + node: BulkItemPickerNode; + depth: number; + /** `null` for top-level rows. */ + parentId: string | null; + leaf: boolean; + expanded: boolean; +}; + +/** + * Flatten the forest into the rows that are actually rendered: a collapsed + * parent's children are not visible and therefore not included. + */ +export function flattenVisible( + nodes: ReadonlyArray>, + isExpanded: (id: string) => boolean, + depth = 0, + parentId: string | null = null, + into: VisibleRow[] = [], +): VisibleRow[] { + for (const node of nodes) { + const leaf = isLeaf(node); + const expanded = !leaf && isExpanded(node.id); + into.push({ node, depth, parentId, leaf, expanded }); + if (expanded) flattenVisible(node.children ?? [], isExpanded, depth + 1, node.id, into); + } + return into; +} diff --git a/packages/core/src/components/bulk-item-picker/types.ts b/packages/core/src/components/bulk-item-picker/types.ts new file mode 100644 index 000000000..35a3fbf44 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/types.ts @@ -0,0 +1,64 @@ +import type * as React from "react"; + +/** + * A single node in the picker's catalog tree. + * + * - `id` must be unique across the **entire** tree — it doubles as the + * selection key. Duplicate ids silently break selection; prefix them on + * construction (e.g. `` `${parentId}-${childId}` ``) when your source data + * reuses keys across levels. + * - `data` is opaque to the picker. It is only handed back to `renderRow`, + * `renderMetric`, and `matchesSearch`. + * - A node whose `children` is `undefined` or empty is a **leaf**. Selection is + * leaf-only, so leaves are the units the consumer receives from `onCommit`. + */ +export type BulkItemPickerNode = { + id: string; + data: T; + children?: BulkItemPickerNode[]; +}; + +/** Props for {@link BulkItemPicker}. */ +export interface BulkItemPickerProps { + /** Controls dialog visibility. */ + open: boolean; + /** + * Fires on every user-driven open/close (Esc, backdrop, close button, Cancel, + * and after a successful commit). The picker closes itself after `onCommit`, + * so consumers should not also call this from their commit handler. + */ + onOpenChange(open: boolean): void; + /** Dialog title. Also labels the `tree` for assistive tech. Default: `"Select items"`. */ + title?: React.ReactNode; + /** Hierarchical items. Selection is leaf-only (a node with no `children`). */ + items: ReadonlyArray>; + /** + * Renders the row's own content. The library draws the checkbox, the + * expand/collapse caret, the indentation, and the metric column — this only + * needs to return the label/body for the node. + */ + renderRow(node: BulkItemPickerNode, depth: number): React.ReactNode; + /** Optional right-aligned metric column (e.g. "Total available"). */ + renderMetric?(node: BulkItemPickerNode, depth: number): React.ReactNode; + /** Header label above the `renderRow` column. Default: `"Item"`. */ + rowLabel?: React.ReactNode; + /** Header label above the `renderMetric` column. Omitted when absent. */ + metricLabel?: React.ReactNode; + /** + * Search predicate. Defaults to a case-insensitive substring match against + * `String(node.data)`, which is only useful when `data` is a primitive — + * **override it whenever `data` is an object**, otherwise every node + * stringifies to `"[object Object]"` and the query matches nothing. + */ + matchesSearch?(node: BulkItemPickerNode, query: string): boolean; + /** Slot rendered to the right of the search input (e.g. a category filter button). */ + filterSlot?: React.ReactNode; + /** Receives the selected **leaf** nodes in DFS tree order. */ + onCommit(selected: BulkItemPickerNode[]): void; + /** Primary CTA label. Default: `"Add N items"`, or `"Add items"` when nothing is selected. */ + ctaLabel?(count: number): string; + /** Shown when the (filtered) tree is empty. Default: `"No matching items."`. */ + emptyText?: React.ReactNode; + /** Search input placeholder. Default: `"Search"`. */ + searchPlaceholder?: string; +} diff --git a/packages/core/src/components/bulk-item-picker/use-tree-focus.ts b/packages/core/src/components/bulk-item-picker/use-tree-focus.ts new file mode 100644 index 000000000..40d85d286 --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/use-tree-focus.ts @@ -0,0 +1,148 @@ +import * as React from "react"; + +import type { VisibleRow } from "./tree-utils"; +import type { BulkItemPickerNode } from "./types"; + +/** + * @internal + * Implements the ARIA tree keyboard contract on top of a flattened list of + * visible rows: + * + * - **roving tabindex** — exactly one visible row is in the tab order + * - `ArrowDown` / `ArrowUp` — move between *visible* rows (a collapsed parent's + * children are not visible, so they are skipped) + * - `ArrowRight` — expand a collapsed parent, else step into its first child + * - `ArrowLeft` — collapse an expanded parent, else step out to its parent + * - `Home` / `End` — first / last visible row + * - `Space` — toggle the focused row (cascading for parents) without moving focus + * - `Enter` — commit, but never while focus sits in a text input (so it does not + * fight typing in the search box) or on a button (whose own click owns it) + */ + +type UseTreeFocusOptions = { + open: boolean; + rows: ReadonlyArray>; + setExpanded(id: string, next: boolean): void; + toggleNode(node: BulkItemPickerNode, next: boolean): void; + isChecked(node: BulkItemPickerNode): boolean; + commit(): void; +}; + +type UseTreeFocusResult = { + /** The single row that carries `tabIndex={0}`; every other row gets `-1`. */ + activeRowId: string | null; + registerRow(id: string, element: HTMLElement | null): void; + onRowFocus(id: string): void; + handleKeyDown(event: React.KeyboardEvent): void; +}; + +const ROW_SELECTOR = '[role="treeitem"]'; +const NODE_ID_ATTRIBUTE = "data-node-id"; + +export function useTreeFocus({ + open, + rows, + setExpanded, + toggleNode, + isChecked, + commit, +}: UseTreeFocusOptions): UseTreeFocusResult { + const [focusedId, setFocusedId] = React.useState(null); + const rowElements = React.useRef(new Map()); + + React.useEffect(() => { + if (!open) setFocusedId(null); + }, [open]); + + const registerRow = React.useCallback((id: string, element: HTMLElement | null) => { + if (element) rowElements.current.set(id, element); + else rowElements.current.delete(id); + }, []); + + const onRowFocus = React.useCallback((id: string) => setFocusedId(id), []); + + // The remembered row may have been filtered away by the search query; fall + // back to the first visible row so the tree always has exactly one tab stop. + const activeRowId = + focusedId !== null && rows.some((row) => row.node.id === focusedId) + ? focusedId + : (rows[0]?.node.id ?? null); + + const focusRow = React.useCallback((id: string | undefined) => { + if (id === undefined) return; + setFocusedId(id); + rowElements.current.get(id)?.focus(); + }, []); + + const handleKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + const target = event.target as HTMLElement | null; + if (target === null) return; + const inTextInput = target.tagName === "INPUT"; + const onButton = target.closest("button") !== null; + + if (event.key === "Enter") { + if (inTextInput || onButton) return; + event.preventDefault(); + commit(); + return; + } + + if (inTextInput) return; + + const rowElement = target.closest(ROW_SELECTOR); + const nodeId = rowElement?.getAttribute(NODE_ID_ATTRIBUTE); + if (nodeId == null) return; + const index = rows.findIndex((row) => row.node.id === nodeId); + const row = rows[index]; + if (row === undefined) return; + + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + focusRow(rows[index + 1]?.node.id); + break; + } + case "ArrowUp": { + event.preventDefault(); + focusRow(rows[index - 1]?.node.id); + break; + } + case "ArrowRight": { + if (row.leaf) break; + event.preventDefault(); + if (row.expanded) focusRow(rows[index + 1]?.node.id); + else setExpanded(row.node.id, true); + break; + } + case "ArrowLeft": { + event.preventDefault(); + if (!row.leaf && row.expanded) setExpanded(row.node.id, false); + else if (row.parentId !== null) focusRow(row.parentId); + break; + } + case "Home": { + event.preventDefault(); + focusRow(rows[0]?.node.id); + break; + } + case "End": { + event.preventDefault(); + focusRow(rows.at(-1)?.node.id); + break; + } + case " ": { + if (onButton) break; + event.preventDefault(); + toggleNode(row.node, !isChecked(row.node)); + break; + } + default: + break; + } + }, + [rows, focusRow, setExpanded, toggleNode, isChecked, commit], + ); + + return { activeRowId, registerRow, onRowFocus, handleKeyDown }; +} diff --git a/packages/core/src/components/bulk-item-picker/use-tree-selection.ts b/packages/core/src/components/bulk-item-picker/use-tree-selection.ts new file mode 100644 index 000000000..bccdb94ff --- /dev/null +++ b/packages/core/src/components/bulk-item-picker/use-tree-selection.ts @@ -0,0 +1,116 @@ +import * as React from "react"; + +import { allLeaves, defaultMatcher, filterTree, leafIds } from "./tree-utils"; +import type { BulkItemPickerNode } from "./types"; + +/** + * @internal + * Owns everything about *what is shown and what is ticked*: the search query, + * the filtered forest, the auto-expand derivation, the leaf-only selection set, + * and the user's manual expand/collapse set. All of it resets when the dialog + * closes so each open starts clean. + */ + +type UseTreeSelectionOptions = { + open: boolean; + items: ReadonlyArray>; + matchesSearch?: (node: BulkItemPickerNode, query: string) => boolean; +}; + +type UseTreeSelectionResult = { + query: string; + setQuery(query: string): void; + /** The forest to render — `items` when the query is empty, filtered otherwise. */ + kept: BulkItemPickerNode[]; + /** `autoExpand.has(id) || userExpanded.has(id)` — manual state survives clearing the query. */ + isExpanded(id: string): boolean; + toggleExpand(id: string): void; + setExpanded(id: string, next: boolean): void; + /** Leaf ids only — parents are never stored, their state is always derived. */ + selectedLeaves: ReadonlySet; + /** Adds/removes every descendant leaf of `node` in a single set update. */ + toggleNode(node: BulkItemPickerNode, next: boolean): void; + /** Selected leaf nodes in DFS tree order. */ + selectedNodes: BulkItemPickerNode[]; +}; + +export function useTreeSelection({ + open, + items, + matchesSearch, +}: UseTreeSelectionOptions): UseTreeSelectionResult { + const [query, setQuery] = React.useState(""); + const [selectedLeaves, setSelectedLeaves] = React.useState>(() => new Set()); + const [userExpanded, setUserExpanded] = React.useState>(() => new Set()); + + // Reset on close so the next open starts empty. + React.useEffect(() => { + if (open) return; + setQuery(""); + setSelectedLeaves(new Set()); + setUserExpanded(new Set()); + }, [open]); + + const matcher = matchesSearch ?? defaultMatcher; + + const { kept, autoExpand } = React.useMemo(() => { + const trimmed = query.trim(); + if (trimmed === "") return { kept: [...items], autoExpand: new Set() }; + return filterTree(items, (node) => matcher(node, trimmed)); + }, [items, query, matcher]); + + const isExpanded = React.useCallback( + (id: string) => autoExpand.has(id) || userExpanded.has(id), + [autoExpand, userExpanded], + ); + + const setExpanded = React.useCallback((id: string, next: boolean) => { + setUserExpanded((prev) => { + if (next === prev.has(id)) return prev; + const updated = new Set(prev); + if (next) updated.add(id); + else updated.delete(id); + return updated; + }); + }, []); + + const toggleExpand = React.useCallback((id: string) => { + setUserExpanded((prev) => { + const updated = new Set(prev); + if (updated.has(id)) updated.delete(id); + else updated.add(id); + return updated; + }); + }, []); + + const toggleNode = React.useCallback((node: BulkItemPickerNode, next: boolean) => { + setSelectedLeaves((prev) => { + const updated = new Set(prev); + for (const id of leafIds(node)) { + if (next) updated.add(id); + else updated.delete(id); + } + return updated; + }); + }, []); + + // Ordered against the FULL tree, not the filtered one, so the commit order is + // stable regardless of the query that was active when the user ticked a row. + const leavesInTreeOrder = React.useMemo(() => allLeaves(items), [items]); + const selectedNodes = React.useMemo( + () => leavesInTreeOrder.filter((node) => selectedLeaves.has(node.id)), + [leavesInTreeOrder, selectedLeaves], + ); + + return { + query, + setQuery, + kept, + isExpanded, + toggleExpand, + setExpanded, + selectedLeaves, + toggleNode, + selectedNodes, + }; +} diff --git a/packages/core/src/components/line-items/column-layout.test.ts b/packages/core/src/components/line-items/column-layout.test.ts new file mode 100644 index 000000000..022ea6ee0 --- /dev/null +++ b/packages/core/src/components/line-items/column-layout.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { + ACTIONS_COLUMN_WIDTH, + AUTO_COLUMN_MIN_WIDTH, + FLEX_COLUMN_MIN_WIDTH, + computeColumnLayout, + GRIP_COLUMN_WIDTH, + SELECTION_COLUMN_ID, + SELECTION_COLUMN_WIDTH, + pinStyle, +} from "./column-layout"; +import { createLineItemHelper } from "./field"; + +type Line = { lineRef: string; sku: string; name: string; qty: number; total: number }; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ key: "sku", label: "SKU", render: (l) => l.sku, width: 120, pin: "left" }), + f.field({ key: "name", label: "Name", render: (l) => l.name, flex: true }), + f.field({ key: "qty", label: "Qty", render: (l) => l.qty, width: 80, type: { kind: "number" } }), + f.field({ key: "total", label: "Total", render: (l) => l.total, width: 100, pin: "right" }), +]; + +const noExtras = { hasGrip: false, hasSelection: false, hasActions: false }; + +describe("computeColumnLayout", () => { + it("orders columns left-pinned, unpinned, then right-pinned", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.map((c) => c.id)).toEqual(["sku", "name", "qty", "total"]); + expect(layout.gridColumnIds).toEqual(["sku", "name", "qty", "total"]); + }); + + it("keeps unpinned columns unsticky", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.find((c) => c.id === "name")?.placement).toBeUndefined(); + expect(layout.columns.find((c) => c.id === "qty")?.placement).toBeUndefined(); + }); + + it("stacks left-pinned offsets after the selection and grip columns", () => { + const layout = computeColumnLayout(fields, { + widths: {}, + hasGrip: true, + hasSelection: true, + hasActions: false, + }); + + expect(layout.selection).toEqual({ side: "left", offset: 0, isBoundary: false }); + expect(layout.grip).toEqual({ + side: "left", + offset: SELECTION_COLUMN_WIDTH, + isBoundary: false, + }); + expect(layout.columns.find((c) => c.id === "sku")?.placement).toEqual({ + side: "left", + offset: SELECTION_COLUMN_WIDTH + GRIP_COLUMN_WIDTH, + isBoundary: true, + }); + }); + + it("prefers measured widths over declared ones for offsets", () => { + const layout = computeColumnLayout(fields, { + widths: { [SELECTION_COLUMN_ID]: 60 }, + hasGrip: true, + hasSelection: true, + hasActions: false, + }); + expect(layout.grip?.offset).toBe(60); + }); + + it("lets a pinned column with no declared width still stack correctly once measured", () => { + const unsized = [ + f.field({ key: "a", label: "A", render: () => null, pin: "left" }), + f.field({ key: "b", label: "B", render: () => null, pin: "left" }), + ]; + const layout = computeColumnLayout(unsized, { widths: { a: 90 }, ...noExtras }); + + expect(layout.columns[0]?.placement?.offset).toBe(0); + expect(layout.columns[1]?.placement?.offset).toBe(90); + }); + + it("accumulates right-pinned offsets inward from the actions column", () => { + const twoRight = [ + ...fields, + f.field({ key: "extra", label: "Extra", render: () => null, width: 70, pin: "right" }), + ]; + const layout = computeColumnLayout(twoRight, { + widths: {}, + hasGrip: false, + hasSelection: false, + hasActions: true, + }); + + expect(layout.actions).toEqual({ side: "right", offset: 0, isBoundary: false }); + expect(layout.columns.find((c) => c.id === "extra")?.placement).toEqual({ + side: "right", + offset: ACTIONS_COLUMN_WIDTH, + isBoundary: false, + }); + expect(layout.columns.find((c) => c.id === "total")?.placement).toEqual({ + side: "right", + offset: ACTIONS_COLUMN_WIDTH + 70, + isBoundary: true, + }); + }); + + it("marks the outermost pinned column of each side as the boundary", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.find((c) => c.id === "sku")?.placement?.isBoundary).toBe(true); + expect(layout.columns.find((c) => c.id === "total")?.placement?.isBoundary).toBe(true); + }); + + it("derives alignment from the field type when not set", () => { + const layout = computeColumnLayout(fields, { widths: {}, ...noExtras }); + expect(layout.columns.find((c) => c.id === "qty")?.align).toBe("right"); + expect(layout.columns.find((c) => c.id === "name")?.align).toBe("left"); + }); + + describe("spacer", () => { + it("is not needed when a column claims flex", () => { + expect(computeColumnLayout(fields, { widths: {}, ...noExtras }).needsSpacer).toBe(false); + }); + + it("is needed when every column has a fixed width and none flexes", () => { + const fixed = [ + f.field({ key: "sku", label: "SKU", render: () => null, width: 100 }), + f.field({ key: "qty", label: "Qty", render: () => null, width: 80 }), + ]; + expect(computeColumnLayout(fixed, { widths: {}, ...noExtras }).needsSpacer).toBe(true); + }); + + it("is not needed when a column sizes naturally", () => { + const mixed = [ + f.field({ key: "sku", label: "SKU", render: () => null, width: 100 }), + f.field({ key: "note", label: "Note", render: () => null }), + ]; + expect(computeColumnLayout(mixed, { widths: {}, ...noExtras }).needsSpacer).toBe(false); + }); + + it("is not needed for an empty schema", () => { + expect(computeColumnLayout([], { widths: {}, ...noExtras }).needsSpacer).toBe(false); + }); + }); +}); + +describe("pinStyle", () => { + it("returns sticky positioning for the placement's side", () => { + expect(pinStyle({ side: "left", offset: 44, isBoundary: false })).toEqual({ + position: "sticky", + left: 44, + }); + expect(pinStyle({ side: "right", offset: 0, isBoundary: true })).toEqual({ + position: "sticky", + right: 0, + }); + }); + + it("returns nothing for an unpinned column", () => { + expect(pinStyle(undefined)).toBeUndefined(); + }); +}); + +describe("minTableWidth", () => { + it("sums declared widths plus the utility columns", () => { + const layout = computeColumnLayout(fields, { + widths: {}, + hasGrip: true, + hasSelection: true, + hasActions: true, + }); + // sku 120 + name (flex floor) + qty 80 + total 100, plus grip/selection/actions. + expect(layout.minTableWidth).toBe( + 120 + + FLEX_COLUMN_MIN_WIDTH + + 80 + + 100 + + GRIP_COLUMN_WIDTH + + SELECTION_COLUMN_WIDTH + + ACTIONS_COLUMN_WIDTH, + ); + }); + + it("gives an unsized non-flex column a floor so it cannot be crushed", () => { + const layout = computeColumnLayout( + [f.field({ key: "note", label: "Note", render: () => null })], + { widths: {}, ...noExtras }, + ); + expect(layout.minTableWidth).toBe(AUTO_COLUMN_MIN_WIDTH); + }); +}); diff --git a/packages/core/src/components/line-items/column-layout.ts b/packages/core/src/components/line-items/column-layout.ts new file mode 100644 index 000000000..504d79c5e --- /dev/null +++ b/packages/core/src/components/line-items/column-layout.ts @@ -0,0 +1,170 @@ +import type { CSSProperties } from "react"; + +import { defaultAlignForField } from "./field"; +import type { LineItemsColumnAlign, LineItemsField, LineItemsRowData } from "./types"; + +/** Widths measured from the rendered header, keyed by column id. */ +export type ColumnWidths = Record; + +export type PinSide = "left" | "right"; + +export type PinPlacement = { + side: PinSide; + offset: number; + /** The outermost cell of its pinned group — the one that draws the freeze seam. */ + isBoundary: boolean; +}; + +/** Fixed-width utility columns the grid owns rather than the field schema. */ +export const GRIP_COLUMN_ID = "__grip"; +export const SELECTION_COLUMN_ID = "__selection"; +export const ACTIONS_COLUMN_ID = "__actions"; +export const SPACER_COLUMN_ID = "__spacer"; + +export const GRIP_COLUMN_WIDTH = 36; +export const SELECTION_COLUMN_WIDTH = 44; +export const ACTIONS_COLUMN_WIDTH = 56; + +export type LayoutColumn = { + id: string; + field: LineItemsField; + align: LineItemsColumnAlign; + width: number | undefined; + flex: boolean; + placement: PinPlacement | undefined; +}; + +/** + * Floor applied to columns that declare no `width`, so `table-layout: fixed` + * scrolls the grid horizontally instead of crushing them to nothing. + */ +export const FLEX_COLUMN_MIN_WIDTH = 200; +export const AUTO_COLUMN_MIN_WIDTH = 120; + +export type ColumnLayout = { + /** Field columns in render order: left-pinned, unpinned, right-pinned. */ + columns: LayoutColumn[]; + /** + * Narrowest the table may render before it starts scrolling. Without it, a + * schema wider than its container squeezes unsized columns to a few pixels. + */ + minTableWidth: number; + /** Ids of the editable grid's columns in render order, for keyboard and range math. */ + gridColumnIds: string[]; + grip: PinPlacement | undefined; + selection: PinPlacement | undefined; + actions: PinPlacement | undefined; + /** + * True when no column claims `flex` and every column declares a width — the + * grid then renders a trailing spacer so declared widths stay pixel-exact + * instead of being stretched to fill the container. + */ + needsSpacer: boolean; +}; + +function resolveWidth(id: string, declared: number | undefined, widths: ColumnWidths): number { + return widths[id] ?? declared ?? 0; +} + +/** + * Groups columns by pin side and computes cumulative sticky offsets, mirroring + * `computePinLayout` in `data-table.tsx`. Offsets prefer measured widths over + * declared ones, so a pinned column does not need an explicit `width` to stack + * correctly. + */ +export function computeColumnLayout( + fields: LineItemsField[], + opts: { + widths: ColumnWidths; + hasGrip: boolean; + hasSelection: boolean; + hasActions: boolean; + actionsWidth?: number; + }, +): ColumnLayout { + const { widths, hasGrip, hasSelection, hasActions, actionsWidth = ACTIONS_COLUMN_WIDTH } = opts; + + const left: LineItemsField[] = []; + const middle: LineItemsField[] = []; + const right: LineItemsField[] = []; + + for (const field of fields) { + if (field.pin === "left") left.push(field); + else if (field.pin === "right") right.push(field); + else middle.push(field); + } + + const placements = new Map, PinPlacement>(); + + let leftOffset = 0; + let selection: PinPlacement | undefined; + let grip: PinPlacement | undefined; + + if (hasSelection) { + selection = { side: "left", offset: 0, isBoundary: !hasGrip && left.length === 0 }; + leftOffset += resolveWidth(SELECTION_COLUMN_ID, SELECTION_COLUMN_WIDTH, widths); + } + if (hasGrip) { + grip = { side: "left", offset: leftOffset, isBoundary: left.length === 0 }; + leftOffset += resolveWidth(GRIP_COLUMN_ID, GRIP_COLUMN_WIDTH, widths); + } + left.forEach((field, i) => { + placements.set(field, { + side: "left", + offset: leftOffset, + isBoundary: i === left.length - 1, + }); + leftOffset += resolveWidth(field.key, field.width, widths); + }); + + let rightOffset = 0; + let actions: PinPlacement | undefined; + + if (hasActions) { + actions = { side: "right", offset: 0, isBoundary: right.length === 0 }; + rightOffset += resolveWidth(ACTIONS_COLUMN_ID, actionsWidth, widths); + } + for (let i = right.length - 1; i >= 0; i--) { + const field = right[i]!; + placements.set(field, { side: "right", offset: rightOffset, isBoundary: i === 0 }); + rightOffset += resolveWidth(field.key, field.width, widths); + } + + const ordered = [...left, ...middle, ...right]; + const columns: LayoutColumn[] = ordered.map((field) => ({ + id: field.key, + field, + align: defaultAlignForField(field), + width: field.width, + flex: field.flex === true, + placement: placements.get(field), + })); + + const minTableWidth = + (hasSelection ? SELECTION_COLUMN_WIDTH : 0) + + (hasGrip ? GRIP_COLUMN_WIDTH : 0) + + (hasActions ? actionsWidth : 0) + + columns.reduce((sum, c) => { + if (typeof c.width === "number") return sum + c.width; + return sum + (c.flex ? FLEX_COLUMN_MIN_WIDTH : AUTO_COLUMN_MIN_WIDTH); + }, 0); + + return { + columns, + gridColumnIds: columns.map((c) => c.id), + grip, + selection, + actions, + minTableWidth, + needsSpacer: + columns.length > 0 && + !columns.some((c) => c.flex) && + columns.every((c) => typeof c.width === "number"), + }; +} + +/** Sticky positioning for a pinned cell, matching DataTable's `pinCellProps`. */ +export function pinStyle(placement: PinPlacement | undefined): CSSProperties | undefined { + if (!placement) return undefined; + return { position: "sticky", [placement.side]: placement.offset }; +} diff --git a/packages/core/src/components/line-items/field.ts b/packages/core/src/components/line-items/field.ts new file mode 100644 index 000000000..1c643b2b5 --- /dev/null +++ b/packages/core/src/components/line-items/field.ts @@ -0,0 +1,150 @@ +import type { LineItemsColumnDef, LineItemsMutationScope } from "./internals"; +import type { + LineItemsColumnAlign, + LineItemsField, + LineItemsFieldCommit, + LineItemsMode, + LineItemsRowData, +} from "./types"; + +/** + * Field schema builder. Gives full inference of `key`, `render(line)`, and + * `sort.comparator` for a row shape `T`. + * + * @example + * type POLine = { lineRef: string; sku: string; quantity: number; total: number }; + * const f = createLineItemHelper(); + * const fields = [ + * f.field({ key: "sku", label: "SKU", render: (l) => l.sku, editable: ["edit"], type: { kind: "text" } }), + * f.field({ key: "quantity", label: "Qty", render: (l) => l.quantity, editable: ["edit"], type: { kind: "number", decimals: 0 } }), + * f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2) }), + * ]; + */ +export function createLineItemHelper() { + return { + field: (f: LineItemsField): LineItemsField => f, + }; +} + +/** + * Whether the field renders an editor (vs its read-only `render` output) in + * `mode`. A field opts into amend by listing it, e.g. `editable: ["edit", "amend"]`. + */ +export function fieldIsEditableInMode( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + if (mode === "view") return false; + if (!field.editable || field.editable.length === 0) return false; + return field.editable.includes(mode); +} + +/** Cells locked by amend's partial lock get a tint, so what is still editable reads clearly. */ +export function fieldIsAmendLocked( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + return mode === "amend" && !fieldIsEditableInMode(field, mode); +} + +/** Resolves where a field's edits go. Non-editable columns never reach the change-set. */ +export function fieldCommitScope( + field: LineItemsField, +): LineItemsFieldCommit { + return field.commit ?? "document"; +} + +/** + * Whether the field participates in the document change-set. Computed and + * read-only columns are excluded, so a derived total never submits as an edit. + */ +export function fieldMutationScope( + field: LineItemsField, +): LineItemsMutationScope { + if ((field.editable?.length ?? 0) === 0) return "excluded"; + return fieldCommitScope(field) === "metadata" ? "excluded" : "document"; +} + +/** Whether the column accepts TSV paste in `mode`. */ +export function fieldAllowsPaste( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + return fieldIsEditableInMode(field, mode); +} + +/** Whether a fill handle should be offered for the column in `mode`. */ +export function fieldAllowsFill( + field: LineItemsField, + mode: LineItemsMode, +): boolean { + if (mode !== "edit") return false; + return fieldIsEditableInMode(field, mode) && fieldMutationScope(field) === "document"; +} + +/** + * Translates a field into the internal column descriptor the change-set engine + * consumes. + * + * Numeric fields get a normalizer that coerces strings to numbers and rounds by + * `decimals`, plus a tolerance-aware `equals`, so cosmetic input differences + * like `"1"` vs `"1.00"` do not register as document changes. Precedence for + * overrides runs field-level, then type-level, then these built-in defaults. + */ +export function fieldToColumnDef( + field: LineItemsField, +): LineItemsColumnDef { + const def: LineItemsColumnDef = { + id: field.key, + accessorKey: field.key as keyof T & string, + mutationScope: fieldMutationScope(field), + }; + + if (field.type?.kind === "number") { + const decimals = field.type.decimals; + def.normalize = (value: unknown): 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; + }; + } + + if (field.type?.kind === "custom") { + if (field.type.normalize) def.normalize = field.type.normalize; + if (field.type.equals) def.equals = field.type.equals; + } + + if (field.normalize) def.normalize = field.normalize; + if (field.equals) def.equals = field.equals; + + return def; +} + +export function fieldsToColumnDefs( + fields: LineItemsField[], +): LineItemsColumnDef[] { + return fields.map((f) => fieldToColumnDef(f)); +} + +/** Numbers right-align, booleans center, everything else left. */ +export function defaultAlignForField( + field: LineItemsField, +): LineItemsColumnAlign { + if (field.align) return field.align; + if (field.type?.kind === "number") return "right"; + if (field.type?.kind === "boolean") return "center"; + return "left"; +} diff --git a/packages/core/src/components/line-items/index.ts b/packages/core/src/components/line-items/index.ts new file mode 100644 index 000000000..f6d345d04 --- /dev/null +++ b/packages/core/src/components/line-items/index.ts @@ -0,0 +1,38 @@ +export { LineItems, type LineItemsRootProps } from "./line-items"; +export { useLineItems } from "./use-line-items"; +export { createLineItemHelper } from "./field"; +export { lineItemsFloatingBarStyles } from "./line-items-parts"; +export type { LineItemsTableProps } from "./line-items-table"; +export type { + LineItemsAddRowProps, + LineItemsBulkActionsProps, + LineItemsBulkActionsRenderArgs, + LineItemsDirtyBarProps, + LineItemsFloatingDockProps, + LineItemsFullscreenToggleProps, + LineItemsSaveActionsProps, + LineItemsSearchProps, + LineItemsSearchToggleProps, + LineItemsSelectionBarProps, + LineItemsSelectionBarRenderArgs, + LineItemsTotalsRowProps, +} from "./line-items-parts"; +export type { + LineItemsChangeSet, + LineItemsColumnAlign, + LineItemsCustomEditorContext, + LineItemsField, + LineItemsFieldCommit, + LineItemsFieldType, + LineItemsLineChange, + LineItemsMetadataCommit, + LineItemsMode, + LineItemsOrderingMode, + LineItemsRowData, + LineItemsRowPatch, + LineItemsSelectOption, + LineItemsSortDirection, + LineItemsSortState, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; diff --git a/packages/core/src/components/line-items/internals.test.ts b/packages/core/src/components/line-items/internals.test.ts new file mode 100644 index 000000000..14dba8368 --- /dev/null +++ b/packages/core/src/components/line-items/internals.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; + +import { createLineItemHelper, fieldsToColumnDefs } from "./field"; +import { buildChangeSet, cloneBaseline, type LineItemsBaseline } from "./internals"; +import type { LineItemsOrderingMode } from "./types"; + +type Line = { + lineRef: string; + sku: string; + quantity: number; + note: string; + total: number; +}; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + }), + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit"], + type: { kind: "text" }, + }), + // Computed column — read-only, so it must never reach the change-set. + f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2) }), +]; + +const columns = fieldsToColumnDefs(fields); + +function line(over: Partial & { lineRef: string }): Line { + return { sku: "SKU-1", quantity: 1, note: "", total: 0, ...over }; +} + +function pack(rows: Line[]) { + const byRef: Record = {}; + const order: string[] = []; + for (const r of rows) { + byRef[r.lineRef] = r; + order.push(r.lineRef); + } + return { byRef, order }; +} + +function changeSet( + baseline: LineItemsBaseline, + rows: Line[], + opts?: { + inserted?: string[]; + removed?: string[]; + ordering?: LineItemsOrderingMode; + }, +) { + const { byRef, order } = pack(rows); + return buildChangeSet( + columns, + baseline, + order, + byRef, + new Set(opts?.removed ?? []), + new Set(opts?.inserted ?? []), + opts?.ordering ?? "sort", + ); +} + +function baselineOf(rows: Line[]): LineItemsBaseline { + const { byRef, order } = pack(rows); + return cloneBaseline(order, byRef); +} + +describe("buildChangeSet", () => { + it("is empty when nothing changed", () => { + const rows = [line({ lineRef: "1" }), line({ lineRef: "2" })]; + const cs = changeSet(baselineOf(rows), rows); + expect(cs.isEmpty).toBe(true); + expect(cs.lineChanges).toEqual([]); + }); + + it("emits an add with a tempId for an inserted line", () => { + const base = baselineOf([line({ lineRef: "1" })]); + const cs = changeSet(base, [line({ lineRef: "1" }), line({ lineRef: "tmp", sku: "SKU-9" })], { + inserted: ["tmp"], + }); + + expect(cs.isEmpty).toBe(false); + expect(cs.lineChanges).toEqual([ + { action: "add", tempId: "tmp", data: { sku: "SKU-9", quantity: 1, note: "" } }, + ]); + }); + + it("emits an update carrying only the changed fields", () => { + const base = baselineOf([line({ lineRef: "1", sku: "SKU-1", quantity: 1 })]); + const cs = changeSet(base, [line({ lineRef: "1", sku: "SKU-1", quantity: 5 })]); + + expect(cs.lineChanges).toEqual([{ action: "update", lineId: "1", patch: { quantity: 5 } }]); + }); + + it("emits a remove for a line dropped from the document", () => { + const base = baselineOf([line({ lineRef: "1" }), line({ lineRef: "2" })]); + const cs = changeSet(base, [line({ lineRef: "1" })], { removed: ["2"] }); + + expect(cs.lineChanges).toEqual([{ action: "remove", lineId: "2" }]); + }); + + it("never includes lineRef or read-only computed columns in a patch", () => { + const base = baselineOf([line({ lineRef: "1", total: 0 })]); + const cs = changeSet(base, [line({ lineRef: "1", total: 999, quantity: 2 })]); + + expect(cs.lineChanges).toEqual([{ action: "update", lineId: "1", patch: { quantity: 2 } }]); + }); + + describe("normalization", () => { + it("treats cosmetic numeric input as unchanged", () => { + const base = baselineOf([line({ lineRef: "1", quantity: 1 })]); + const cs = changeSet(base, [line({ lineRef: "1", quantity: "1.00" as unknown as number })]); + expect(cs.isEmpty).toBe(true); + }); + + it("rounds numeric values by the field's decimals", () => { + const base = baselineOf([line({ lineRef: "1", quantity: 1 })]); + const cs = changeSet(base, [line({ lineRef: "1", quantity: 1.239 })]); + expect(cs.lineChanges).toEqual([ + { action: "update", lineId: "1", patch: { quantity: 1.24 } }, + ]); + }); + + it("trims strings before comparing", () => { + const base = baselineOf([line({ lineRef: "1", note: "hello" })]); + expect(changeSet(base, [line({ lineRef: "1", note: " hello " })]).isEmpty).toBe(true); + expect(changeSet(base, [line({ lineRef: "1", note: " bye " })]).lineChanges).toEqual([ + { action: "update", lineId: "1", patch: { note: "bye" } }, + ]); + }); + + it("honors a field-level equals override", () => { + const custom = fieldsToColumnDefs([ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + equals: (a, b) => String(a).toLowerCase() === String(b).toLowerCase(), + }), + ]); + const rows = [line({ lineRef: "1", sku: "sku-1" })]; + const cs = buildChangeSet( + custom, + baselineOf([line({ lineRef: "1", sku: "SKU-1" })]), + ["1"], + pack(rows).byRef, + new Set(), + new Set(), + "sort", + ); + expect(cs.isEmpty).toBe(true); + }); + }); + + describe("reorder", () => { + const base = baselineOf([ + line({ lineRef: "1" }), + line({ lineRef: "2" }), + line({ lineRef: "3" }), + ]); + const swapped = [line({ lineRef: "2" }), line({ lineRef: "1" }), line({ lineRef: "3" })]; + + it("emits reorder ops with final positions under manual ordering", () => { + const cs = changeSet(base, swapped, { ordering: "manual" }); + expect(cs.lineChanges).toEqual([ + { action: "reorder", lineId: "2", position: 0 }, + { action: "reorder", lineId: "1", position: 1 }, + ]); + }); + + it("stays silent under sort ordering", () => { + expect(changeSet(base, swapped, { ordering: "sort" }).isEmpty).toBe(true); + }); + + it("emits nothing when a line is dragged back to its original slot", () => { + const original = [line({ lineRef: "1" }), line({ lineRef: "2" }), line({ lineRef: "3" })]; + expect(changeSet(base, original, { ordering: "manual" }).isEmpty).toBe(true); + }); + + it("does not read an insert as a move of everything below it", () => { + const withInsert = [ + line({ lineRef: "1" }), + line({ lineRef: "tmp" }), + line({ lineRef: "2" }), + line({ lineRef: "3" }), + ]; + const cs = changeSet(base, withInsert, { inserted: ["tmp"], ordering: "manual" }); + expect(cs.lineChanges.filter((c) => c.action === "reorder")).toEqual([]); + }); + + it("does not read a removal as a move of everything below it", () => { + const withRemoval = [line({ lineRef: "1" }), line({ lineRef: "3" })]; + const cs = changeSet(base, withRemoval, { removed: ["2"], ordering: "manual" }); + expect(cs.lineChanges.filter((c) => c.action === "reorder")).toEqual([]); + }); + }); + + it("reports adds, updates, removes and reorders together", () => { + const base = baselineOf([ + line({ lineRef: "1" }), + line({ lineRef: "2" }), + line({ lineRef: "3" }), + ]); + const cs = changeSet( + base, + [line({ lineRef: "3" }), line({ lineRef: "1", quantity: 7 }), line({ lineRef: "tmp" })], + { inserted: ["tmp"], removed: ["2"], ordering: "manual" }, + ); + + expect(cs.lineChanges).toContainEqual({ action: "remove", lineId: "2" }); + expect(cs.lineChanges).toContainEqual({ + action: "update", + lineId: "1", + patch: { quantity: 7 }, + }); + expect(cs.lineChanges.some((c) => c.action === "add" && c.tempId === "tmp")).toBe(true); + }); +}); + +describe("cloneBaseline", () => { + it("snapshots rows so later mutation of the source does not leak in", () => { + const rows = [line({ lineRef: "1", note: "before" })]; + const { byRef, order } = pack(rows); + const baseline = cloneBaseline(order, byRef); + + rows[0]!.note = "after"; + + expect(baseline.rows["1"]?.note).toBe("before"); + }); +}); diff --git a/packages/core/src/components/line-items/internals.ts b/packages/core/src/components/line-items/internals.ts new file mode 100644 index 000000000..8aef7bf9c --- /dev/null +++ b/packages/core/src/components/line-items/internals.ts @@ -0,0 +1,237 @@ +import type { + LineItemsChangeSet, + LineItemsLineChange, + LineItemsOrderingMode, + LineItemsRowData, + LineItemsRowPatch, +} from "./types"; + +/** + * `"excluded"` columns never reach the change-set — computed and read-only + * columns are flagged this way so a derived total is not submitted as an edit. + */ +export type LineItemsMutationScope = "document" | "excluded"; + +/** + * Internal column descriptor consumed by the change-set engine. Consumers + * describe columns with `LineItemsField`; `fieldToColumnDef` translates each + * into this shape. + */ +export type LineItemsColumnDef = { + id: string; + accessorKey?: keyof TRow & string; + mutationScope?: LineItemsMutationScope; + normalize?: (value: unknown, row: TRow) => unknown; + equals?: (a: unknown, b: unknown, row: TRow) => boolean; +}; + +/** Baseline captures document order plus row snapshots keyed by `lineRef`. */ +export type LineItemsBaseline = { + order: string[]; + rows: Record; +}; + +function defaultNormalizeValue(value: unknown): unknown { + if (typeof value === "string") return value.trim(); + return value; +} + +/** + * Indexes columns by both `accessorKey` and `id` so per-key lookups during a + * diff are O(1) — `getChangeSet` runs on every render to derive `isDirty`, over + * every row and column. + */ +function indexColumns( + cols: LineItemsColumnDef[], +): Map> { + const index = new Map>(); + for (const col of cols) { + if (col.accessorKey && !index.has(col.accessorKey)) index.set(col.accessorKey, col); + if (!index.has(col.id)) index.set(col.id, col); + } + return index; +} + +/** + * Resolves a single value through its column's normalizer. Used by paste and + * fill, which coerce incoming text before writing it into a row. + */ +export function normalizeField( + cols: LineItemsColumnDef[], + key: string, + value: unknown, + row: TRow, +): unknown { + const col = cols.find((c) => c.accessorKey === key || c.id === key); + if (col?.normalize) return col.normalize(value, row); + return defaultNormalizeValue(value); +} + +export function computeDocumentPatches( + cols: LineItemsColumnDef[], + baseline: LineItemsBaseline, + currentOrder: readonly string[], + currentByRef: Record, + removedRefs: ReadonlySet, + insertedRefs: ReadonlySet, + orderingMode: LineItemsOrderingMode, +): LineItemsLineChange[] { + const columnIndex = indexColumns(cols); + const documentKeys = columnsByScope(cols, "document"); + const currentIds = new Set(currentOrder); + const lines: LineItemsLineChange[] = []; + + const normalize = (key: string, value: unknown, row: TRow): unknown => { + const col = columnIndex.get(key); + if (col?.normalize) return col.normalize(value, row); + return defaultNormalizeValue(value); + }; + + const equals = (key: string, a: unknown, b: unknown, row: TRow): boolean => { + const col = columnIndex.get(key); + if (col?.equals) return col.equals(a, b, row); + return Object.is(a, b); + }; + + for (const id of baseline.order) { + if (removedRefs.has(id) || !currentIds.has(id)) { + if (baseline.rows[id]) lines.push({ action: "remove", lineId: id }); + } + } + + for (const id of currentOrder) { + if (!insertedRefs.has(id) || baseline.rows[id]) continue; + const row = currentByRef[id]; + if (!row) continue; + const data: LineItemsRowPatch = {}; + for (const key of documentKeys) { + data[key] = normalize(key, row[key as keyof TRow], row); + } + lines.push({ action: "add", tempId: id, data }); + } + + for (const id of currentOrder) { + if (removedRefs.has(id)) continue; + const base = baseline.rows[id]; + const row = currentByRef[id]; + if (!row || !base) continue; + + const patch: LineItemsRowPatch = {}; + for (const key of documentKeys) { + const nCur = normalize(key, row[key as keyof TRow], row); + const nBase = normalize(key, base[key as keyof TRow], base); + if (!equals(key, nCur, nBase, row)) patch[key] = nCur; + } + if (Object.keys(patch).length > 0) lines.push({ action: "update", lineId: id, patch }); + } + + if (orderingMode === "manual") { + lines.push( + ...diffPersistedMoves( + baseline.order, + currentOrder, + baseline.rows, + insertedRefs, + removedRefs, + currentByRef, + ), + ); + } + + return lines; +} + +/** + * Emits a `reorder` op for each persisted line whose index moved relative to + * baseline. Restricted to lines present in both baseline and current state, so + * an add or remove shifting everything below it does not read as N moves. + */ +function diffPersistedMoves( + baseOrderFull: readonly string[], + curOrderFull: readonly string[], + baselineRows: Record, + insertedRefs: ReadonlySet, + removedRefs: ReadonlySet, + currentByRef: Record, +): LineItemsLineChange[] { + const persisted = (ids: readonly string[]) => + ids.filter( + (id) => baselineRows[id] && currentByRef[id] && !removedRefs.has(id) && !insertedRefs.has(id), + ); + + const baseIds = persisted(baseOrderFull); + const curIds = persisted(curOrderFull); + if (baseIds.length !== curIds.length || !setsEqual(new Set(baseIds), new Set(curIds))) return []; + + const baseIndex = new Map(); + for (let i = 0; i < baseIds.length; i++) baseIndex.set(baseIds[i]!, i); + + const reorders: LineItemsLineChange[] = []; + for (let i = 0; i < curIds.length; i++) { + const id = curIds[i]!; + if (baseIndex.get(id) !== i) { + reorders.push({ action: "reorder", lineId: id, position: i }); + } + } + return reorders; +} + +function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a.size !== b.size) return false; + for (const x of a) if (!b.has(x)) return false; + return true; +} + +function columnsByScope( + cols: LineItemsColumnDef[], + scope: LineItemsMutationScope, +): string[] { + const keys: string[] = []; + for (const c of cols) { + if ((c.mutationScope ?? "document") !== scope) continue; + const key = (c.accessorKey as string | undefined) ?? c.id; + if (key && key !== "lineRef") keys.push(key); + } + return keys; +} + +export function buildChangeSet( + cols: LineItemsColumnDef[], + baseline: LineItemsBaseline, + currentOrder: readonly string[], + currentByRef: Record, + removedRefs: ReadonlySet, + insertedRefs: ReadonlySet, + orderingMode: LineItemsOrderingMode, +): LineItemsChangeSet { + const lineChanges = computeDocumentPatches( + cols, + baseline, + currentOrder, + currentByRef, + removedRefs, + insertedRefs, + orderingMode, + ); + return { isEmpty: lineChanges.length === 0, lineChanges }; +} + +export function cloneRow(row: TRow): TRow { + try { + return structuredClone(row) as TRow; + } catch { + return { ...row } as TRow; + } +} + +export function cloneBaseline( + order: readonly string[], + byRef: Record, +): LineItemsBaseline { + const rows: Record = {}; + for (const id of order) { + const r = byRef[id]; + if (r) rows[id] = cloneRow(r); + } + return { order: [...order], rows }; +} diff --git a/packages/core/src/components/line-items/line-items-cell.tsx b/packages/core/src/components/line-items/line-items-cell.tsx new file mode 100644 index 000000000..faca6aeb0 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-cell.tsx @@ -0,0 +1,380 @@ +import * as React from "react"; + +import { + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxInputGroup, + ComboboxItem, + ComboboxList, + ComboboxRoot, + ComboboxTrigger, +} from "@/components/combobox/combobox"; +import { cn } from "@/lib/utils"; +import { fieldCommitScope } from "./field"; +import type { + LineItemsField, + LineItemsMode, + LineItemsRowData, + LineItemsSelectOption, +} from "./types"; + +/** Where a commit hands focus next. */ +export type CellNavigation = "enter-down" | "enter-up" | "tab" | "shift-tab"; + +export type ArrowKey = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + +/** + * Editors are borderless and fill the cell, unlike the standalone `Input` + * component (a bordered 36px control), which would break the grid's rhythm. + * Every editable cell mounts its editor, so the grid reads as a spreadsheet at + * rest — a text caret in text cells, a caret button in select cells. + */ +const editorClasses = cn( + // `dark:bg-transparent` is not redundant: the wrapped input ships + // `dark:bg-input/30`, and tailwind-merge only dedupes within one variant, so a + // bare `bg-transparent` leaves the dark fill painted over the cell. + "astw:h-full astw:min-h-0 astw:w-full astw:min-w-0 astw:flex-1 astw:border-0 astw:bg-transparent astw:dark:bg-transparent astw:px-2 astw:text-sm astw:leading-none astw:text-foreground", + "astw:rounded-none astw:shadow-none astw:outline-none astw:ring-0", + "astw:focus:outline-none astw:focus:shadow-none astw:focus:ring-0 astw:focus:border-transparent", + "astw:focus-visible:outline-none astw:focus-visible:shadow-none astw:focus-visible:ring-0 astw:focus-visible:border-transparent", +); + +/** The native number spinner has no room in a grid cell. */ +const numberEditorClasses = + "astw:[appearance:textfield] astw:[&::-webkit-outer-spin-button]:appearance-none astw:[&::-webkit-inner-spin-button]:appearance-none"; + +/** Native input type per field kind; anything else is plain text. */ +const inputTypes: Record = { number: "number", date: "date" }; + +const alignClasses = { + left: "astw:text-left", + center: "astw:text-center", + right: "astw:text-right astw:tabular-nums", +} as const; + +export type LineItemsCellProps = { + row: T; + field: LineItemsField; + mode: LineItemsMode; + align: "left" | "center" | "right"; + editable: boolean; + /** Renders the fill handle — the active cell of a fillable column only. */ + showFillGrip: boolean; + onCommit: (value: unknown) => void; + /** Syncs the grid's active coordinate when an editor takes focus by any route. */ + onFocusCell: () => void; + onNavigate: (to: CellNavigation) => void; + /** Alt+Arrow moves the active cell without leaving the editor. */ + onArrowFromEditor: (key: ArrowKey, shift: boolean) => void; + onFillGripPointerDown: (event: React.PointerEvent) => void; +}; + +function toEditorValue(value: unknown): string { + if (value === null || value === undefined) return ""; + return String(value); +} + +const optionLabel = (o: LineItemsSelectOption): string => + o.description ? `${o.label} ${o.description}` : o.label; +const optionValue = (o: LineItemsSelectOption): string => o.value; + +/** 6px square on the cell's bottom-right corner; drag it to fill down a column. */ +function FillGrip({ onPointerDown }: { onPointerDown: (e: React.PointerEvent) => void }) { + return ( + + ); +} + +/** + * A searchable select cell. Keeps the current value selectable even when it is + * absent from `options`, so a value the catalogue no longer offers is never + * silently dropped. + */ +function SelectCell({ + field, + value, + onCommit, + onFocusCell, + onNavigate, + onArrowFromEditor, +}: Pick< + LineItemsCellProps, + "field" | "onCommit" | "onFocusCell" | "onNavigate" | "onArrowFromEditor" +> & { value: unknown }) { + const type = field.type; + const current = toEditorValue(value); + + const items = React.useMemo((): LineItemsSelectOption[] => { + if (!type || type.kind !== "select") return []; + const base = [...type.options]; + if (current && !base.some((o) => o.value === current)) { + base.push({ value: current, label: current }); + } + return base; + }, [type, current]); + + if (!type || type.kind !== "select") return null; + const selected = items.find((o) => o.value === current) ?? null; + + return ( + + items={items} + value={selected} + onValueChange={(next) => onCommit(next?.value ?? "")} + itemToStringLabel={optionLabel} + itemToStringValue={optionValue} + > + + { + onFocusCell(); + e.currentTarget.select(); + }} + onKeyDown={(e) => { + if (e.altKey && isArrowKey(e.key)) { + e.preventDefault(); + onArrowFromEditor(e.key, e.shiftKey); + return; + } + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + e.stopPropagation(); + onNavigate("enter-down"); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + e.stopPropagation(); + onNavigate(e.shiftKey ? "shift-tab" : "tab"); + } + }} + /> + + + + No matches. + + {(item: LineItemsSelectOption) => ( + +
+ {item.label} + {item.description ? ( + + {item.description} + + ) : null} +
+
+ )} +
+
+ + ); +} + +function isArrowKey(key: string): key is ArrowKey { + return key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight"; +} + +/** + * One grid cell's content. + * + * Edits are held locally and committed on blur or Enter, never per keystroke: + * committing every character re-renders the grid and costs the input its focus + * mid-word. + */ +function LineItemsCellInner({ + row, + field, + mode, + align, + editable, + showFillGrip, + onCommit, + onFocusCell, + onNavigate, + onArrowFromEditor, + onFillGripPointerDown, +}: LineItemsCellProps) { + const rawValue = (row as Record)[field.key]; + const [draft, setDraft] = React.useState(() => toEditorValue(rawValue)); + const inputRef = React.useRef(null); + + // Reseed when the committed value changes underneath the editor (paste, fill, + // revert), but never while the user is typing into it. + React.useEffect(() => { + if (document.activeElement === inputRef.current) return; + setDraft(toEditorValue(rawValue)); + }, [rawValue]); + + const commit = React.useCallback( + (next: string) => { + if (next === toEditorValue(rawValue)) return; + onCommit(next); + }, + [onCommit, rawValue], + ); + + const grip = showFillGrip ? : null; + + if (!editable) { + return ( + <> + + {field.render(row)} + + {grip} + + ); + } + + if (field.type?.kind === "custom") { + return ( + <> +
+ {field.type.renderEditor({ + value: rawValue, + onCommit, + onCancel: () => setDraft(toEditorValue(rawValue)), + row, + mode, + field, + })} +
+ {grip} + + ); + } + + if (field.type?.kind === "select") { + return ( + <> +
+ +
+ {grip} + + ); + } + + if (field.type?.kind === "boolean") { + return ( + <> + + {grip} + + ); + } + + const kind = field.type?.kind ?? "text"; + const isNumber = kind === "number"; + + return ( + <> + setDraft(e.target.value)} + onFocus={(e) => { + onFocusCell(); + try { + e.currentTarget.select(); + } catch { + // Safari throws on select() for some input types; harmless. + } + }} + onBlur={() => commit(draft)} + onKeyDown={(e) => { + if (e.altKey && isArrowKey(e.key)) { + e.preventDefault(); + onArrowFromEditor(e.key, e.shiftKey); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + commit(draft); + onNavigate(e.shiftKey ? "enter-up" : "enter-down"); + return; + } + if (e.key === "Tab") { + e.preventDefault(); + commit(draft); + onNavigate(e.shiftKey ? "shift-tab" : "tab"); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + e.stopPropagation(); + setDraft(toEditorValue(rawValue)); + // Re-commit the stored value so a document-scoped field settles back + // to exactly what the baseline holds. + if (fieldCommitScope(field) === "document") onCommit(rawValue); + } + }} + className={cn( + editorClasses, + alignClasses[align], + isNumber && numberEditorClasses, + typeof field.className === "function" ? field.className(row) : field.className, + )} + /> + {grip} + + ); +} + +export const LineItemsCell = React.memo(LineItemsCellInner) as typeof LineItemsCellInner; diff --git a/packages/core/src/components/line-items/line-items-context.tsx b/packages/core/src/components/line-items/line-items-context.tsx new file mode 100644 index 000000000..127c00321 --- /dev/null +++ b/packages/core/src/components/line-items/line-items-context.tsx @@ -0,0 +1,41 @@ +import * as React from "react"; + +import type { LineItemsRowData, UseLineItemsReturn } from "./types"; + +/** Cell contents for the totals row, keyed by column id. */ +export type LineItemsTotalsRenderer = ( + lines: LineItemsRowData[], +) => Record; + +export type LineItemsContextValue = { + lineItems: UseLineItemsReturn; + fullscreen: boolean; + setFullscreen: (next: boolean) => void; + /** + * Registered by `` so the table can render it inside its + * own ``, where the cells stay aligned with the columns above. + */ + totalsRenderer: LineItemsTotalsRenderer | undefined; + setTotalsRenderer: (renderer: LineItemsTotalsRenderer | undefined) => void; +}; + +const LineItemsContext = React.createContext(null); + +export const LineItemsContextProvider = LineItemsContext.Provider; + +/** + * Reads the enclosing `` value. + * + * Grid interaction state (active cell, selected range, drag) deliberately does + * not live here — it stays local to `` and reaches cells as + * props, so a pointer drag across the grid cannot re-render every sibling part. + */ +export function useLineItemsContext( + part: string, +): LineItemsContextValue { + const ctx = React.useContext(LineItemsContext); + if (!ctx) { + throw new Error(` must be used within `); + } + return ctx as LineItemsContextValue; +} diff --git a/packages/core/src/components/line-items/line-items-parts.tsx b/packages/core/src/components/line-items/line-items-parts.tsx new file mode 100644 index 000000000..c8d97daba --- /dev/null +++ b/packages/core/src/components/line-items/line-items-parts.tsx @@ -0,0 +1,534 @@ +import * as React from "react"; +import { Maximize2, Minimize2, Search as SearchIcon, X } from "lucide-react"; + +import { Button } from "@/components/button"; +import { cn } from "@/lib/utils"; +import { useLineItemsContext, type LineItemsTotalsRenderer } from "./line-items-context"; +import type { LineItemsChangeSet, LineItemsRowData } from "./types"; + +/* ------------------------------------------------------------------ Search */ + +export type LineItemsSearchProps = { + placeholder?: string; + /** Set `false` to drop the leading magnifier. */ + showIcon?: boolean; + className?: string; +}; + +const searchInputClasses = + "astw:border-input astw:h-9 astw:w-full astw:rounded-md astw:border astw:bg-transparent astw:text-sm astw:shadow-xs astw:outline-none astw:placeholder:text-muted-foreground astw:focus-visible:border-ring astw:focus-visible:ring-[3px] astw:focus-visible:ring-ring/50"; + +/** Always-visible search box bound to the filter. */ +function LineItemsSearch({ + placeholder = "Search lines", + showIcon = true, + className, +}: LineItemsSearchProps) { + const { lineItems } = useLineItemsContext("Search"); + return ( +
+ {showIcon ? ( +
+ ); +} +LineItemsSearch.displayName = "LineItems.Search"; + +export type LineItemsSearchToggleProps = { + placeholder?: string; + expandedWidth?: number; + variant?: React.ComponentProps["variant"]; + className?: string; +}; + +/** + * Icon button that expands into a search input, then collapses again on blur + * when empty. Fits a card header where the full-width `Search` box does not. + */ +function LineItemsSearchToggle({ + placeholder = "Search lines", + expandedWidth = 240, + variant = "outline", + className, +}: LineItemsSearchToggleProps) { + const { lineItems } = useLineItemsContext("SearchToggle"); + const [expanded, setExpanded] = React.useState(false); + const inputRef = React.useRef(null); + + React.useEffect(() => { + if (expanded) inputRef.current?.focus(); + }, [expanded]); + + if (!expanded) { + return ( + + ); + } + + return ( +
+
+ ); +} +LineItemsSearchToggle.displayName = "LineItems.SearchToggle"; + +/* ------------------------------------------------------------ Bulk actions */ + +export type LineItemsBulkActionsRenderArgs = { + selectedIds: string[]; + bulkUpdate: (patch: Record) => void; + bulkRemove: () => void; + clear: () => void; +}; + +export type LineItemsBulkActionsProps = { + children: (args: LineItemsBulkActionsRenderArgs) => React.ReactNode; + className?: string; +}; + +/** Inline toolbar for the current selection. Renders nothing when none. */ +function LineItemsBulkActions({ children, className }: LineItemsBulkActionsProps) { + const { lineItems } = useLineItemsContext("BulkActions"); + if (lineItems.selectedIds.length === 0) return null; + + return ( +
+ {lineItems.selectedIds.length} selected + {children({ + selectedIds: lineItems.selectedIds, + bulkUpdate: lineItems.bulkUpdate as (patch: Record) => void, + bulkRemove: lineItems.bulkRemove, + clear: lineItems.clearSelection, + })} +
+ ); +} +LineItemsBulkActions.displayName = "LineItems.BulkActions"; + +/* ----------------------------------------------------------------- Add row */ + +export type LineItemsAddRowProps = { + /** Host content — typically a catalogue combobox plus a bulk-add button. */ + children?: React.ReactNode; + className?: string; +}; + +/** + * Slot pinned below the table for adding lines. Give it whatever the document + * needs — a searchable product combobox, a bulk-add button — and drive + * `addLine`/`addLines` from it. Renders nothing in view mode. + */ +function LineItemsAddRow({ children, className }: LineItemsAddRowProps) { + const { lineItems } = useLineItemsContext("AddRow"); + if (lineItems.mode === "view") return null; + + return ( +
+ {children} +
+ ); +} +LineItemsAddRow.displayName = "LineItems.AddRow"; + +/* ------------------------------------------------------- Fullscreen toggle */ + +export type LineItemsFullscreenToggleProps = { + variant?: React.ComponentProps["variant"]; + className?: string; +}; + +/** Expands the hosting card to a fullscreen overlay, and back. */ +function LineItemsFullscreenToggle({ + variant = "outline", + className, +}: LineItemsFullscreenToggleProps) { + const { fullscreen, setFullscreen } = useLineItemsContext("FullscreenToggle"); + const Icon = fullscreen ? Minimize2 : Maximize2; + + return ( + + ); +} +LineItemsFullscreenToggle.displayName = "LineItems.FullscreenToggle"; + +/* ------------------------------------------------------------ Save actions */ + +export type LineItemsSaveActionsProps = { + onSave: (changeSet: LineItemsChangeSet) => void; + /** Defaults to reverting to the baseline. */ + onDiscard?: () => void; + saveLabel?: React.ReactNode; + discardLabel?: React.ReactNode; + saving?: boolean; + className?: string; +}; + +function LineItemsSaveActions({ + onSave, + onDiscard, + saveLabel = "Save changes", + discardLabel = "Discard", + saving = false, + className, +}: LineItemsSaveActionsProps) { + const { lineItems } = useLineItemsContext("SaveActions"); + if (lineItems.mode === "view") return null; + + return ( +
+ + +
+ ); +} +LineItemsSaveActions.displayName = "LineItems.SaveActions"; + +/* -------------------------------------------------------------- Totals row */ + +export type LineItemsTotalsRowProps = { + /** Cell contents keyed by column id. Unlisted columns render empty. */ + children: (lines: T[]) => Record; +}; + +/** + * Registers a totals row with the table, which renders it in its own `` + * so the cells inherit each column's width, alignment and pinning. Renders no + * DOM of its own — place it anywhere inside ``. + */ +function LineItemsTotalsRow({ + children, +}: LineItemsTotalsRowProps) { + const { setTotalsRenderer } = useLineItemsContext("TotalsRow"); + + // Held in a ref so an inline arrow does not re-register every render. + const rendererRef = React.useRef(children); + rendererRef.current = children; + + React.useEffect(() => { + const renderer: LineItemsTotalsRenderer = (lines) => + rendererRef.current(lines as unknown as T[]); + setTotalsRenderer(renderer); + return () => setTotalsRenderer(undefined); + }, [setTotalsRenderer]); + + return null; +} +LineItemsTotalsRow.displayName = "LineItems.TotalsRow"; + +/* ------------------------------------------------------------ Floating dock */ + +/** + * Inline styles so consumer buttons dropped into the dark pills match their + * chrome without needing the package's own utility classes. + */ +export const lineItemsFloatingBarStyles = { + primaryButton: { + background: "var(--background)", + color: "var(--foreground)", + borderRadius: "0.375rem", + padding: "0.25rem 0.625rem", + fontSize: "0.75rem", + fontWeight: 500, + }, + secondaryButton: { + background: "transparent", + color: "var(--background)", + borderRadius: "0.375rem", + padding: "0.25rem 0.625rem", + fontSize: "0.75rem", + fontWeight: 500, + }, + divider: { width: 1, height: "1rem", background: "currentColor", opacity: 0.3 }, + label: { fontSize: "0.75rem", opacity: 0.8 }, +} as const; + +const JIGGLE_KEYFRAMES = ` +@keyframes line-items-floating-jiggle{ +0%{transform:translateX(0)}15%{transform:translateX(-8px)}30%{transform:translateX(8px)} +45%{transform:translateX(-6px)}60%{transform:translateX(6px)}75%{transform:translateX(-3px)} +100%{transform:translateX(0)}} +`; + +export type LineItemsFloatingDockProps = { + children?: React.ReactNode; + className?: string; +}; + +/** Fixed bottom-center of the viewport, above page chrome. */ +function LineItemsFloatingDock({ children, className }: LineItemsFloatingDockProps) { + return ( + <> + +
+ {children} +
+ + ); +} +LineItemsFloatingDock.displayName = "LineItems.FloatingDock"; + +const pillClasses = + "astw:pointer-events-auto astw:flex astw:items-center astw:gap-3 astw:rounded-lg astw:bg-foreground astw:px-3 astw:py-2 astw:text-xs astw:text-background astw:shadow-lg"; + +const jiggleStyle = { + animation: "line-items-floating-jiggle 0.5s cubic-bezier(0.36,0.07,0.19,0.97)", +}; + +export type LineItemsDirtyBarProps = { + onSave?: (changeSet: LineItemsChangeSet) => void; + onDiscard?: () => void; + label?: (pendingCount: number) => React.ReactNode; + saveLabel?: React.ReactNode; + discardLabel?: React.ReactNode; + /** + * Intercepts in-app link clicks and browser unload while the document is + * dirty, shaking the bar instead of letting the edits be lost. + */ + warnOnNav?: boolean; + children?: (pendingCount: number) => React.ReactNode; + className?: string; +}; + +/** Appears while there are uncommitted changes. Renders nothing when clean. */ +function LineItemsDirtyBar({ + onSave, + onDiscard, + label, + saveLabel = "Save", + discardLabel = "Discard", + warnOnNav = false, + children, + className, +}: LineItemsDirtyBarProps) { + const { lineItems } = useLineItemsContext("DirtyBar"); + const [jiggleNonce, setJiggleNonce] = React.useState(0); + const isDirty = lineItems.isDirty; + + React.useEffect(() => { + if (!warnOnNav || !isDirty) return; + + const jiggle = () => setJiggleNonce((n) => n + 1); + + const onClick = (event: MouseEvent) => { + if (event.defaultPrevented) return; + const target = event.target as HTMLElement | null; + if (!target) return; + // The dock's own controls, and anything inside the hosting card, are how + // the user resolves the dirty state — never block those. + if (target.closest("[data-slot=line-items-floating-dock]")) return; + if (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 === "#") return; + if (anchor.target && anchor.target !== "_self") return; + event.preventDefault(); + event.stopPropagation(); + jiggle(); + }; + + const onBeforeUnload = (event: BeforeUnloadEvent) => { + event.preventDefault(); + event.returnValue = ""; + jiggle(); + }; + + document.addEventListener("click", onClick, true); + globalThis.addEventListener("beforeunload", onBeforeUnload); + return () => { + document.removeEventListener("click", onClick, true); + globalThis.removeEventListener("beforeunload", onBeforeUnload); + }; + }, [isDirty, warnOnNav]); + + if (!isDirty) return null; + + const pending = lineItems.getChangeSet().lineChanges.length; + + return ( +
0 ? jiggleStyle : undefined} + > + + {label ? label(pending) : `${pending} unsaved ${pending === 1 ? "change" : "changes"}`} + + {children ? ( + children(pending) + ) : ( + <> +
+ ); +} +LineItemsDirtyBar.displayName = "LineItems.DirtyBar"; + +export type LineItemsSelectionBarRenderArgs = LineItemsBulkActionsRenderArgs; + +export type LineItemsSelectionBarProps = { + label?: (count: number, total: number) => React.ReactNode; + children?: (args: LineItemsSelectionBarRenderArgs) => React.ReactNode; + className?: string; +}; + +/** + * Appears while rows are selected. Layout mirrors DataTable's selection footer + * (count, then actions, then Clear) so the two components read the same way. + */ +function LineItemsSelectionBar({ label, children, className }: LineItemsSelectionBarProps) { + const { lineItems } = useLineItemsContext("SelectionBar"); + const count = lineItems.selectedIds.length; + if (count === 0) return null; + + return ( +
+ + {label ? label(count, lineItems.allLines.length) : `${count} selected`} + +
+ ); +} +LineItemsSelectionBar.displayName = "LineItems.SelectionBar"; + +export { + LineItemsAddRow, + LineItemsBulkActions, + LineItemsDirtyBar, + LineItemsFloatingDock, + LineItemsFullscreenToggle, + LineItemsSaveActions, + LineItemsSearch, + LineItemsSearchToggle, + LineItemsSelectionBar, + LineItemsTotalsRow, +}; diff --git a/packages/core/src/components/line-items/line-items-skeleton-row.tsx b/packages/core/src/components/line-items/line-items-skeleton-row.tsx new file mode 100644 index 000000000..88b6482ab --- /dev/null +++ b/packages/core/src/components/line-items/line-items-skeleton-row.tsx @@ -0,0 +1,35 @@ +import { cn } from "@/lib/utils"; + +export type LineItemsSkeletonRowProps = { + /** Seeds the per-cell bar width so stacked rows do not align into columns. */ + rowIndex: number; + columnCount: number; + rowHeight: number; +}; + +/** One placeholder row shown while an initial fetch is in flight. */ +export function LineItemsSkeletonRow({ + rowIndex, + columnCount, + rowHeight, +}: LineItemsSkeletonRowProps) { + return ( + + {Array.from({ length: columnCount }, (_, col) => ( + + + + ))} + + ); +} + +LineItemsSkeletonRow.displayName = "LineItems.SkeletonRow"; diff --git a/packages/core/src/components/line-items/line-items-table.tsx b/packages/core/src/components/line-items/line-items-table.tsx new file mode 100644 index 000000000..8a212a88b --- /dev/null +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -0,0 +1,1025 @@ +import * as React from "react"; +import { ChevronDown, ChevronsUpDown, ChevronUp, GripVertical } from "lucide-react"; + +import { Checkbox } from "@/components/checkbox"; +import { cn } from "@/lib/utils"; +import { + ACTIONS_COLUMN_ID, + ACTIONS_COLUMN_WIDTH, + computeColumnLayout, + GRIP_COLUMN_ID, + GRIP_COLUMN_WIDTH, + pinStyle, + SELECTION_COLUMN_ID, + SELECTION_COLUMN_WIDTH, + SPACER_COLUMN_ID, + type ColumnWidths, + type PinPlacement, +} from "./column-layout"; +import { + fieldAllowsFill, + fieldIsAmendLocked, + fieldIsEditableInMode, + fieldsToColumnDefs, +} from "./field"; +import { normalizeField } from "./internals"; +import { LineItemsCell, type ArrowKey, type CellNavigation } from "./line-items-cell"; +import { useLineItemsContext } from "./line-items-context"; +import { LineItemsSkeletonRow } from "./line-items-skeleton-row"; +import { + coordsToRowsMatrix, + parseClipboardTsv, + serializeMatrixTsv, + type GridCoord, +} from "./spreadsheet-logic"; +import { coordKey, useGridSelection } from "./use-grid-selection"; +import { useRowVirtualizer } from "./use-row-virtualizer"; +import type { LineItemsRowData } from "./types"; + +export type LineItemsTableProps = { + /** Uniform row height in pixels, which the virtualizer's math depends on. */ + rowHeight?: number; + /** Height of the scroll viewport. Ignored in fullscreen, which fills the overlay. */ + maxBodyHeight?: React.CSSProperties["maxHeight"]; + /** Set `false` to mount every row (short documents, print, tests). */ + virtualized?: boolean; + /** Trailing pinned column of per-row controls. */ + rowActions?: (row: T) => React.ReactNode; + rowActionsWidth?: number; + /** + * Sticky footer row, rendered in a `` of the same table so its cells + * stay aligned with the columns above. Prefer ``, which + * handles column alignment and pinning for you. + */ + totalsRow?: (lines: T[]) => React.ReactNode; + emptyMessage?: React.ReactNode; + /** Swaps the body for skeleton rows while an initial fetch is in flight. */ + loading?: boolean; + skeletonRowCount?: number; + className?: string; + tableContainerClassName?: string; +}; + +const headCellClasses = + "astw:h-10 astw:bg-card astw:px-2 astw:text-left astw:align-middle astw:text-xs astw:font-medium astw:text-muted-foreground astw:whitespace-nowrap astw:border-b astw:border-border"; + +const bodyCellClasses = + "astw:relative astw:h-9 astw:p-0 astw:align-middle astw:text-sm astw:whitespace-nowrap astw:overflow-hidden astw:border-b astw:border-r astw:border-border"; + +const alignClasses = { + left: "astw:text-left", + center: "astw:text-center", + right: "astw:text-right", +} as const; + +const ariaSortValues = { asc: "ascending", desc: "descending" } as const; +const sortIcons = { asc: ChevronUp, desc: ChevronDown } as const; + +/** + * Pinned cells stay opaque so scrolled content never shows through them, which + * means they cannot inherit the row's background — they have to repaint it, or + * a selected row goes un-tinted under the frozen columns. + */ +function pinClasses( + placement: PinPlacement | undefined, + opts?: { selected?: boolean }, +): string | undefined { + if (!placement) return undefined; + return cn( + "astw:z-[1]", + opts?.selected ? "astw:bg-muted" : "astw:bg-card", + placement.isBoundary && + (placement.side === "left" + ? "astw:shadow-[inset_-1px_0_0_0_var(--border)]" + : "astw:shadow-[inset_1px_0_0_0_var(--border)]"), + ); +} + +/** Thin scrollbars, so a dense grid is not framed by chunky OS gutters. */ +const SCROLLBAR_STYLES = ` +[data-slot="line-items-table-scroll"]{scrollbar-width:thin;scrollbar-color:rgba(128,128,128,0.4) transparent} +[data-slot="line-items-table-scroll"]::-webkit-scrollbar{width:8px;height:8px} +[data-slot="line-items-table-scroll"]::-webkit-scrollbar-track, +[data-slot="line-items-table-scroll"]::-webkit-scrollbar-corner{background:transparent} +[data-slot="line-items-table-scroll"]::-webkit-scrollbar-thumb{background:rgba(128,128,128,0.4);border-radius:4px} +`; + +/** + * The editable grid. + * + * Rows are windowed by a hand-rolled virtualizer (spacer rows above and below + * stand in for unmounted ones), so a thousand-line document mounts only the + * visible slice. Every editable cell in that slice mounts its own editor, which + * is what makes the grid read as a spreadsheet at rest. + */ +export function LineItemsTable({ + rowHeight = 36, + maxBodyHeight = "min(60vh, 480px)", + virtualized = true, + rowActions, + rowActionsWidth = ACTIONS_COLUMN_WIDTH, + totalsRow, + emptyMessage, + loading = false, + skeletonRowCount = 12, + className, + tableContainerClassName, +}: LineItemsTableProps) { + const { lineItems, fullscreen, totalsRenderer } = useLineItemsContext("Table"); + const { lines, fields, mode, ordering, selectionEnabled, selectedIds, sortState } = lineItems; + + const scrollRef = React.useRef(null); + const [widths, setWidths] = React.useState({}); + const [hoveredColumnId, setHoveredColumnId] = React.useState(null); + + const canReorder = ordering === "manual" && mode === "edit"; + const showSelection = selectionEnabled && mode !== "view"; + + const layout = React.useMemo( + () => + computeColumnLayout(fields, { + widths, + hasGrip: canReorder, + hasSelection: showSelection, + hasActions: Boolean(rowActions), + actionsWidth: rowActionsWidth, + }), + [canReorder, fields, rowActions, rowActionsWidth, showSelection, widths], + ); + + const columnDefs = React.useMemo(() => fieldsToColumnDefs(fields), [fields]); + const orderedLineRefs = React.useMemo(() => lines.map((l) => l.lineRef), [lines]); + + /** + * Every schema column takes part in selection and copy, so a computed column + * can be read out; paste and fill still refuse to write read-only ones. + */ + const gridColumnIds = React.useMemo(() => layout.columns.map((c) => c.id), [layout.columns]); + + const grid = useGridSelection(orderedLineRefs, gridColumnIds); + + const virtualWindow = useRowVirtualizer(scrollRef, { + rowCount: lines.length, + rowHeight, + enabled: virtualized && !loading, + }); + + const visibleLines = lines.slice(virtualWindow.startIndex, virtualWindow.endIndex); + const headRefs = React.useRef(new Map()); + + /** + * Measures rendered header widths so pinned columns stack without every one + * declaring a `width`. Skipped while a column is hover-expanded, so the + * expansion does not get baked into the measured layout. + */ + React.useEffect(() => { + if (hoveredColumnId) return; + const measured: ColumnWidths = {}; + let changed = false; + for (const [id, el] of headRefs.current) { + if (!el) continue; + const w = Math.round(el.getBoundingClientRect().width); + if (w <= 0) continue; + measured[id] = w; + if (widths[id] !== w) changed = true; + } + if (changed) setWidths((prev) => ({ ...prev, ...measured })); + }, [hoveredColumnId, layout.columns, lines.length, widths]); + + const lineByRef = React.useMemo(() => { + const map = new Map(); + for (const l of lines) map.set(l.lineRef, l); + return map; + }, [lines]); + + const fieldById = React.useMemo(() => { + const map = new Map(); + for (const f of fields) map.set(f.key, f); + return map; + }, [fields]); + + const displayValueOf = React.useCallback( + (lineRef: string, columnId: string): string => { + const row = lineByRef.get(lineRef); + if (!row) return ""; + const raw = (row as Record)[columnId]; + return raw === null || raw === undefined ? "" : String(raw); + }, + [lineByRef], + ); + + const commitCell = React.useCallback( + (coord: GridCoord, value: unknown) => { + const row = lineByRef.get(coord.lineRef); + if (!row) return; + const normalized = normalizeField(columnDefs, coord.columnId, value, row); + lineItems.updateLines([ + { lineRef: coord.lineRef, patch: { [coord.columnId]: normalized } as Partial }, + ]); + }, + [columnDefs, lineByRef, lineItems], + ); + + /** Keeps the active cell in view without stealing focus or animating. */ + const scrollActiveIntoView = React.useCallback( + (coord: GridCoord) => { + const container = scrollRef.current; + if (!container) return; + const index = orderedLineRefs.indexOf(coord.lineRef); + if (index < 0) return; + const top = index * rowHeight; + const bottom = top + rowHeight; + if (top < container.scrollTop) container.scrollTop = top; + else if (bottom > container.scrollTop + container.clientHeight) { + container.scrollTop = bottom - container.clientHeight; + } + }, + [orderedLineRefs, rowHeight], + ); + + /** Moves DOM focus to a cell's editor, so navigation lands in the next input. */ + const focusCellEditor = React.useCallback((coord: GridCoord) => { + const container = scrollRef.current; + if (!container) return; + const cell = container.querySelector( + `[data-line-ref="${CSS.escape(coord.lineRef)}"][data-column-id="${CSS.escape(coord.columnId)}"]`, + ); + cell?.querySelector("[data-slot=line-items-cell-editor]")?.focus(); + }, []); + + const moveActive = React.useCallback( + (key: ArrowKey, shift?: boolean) => { + const next = grid.moveActive(key, { shift }); + if (!next) return; + scrollActiveIntoView(next); + if (!shift) focusCellEditor(next); + }, + [focusCellEditor, grid, scrollActiveIntoView], + ); + + /** + * Enter and Tab wrap at the row edges, so tabbing straight through a document + * never dead-ends: Tab past the last column lands on the next row's first + * column, and Shift+Tab past the first wraps back up to the previous row's last. + */ + const navigateFromEdit = React.useCallback( + (to: CellNavigation, from: GridCoord) => { + const rowIndex = orderedLineRefs.indexOf(from.lineRef); + const colIndex = gridColumnIds.indexOf(from.columnId); + if (rowIndex < 0 || colIndex < 0) return; + + let nextRow = rowIndex; + let nextCol = colIndex; + + if (to === "enter-down") nextRow = Math.min(orderedLineRefs.length - 1, rowIndex + 1); + else if (to === "enter-up") nextRow = Math.max(0, rowIndex - 1); + else if (to === "tab") { + if (colIndex === gridColumnIds.length - 1) { + if (rowIndex === orderedLineRefs.length - 1) return; + nextRow = rowIndex + 1; + nextCol = 0; + } else nextCol = colIndex + 1; + } else if (to === "shift-tab") { + if (colIndex === 0) { + if (rowIndex === 0) return; + nextRow = rowIndex - 1; + nextCol = gridColumnIds.length - 1; + } else nextCol = colIndex - 1; + } + + const lineRef = orderedLineRefs[nextRow]; + const columnId = gridColumnIds[nextCol]; + if (!lineRef || !columnId) return; + + const coord = { lineRef, columnId }; + grid.setActive(coord); + scrollActiveIntoView(coord); + focusCellEditor(coord); + }, + [focusCellEditor, gridColumnIds, grid, orderedLineRefs, scrollActiveIntoView], + ); + + /* ---- Fill gesture ----------------------------------------------------- */ + + const [fillSource, setFillSource] = React.useState(null); + const [fillHoverLineRef, setFillHoverLineRef] = React.useState(null); + + const isInFillPreview = React.useCallback( + (coord: GridCoord): boolean => { + if (!fillSource || !fillHoverLineRef) return false; + if (coord.columnId !== fillSource.columnId) return false; + const a = orderedLineRefs.indexOf(fillSource.lineRef); + const b = orderedLineRefs.indexOf(fillHoverLineRef); + const i = orderedLineRefs.indexOf(coord.lineRef); + if (a < 0 || b < 0 || i < 0) return false; + return i >= Math.min(a, b) && i <= Math.max(a, b); + }, + [fillHoverLineRef, fillSource, orderedLineRefs], + ); + + /** + * Tracks the pointer against rendered cells while a fill is in progress and + * broadcasts the source value across the painted range on release. + */ + React.useEffect(() => { + if (!fillSource) return; + + const onMove = (e: PointerEvent) => { + const el = document.elementFromPoint(e.clientX, e.clientY); + const cell = el?.closest("[data-slot=line-items-grid-cell]"); + const lineRef = cell?.dataset.lineRef; + if (lineRef) setFillHoverLineRef(lineRef); + }; + + const onUp = () => { + const targetRef = fillHoverLineRef; + const source = fillSource; + setFillSource(null); + setFillHoverLineRef(null); + if (!targetRef || targetRef === source.lineRef) return; + + const field = fieldById.get(source.columnId); + const sourceRow = lineByRef.get(source.lineRef); + if (!field || !sourceRow || !fieldAllowsFill(field, mode)) return; + + const a = orderedLineRefs.indexOf(source.lineRef); + const b = orderedLineRefs.indexOf(targetRef); + if (a < 0 || b < 0) return; + + const value = (sourceRow as Record)[source.columnId]; + const patches: { lineRef: string; patch: Partial }[] = []; + for (let i = Math.min(a, b); i <= Math.max(a, b); i++) { + const ref = orderedLineRefs[i]; + if (!ref || ref === source.lineRef) continue; + const row = lineByRef.get(ref); + if (!row) continue; + patches.push({ + lineRef: ref, + patch: { + [source.columnId]: normalizeField(columnDefs, source.columnId, value, row), + } as Partial, + }); + } + if (patches.length > 0) lineItems.updateLines(patches); + }; + + globalThis.addEventListener("pointermove", onMove); + globalThis.addEventListener("pointerup", onUp); + return () => { + globalThis.removeEventListener("pointermove", onMove); + globalThis.removeEventListener("pointerup", onUp); + }; + }, [ + columnDefs, + fieldById, + fillHoverLineRef, + fillSource, + lineByRef, + lineItems, + mode, + orderedLineRefs, + ]); + + /* ---- Clipboard -------------------------------------------------------- */ + + const handleCopy = React.useCallback( + (event: React.ClipboardEvent) => { + if (grid.selected.size <= 1) return; + const coords: GridCoord[] = []; + for (const lineRef of orderedLineRefs) { + for (const columnId of gridColumnIds) { + if (grid.selected.has(coordKey(lineRef, columnId))) coords.push({ lineRef, columnId }); + } + } + if (coords.length === 0) return; + const matrix = coordsToRowsMatrix(orderedLineRefs, gridColumnIds, coords, displayValueOf); + if (matrix.length === 0) return; + event.preventDefault(); + event.clipboardData.setData("text/plain", serializeMatrixTsv(matrix)); + }, + [displayValueOf, grid.selected, gridColumnIds, orderedLineRefs], + ); + + /** + * Pastes a TSV block anchored at the active cell. A single copied value is + * broadcast across the whole selection (Excel's behavior); a larger block is + * written as a rectangle. Read-only columns in range are skipped. + */ + const handlePaste = React.useCallback( + (event: React.ClipboardEvent) => { + if (!grid.active || mode === "view") return; + const text = event.clipboardData.getData("text/plain"); + const matrix = parseClipboardTsv(text); + if (matrix.length === 0) return; + event.preventDefault(); + + const patches = new Map>(); + const put = (lineRef: string, columnId: string, raw: string) => { + const field = fieldById.get(columnId); + const row = lineByRef.get(lineRef); + if (!field || !row || !fieldIsEditableInMode(field, mode)) return; + const patch = patches.get(lineRef) ?? {}; + patch[columnId] = normalizeField(columnDefs, columnId, raw, row); + patches.set(lineRef, patch); + }; + + const single = matrix.length === 1 && matrix[0]?.length === 1; + if (single && grid.selected.size > 1) { + const raw = matrix[0]![0]!; + for (const lineRef of orderedLineRefs) { + for (const columnId of gridColumnIds) { + if (grid.selected.has(coordKey(lineRef, columnId))) put(lineRef, columnId, raw); + } + } + } else { + const startRow = orderedLineRefs.indexOf(grid.active.lineRef); + const startCol = gridColumnIds.indexOf(grid.active.columnId); + if (startRow < 0 || startCol < 0) return; + matrix.forEach((cells, r) => { + const lineRef = orderedLineRefs[startRow + r]; + if (!lineRef) return; + cells.forEach((raw, c) => { + const columnId = gridColumnIds[startCol + c]; + if (columnId) put(lineRef, columnId, raw); + }); + }); + } + + if (patches.size === 0) return; + lineItems.updateLines( + [...patches].map(([lineRef, patch]) => ({ lineRef, patch: patch as Partial })), + ); + }, + [ + columnDefs, + fieldById, + grid.active, + grid.selected, + gridColumnIds, + lineByRef, + lineItems, + mode, + orderedLineRefs, + ], + ); + + const handleGridKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey) return; + const target = event.target as HTMLElement; + // An open editor owns its own keys; it forwards navigation explicitly. + if (target.dataset?.slot === "line-items-cell-editor") return; + if (!grid.active) return; + + const key = event.key; + if (key === "ArrowUp" || key === "ArrowDown" || key === "ArrowLeft" || key === "ArrowRight") { + event.preventDefault(); + moveActive(key, event.shiftKey); + return; + } + if (key === "Escape") grid.clearSelection(); + }, + [grid, moveActive], + ); + + /* ---- Row reordering --------------------------------------------------- */ + + const [dragRef, setDragRef] = React.useState(null); + const [dropBefore, setDropBefore] = React.useState(null); + const [liftedRef, setLiftedRef] = React.useState(null); + + const moveLine = React.useCallback( + (lineRef: string, beforeLineRef: string | null) => { + const others = orderedLineRefs.filter((id) => id !== lineRef); + const at = beforeLineRef ? others.indexOf(beforeLineRef) : others.length; + lineItems.reorderLine(lineRef, at <= 0 ? null : (others[at - 1] ?? null)); + }, + [lineItems, orderedLineRefs], + ); + + const endDrag = React.useCallback(() => { + setDragRef(null); + setDropBefore(null); + }, []); + + const nudge = React.useCallback( + (lineRef: string, delta: -1 | 1) => { + const index = orderedLineRefs.indexOf(lineRef); + const target = index + delta; + if (index < 0 || target < 0 || target >= orderedLineRefs.length) return; + const others = orderedLineRefs.filter((id) => id !== lineRef); + lineItems.reorderLine(lineRef, target === 0 ? null : (others[target - 1] ?? null)); + }, + [lineItems, orderedLineRefs], + ); + + const utilityColumnCount = (showSelection ? 1 : 0) + (canReorder ? 1 : 0) + (rowActions ? 1 : 0); + const columnCount = layout.columns.length + utilityColumnCount; + const allVisibleSelected = + lines.length > 0 && lines.every((l) => selectedIds.includes(l.lineRef)); + + /** + * A hover-expanded column takes its extra width from the flex column, which is + * frozen at its own measured width for the duration so the table grows + * sideways and scrolls, instead of the flex column collapsing. + */ + const flexColumnId = layout.columns.find((c) => c.flex)?.id; + const flexFrozenWidth = hoveredColumnId && flexColumnId ? widths[flexColumnId] : undefined; + + const colWidthStyle = (c: (typeof layout.columns)[number]): React.CSSProperties | undefined => { + if (c.flex) return flexFrozenWidth ? { width: flexFrozenWidth } : undefined; + return { width: c.width }; + }; + + /** + * Hover-expanding columns have their width animated imperatively rather than + * rendered: a `` ignores CSS transitions, so the width is tweened frame + * by frame. React never writes their width, or a re-render mid-tween would + * snap the column to its resting size. + */ + const colRefs = React.useRef(new Map()); + const tweenFrame = React.useRef(null); + + const tweenColumnWidth = React.useCallback((columnId: string, to: number) => { + const el = colRefs.current.get(columnId); + if (!el) return; + if (tweenFrame.current !== null) cancelAnimationFrame(tweenFrame.current); + + const from = Number.parseFloat(el.style.width) || to; + if (from === to) return; + + const started = performance.now(); + const duration = 180; + const step = (now: number) => { + const t = Math.min(1, (now - started) / duration); + const eased = 1 - (1 - t) ** 3; + el.style.width = `${from + (to - from) * eased}px`; + tweenFrame.current = t < 1 ? requestAnimationFrame(step) : null; + }; + tweenFrame.current = requestAnimationFrame(step); + }, []); + + React.useEffect( + () => () => { + if (tweenFrame.current !== null) cancelAnimationFrame(tweenFrame.current); + }, + [], + ); + + const enterColumn = (c: (typeof layout.columns)[number]) => { + if (!c.field.hoverExpandWidth) return; + setHoveredColumnId(c.id); + tweenColumnWidth(c.id, c.field.hoverExpandWidth); + }; + + const leaveColumn = (c: (typeof layout.columns)[number]) => { + if (!c.field.hoverExpandWidth) return; + setHoveredColumnId(null); + tweenColumnWidth(c.id, c.width ?? c.field.hoverExpandWidth); + }; + + return ( +
+ +
+ + + {showSelection ? : null} + {canReorder ? : null} + {layout.columns.map((c) => ( + { + colRefs.current.set(c.id, el); + // Seed the resting width once; the tween owns it from here. + if (el && c.field.hoverExpandWidth && !el.style.width) { + el.style.width = `${c.width ?? 0}px`; + } + }} + style={c.field.hoverExpandWidth ? undefined : colWidthStyle(c)} + /> + ))} + {rowActions ? : null} + {layout.needsSpacer ? : null} + + + + + {showSelection ? ( + + ) : null} + + {canReorder ? ( + + ) : null} + + {layout.columns.map((c) => { + const sortable = Boolean(c.field.sort) && ordering === "sort"; + const sorted = sortState?.columnId === c.id ? sortState.direction : undefined; + const SortIcon = sorted ? sortIcons[sorted] : ChevronsUpDown; + const sortedState = sorted ? ariaSortValues[sorted] : "none"; + + return ( + + ); + })} + + {rowActions ? ( + + ) : null} + + {layout.needsSpacer ? ( + + + + + {loading + ? Array.from({ length: skeletonRowCount }, (_, i) => ( + + )) + : null} + + {!loading && lines.length === 0 ? ( + + + + ) : null} + + {!loading && virtualWindow.paddingTop > 0 ? ( + + ) : null} + + {!loading && + visibleLines.map((row, i) => { + const rowIndex = virtualWindow.startIndex + i; + const isDragging = dragRef === row.lineRef; + const isLifted = liftedRef === row.lineRef; + const rowSelected = selectedIds.includes(row.lineRef); + // Drawn per cell rather than on the row, so it crosses the + // opaque pinned columns too. Listed last in each `cn` so it + // wins over a cell's selection ring while a drag is in flight. + const dropLine = + dropBefore === row.lineRef + ? "astw:shadow-[inset_0_2px_0_0_var(--color-primary)]" + : undefined; + + return ( + { + if (!dragRef) return; + e.preventDefault(); + setDropBefore(row.lineRef); + } + : undefined + } + onDrop={ + canReorder + ? (e) => { + if (!dragRef) return; + e.preventDefault(); + moveLine(dragRef, row.lineRef); + endDrag(); + } + : undefined + } + > + {showSelection ? ( + + ) : null} + + {canReorder ? ( + + ) : null} + + {layout.columns.map((c) => { + const coord = { lineRef: row.lineRef, columnId: c.id }; + const editable = fieldIsEditableInMode(c.field, mode); + const isActive = + grid.active?.lineRef === row.lineRef && grid.active?.columnId === c.id; + const isSelected = grid.isSelected(row.lineRef, c.id); + const inFill = isInFillPreview(coord); + const amendLocked = fieldIsAmendLocked(c.field, mode); + const extra = + typeof c.field.className === "function" + ? c.field.className(row) + : c.field.className; + + return ( + + ); + })} + + {rowActions ? ( + + ) : null} + + {layout.needsSpacer ? + ); + })} + + {!loading && virtualWindow.paddingBottom > 0 ? ( + + ) : null} + + + {totalsRenderer || totalsRow ? ( + + {totalsRow ? ( + totalsRow(lines) + ) : ( + + {showSelection ? + ))} + {rowActions ? + )} + + ) : null} +
void headRefs.current.set(SELECTION_COLUMN_ID, el)} + scope="col" + className={cn(headCellClasses, "astw:px-0", pinClasses(layout.selection))} + style={pinStyle(layout.selection)} + > + + 0 && !allVisibleSelected} + onCheckedChange={() => + allVisibleSelected + ? lineItems.clearSelection() + : lineItems.selectAllVisible() + } + /> + + void headRefs.current.set(GRIP_COLUMN_ID, el)} + scope="col" + className={cn(headCellClasses, "astw:px-0", pinClasses(layout.grip))} + style={pinStyle(layout.grip)} + > + Reorder + void headRefs.current.set(c.id, el)} + scope="col" + aria-sort={sortable ? sortedState : undefined} + onPointerEnter={c.field.hoverExpandWidth ? () => enterColumn(c) : undefined} + onPointerLeave={c.field.hoverExpandWidth ? () => leaveColumn(c) : undefined} + className={cn(headCellClasses, alignClasses[c.align], pinClasses(c.placement))} + style={pinStyle(c.placement)} + > + {sortable ? ( + + ) : ( + c.field.label + )} + void headRefs.current.set(ACTIONS_COLUMN_ID, el)} + scope="col" + className={cn(headCellClasses, "astw:px-0", pinClasses(layout.actions))} + style={pinStyle(layout.actions)} + > + Actions + void headRefs.current.set(SPACER_COLUMN_ID, el)} + className={headCellClasses} + /> + ) : null} +
+ {emptyMessage ?? "No lines yet."} +
+ + lineItems.toggleSelect(row.lineRef)} + /> + + + + + + { + if (c.field.hoverExpandWidth) enterColumn(c); + // A fill in progress tracks the row under the pointer. + // The window-level handler covers fast drags that + // outrun per-cell events; this covers the rest. + if (fillSource) setFillHoverLineRef(row.lineRef); + else grid.extendSelection(coord); + }} + onPointerLeave={ + c.field.hoverExpandWidth ? () => leaveColumn(c) : undefined + } + onPointerDown={(e) => { + if (e.button !== 0) return; + const target = e.target as HTMLElement; + const interactive = Boolean( + target.closest( + "input, select, button, textarea, [role=combobox], [data-slot=line-items-fill-grip]", + ), + ); + if (e.shiftKey) { + e.preventDefault(); + grid.beginSelection(coord, { shift: true }); + return; + } + // Clicking into an editor must place a caret, so only a + // press on inert cell chrome starts a range drag. + if (interactive) grid.setActive(coord); + else grid.beginSelection(coord); + }} + onPointerUp={grid.endSelection} + > + commitCell(coord, value)} + onFocusCell={() => grid.setActive(coord)} + onNavigate={(to) => navigateFromEdit(to, coord)} + onArrowFromEditor={(key, shift) => moveActive(key, shift)} + onFillGripPointerDown={(e) => { + e.preventDefault(); + e.stopPropagation(); + setFillSource(coord); + setFillHoverLineRef(row.lineRef); + }} + /> + + + {rowActions(row)} + + : null} +
: null} + {canReorder ? : null} + {layout.columns.map((c) => ( + + {totalsRenderer?.(lines as LineItemsRowData[])[c.id] ?? null} + : null} + {layout.needsSpacer ? : null} +
+
+
+ ); +} + +LineItemsTable.displayName = "LineItems.Table"; diff --git a/packages/core/src/components/line-items/line-items.test.tsx b/packages/core/src/components/line-items/line-items.test.tsx new file mode 100644 index 000000000..16bc5807d --- /dev/null +++ b/packages/core/src/components/line-items/line-items.test.tsx @@ -0,0 +1,817 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +import { createLineItemHelper } from "./field"; +import { LineItems } from "./line-items"; +import type { + LineItemsChangeSet, + LineItemsField, + LineItemsMode, + LineItemsOrderingMode, +} from "./types"; +import { useLineItems } from "./use-line-items"; + +type Line = { + lineRef: string; + sku: string; + uom: string; + quantity: number; + note: string; + total: number; +}; + +const f = createLineItemHelper(); + +const UOM = [ + { value: "EA", label: "Each" }, + { value: "CS", label: "Case", description: "12 units" }, +]; + +const fields: LineItemsField[] = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 140, + hoverExpandWidth: 240, + pin: "left", + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "uom", + label: "UoM", + render: (l) => l.uom, + editable: ["edit"], + type: { kind: "select", options: UOM, placeholder: "Pick one" }, + width: 110, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 80, + sort: { comparator: (a, b) => (a.quantity ?? 0) - (b.quantity ?? 0) }, + }), + // Stays editable once the document is locked, which is what amend mode is for. + f.field({ + key: "note", + label: "Note", + render: (l) => l.note, + editable: ["edit", "amend"], + type: { kind: "text" }, + width: 120, + }), + // Computed: read-only, and never submitted. + f.field({ key: "total", label: "Total", render: (l) => (l.total ?? 0).toFixed(2), width: 100 }), +]; + +const seed: Line[] = [ + { lineRef: "1", sku: "APPLE-01", uom: "EA", quantity: 2, note: "", total: 10 }, + { lineRef: "2", sku: "BANANA-02", uom: "CS", quantity: 5, note: "", total: 25 }, + { lineRef: "3", sku: "CHERRY-03", uom: "EA", quantity: 1, note: "", total: 4 }, +]; + +type HarnessProps = { + mode?: LineItemsMode; + ordering?: LineItemsOrderingMode; + selection?: boolean; + virtualized?: boolean; + rowActions?: boolean; + totals?: boolean; + addRow?: boolean; + onSave?: (cs: LineItemsChangeSet) => void; + onReady?: (api: ReturnType>) => void; +}; + +function Harness({ + mode = "edit", + ordering = "sort", + selection = false, + virtualized = false, + rowActions = false, + totals = false, + addRow = false, + onSave, + onReady, +}: HarnessProps) { + const lineItems = useLineItems({ fields, data: seed, mode, ordering, selection }); + onReady?.(lineItems); + + return ( + + + + + ( + + ) + : undefined + } + /> + {totals ? ( + + {(lines: Line[]) => ({ + quantity: lines.reduce((s, l) => s + (l.quantity ?? 0), 0), + total: lines.reduce((s, l) => s + (l.total ?? 0), 0).toFixed(2), + })} + + ) : null} + {addRow ? ( + + + + ) : null} + + + + + {onSave ? : null} + + ); +} + +/** Cells are addressed by coordinate, since editable ones hold inputs not text. */ +function cell(lineRef: string, columnId: string): HTMLElement { + const el = document.querySelector( + `[data-line-ref="${lineRef}"][data-column-id="${columnId}"]`, + ); + if (!el) throw new Error(`cell ${lineRef}/${columnId} not found`); + return el; +} + +function editorIn(lineRef: string, columnId: string): HTMLInputElement { + const el = cell(lineRef, columnId).querySelector( + "[data-slot=line-items-cell-editor]", + ); + if (!el) throw new Error(`no editor in ${lineRef}/${columnId}`); + return el; +} + +const allEditors = () => document.querySelectorAll("[data-slot=line-items-cell-editor]"); + +/** Reads a ``'s width, which is where hover-expand shows up. */ +const colWidth = (index: number) => + (document.querySelector("colgroup")?.children[index] as HTMLElement | undefined)?.style.width; + +afterEach(() => { + cleanup(); +}); + +describe("LineItems", () => { + describe("snapshots", () => { + it("view mode", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("edit mode with selection and row actions", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("amend mode", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + + it("manual ordering renders drag handles", () => { + const { container } = render(); + expect(container.innerHTML).toMatchSnapshot(); + }); + }); + + it("renders a row per line and the column headers", () => { + render(); + expect(screen.getByRole("columnheader", { name: "SKU" })).toBeDefined(); + expect(editorIn("1", "sku").value).toBe("APPLE-01"); + expect(editorIn("3", "sku").value).toBe("CHERRY-03"); + expect(cell("1", "total").textContent).toBe("10.00"); + }); + + it("reports its row and column counts to assistive tech", () => { + render(); + const table = screen.getByRole("table"); + expect(table.getAttribute("aria-rowcount")).toBe("3"); + // 5 schema columns, no utility columns in this configuration. + expect(table.getAttribute("aria-colcount")).toBe("5"); + }); + + it("renders an empty state with no lines", () => { + function Empty() { + const lineItems = useLineItems({ fields, data: [] }); + return ( + + + + ); + } + render(); + expect(screen.getByText("Nothing here")).toBeDefined(); + }); + + it("renders skeleton rows while loading", () => { + function Loading() { + const lineItems = useLineItems({ fields, data: seed }); + return ( + + + + ); + } + render(); + expect(document.querySelectorAll("[data-slot=line-items-skeleton-row]")).toHaveLength(4); + expect(allEditors()).toHaveLength(0); + }); + + it("throws when a part is used outside Root", () => { + const spy = vi.spyOn(console, "error").mockImplementation(() => {}); + expect(() => render()).toThrow(/must be used within /); + spy.mockRestore(); + }); + + describe("editing", () => { + it("mounts an editor in every editable cell, so the grid reads as a spreadsheet at rest", () => { + render(); + // 4 editable columns (sku, uom, quantity, note) across 3 rows. + expect(allEditors()).toHaveLength(12); + expect(cell("1", "total").querySelector("[data-slot=line-items-cell-editor]")).toBeNull(); + }); + + it("commits an edit on blur", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "sku"); + await user.clear(editor); + await user.type(editor, "PEAR-99"); + await user.tab(); + + expect(editorIn("1", "sku").value).toBe("PEAR-99"); + }); + + it("does not commit per keystroke", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "sku"); + await user.clear(editor); + await user.type(editor, "PEAR"); + + expect(api?.isDirty).toBe(false); + }); + + it("commits on Enter", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "quantity"); + await user.clear(editor); + await user.type(editor, "42{Enter}"); + + expect(api?.allLines[0]?.quantity).toBe(42); + }); + + it("commits on Tab", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "quantity"); + await user.clear(editor); + await user.type(editor, "7{Tab}"); + + expect(api?.allLines[0]?.quantity).toBe(7); + }); + + it("abandons the edit on Escape", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "sku"); + await user.clear(editor); + await user.type(editor, "DISCARD-ME{Escape}"); + + expect(editorIn("1", "sku").value).toBe("APPLE-01"); + }); + + it("selects the whole value on focus so typing replaces rather than appends", async () => { + const user = userEvent.setup(); + render(); + + const editor = editorIn("1", "sku"); + await user.click(editor); + + expect(editor.selectionStart).toBe(0); + expect(editor.selectionEnd).toBe("APPLE-01".length); + }); + + it("renders a searchable combobox for select fields", () => { + render(); + const uom = cell("2", "uom"); + // The caret is the component's own trigger, next to a typeable input. + expect(uom.querySelector("[data-slot=line-items-cell-editor]")).not.toBeNull(); + expect(within(uom).getByRole("button")).toBeDefined(); + }); + + it("renders no editors at all in view mode", () => { + render(); + expect(allEditors()).toHaveLength(0); + expect(cell("1", "sku").textContent).toBe("APPLE-01"); + }); + }); + + describe("amend mode", () => { + it("only fields listing amend stay editable", () => { + render(); + expect(cell("1", "note").querySelector("[data-slot=line-items-cell-editor]")).not.toBeNull(); + expect(cell("1", "sku").querySelector("[data-slot=line-items-cell-editor]")).toBeNull(); + expect(cell("1", "quantity").querySelector("[data-slot=line-items-cell-editor]")).toBeNull(); + }); + + it("tints the cells the lock closed", () => { + render(); + expect(cell("1", "sku").className).toContain("bg-muted/40"); + expect(cell("1", "note").className).not.toContain("bg-muted/40"); + }); + + it("still commits edits to the fields left open", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("1", "note"); + await user.type(editor, "corrected{Enter}"); + + expect(api?.allLines[0]?.note).toBe("corrected"); + }); + }); + + describe("fill handle", () => { + it("appears on the active cell of a fillable column", async () => { + const user = userEvent.setup(); + render(); + + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(0); + await user.click(editorIn("1", "quantity")); + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(1); + }); + + it("fills the dragged range with the source value in one commit", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + // Seed a distinctive value, then drag its grip down over the rows below. + const source = editorIn("1", "quantity"); + await user.clear(source); + await user.type(source, "99{Enter}"); + await user.click(editorIn("1", "quantity")); + + const grip = document.querySelector("[data-slot=line-items-fill-grip]"); + expect(grip).not.toBeNull(); + + await user.pointer([ + { keys: "[MouseLeft>]", target: grip! }, + { target: cell("2", "quantity") }, + { target: cell("3", "quantity") }, + { keys: "[/MouseLeft]" }, + ]); + + expect(api?.allLines.map((l) => l.quantity)).toEqual([99, 99, 99]); + }); + + it("leaves rows outside the dragged range alone", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const source = editorIn("1", "quantity"); + await user.clear(source); + await user.type(source, "50{Enter}"); + await user.click(editorIn("1", "quantity")); + + const grip = document.querySelector("[data-slot=line-items-fill-grip]"); + await user.pointer([ + { keys: "[MouseLeft>]", target: grip! }, + { target: cell("2", "quantity") }, + { keys: "[/MouseLeft]" }, + ]); + + // Row 3 was never dragged over, so it keeps its own value. + expect(api?.allLines.map((l) => l.quantity)).toEqual([50, 50, 1]); + }); + + it("is withheld from read-only columns", async () => { + const user = userEvent.setup(); + render(); + await user.click(cell("1", "total")); + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(0); + }); + + it("is withheld in amend mode, which must not be bulk-overwritten", async () => { + const user = userEvent.setup(); + render(); + await user.click(editorIn("1", "note")); + expect(document.querySelectorAll("[data-slot=line-items-fill-grip]")).toHaveLength(0); + }); + }); + + describe("hover-expand", () => { + it("widens a column while the pointer is over it, then restores it", async () => { + const user = userEvent.setup(); + render(); + + const header = screen.getByRole("columnheader", { name: "SKU" }); + expect(colWidth(0)).toBe("140px"); + + await user.hover(header); + // The width is tweened frame by frame, so it must not snap to the target. + expect(colWidth(0)).not.toBe("240px"); + await waitFor(() => expect(colWidth(0)).toBe("240px")); + + await user.unhover(header); + await waitFor(() => expect(colWidth(0)).toBe("140px")); + }); + + it("leaves columns without hoverExpandWidth at their declared width", async () => { + const user = userEvent.setup(); + render(); + + await user.hover(screen.getByRole("columnheader", { name: "UoM" })); + expect(colWidth(1)).toBe("110px"); + }); + }); + + describe("selection", () => { + it("selects a row and surfaces the selection bar", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Select line 2" })); + expect(screen.getByText("1 selected")).toBeDefined(); + }); + + it("select-all toggles every row", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("checkbox", { name: "Select all lines" })); + expect(screen.getByText("3 selected")).toBeDefined(); + }); + + it("hides the selection column in view mode", () => { + render(); + expect(screen.queryByRole("checkbox", { name: "Select all lines" })).toBeNull(); + }); + }); + + describe("drag reordering", () => { + it("renders a labelled grip per row under manual ordering", () => { + render(); + expect(screen.getByRole("button", { name: "Drag to reorder line 1" })).toBeDefined(); + expect(screen.getByRole("button", { name: "Drag to reorder line 3" })).toBeDefined(); + }); + + it("renders no grips under sort ordering", () => { + render(); + expect(screen.queryByRole("button", { name: /drag to reorder/i })).toBeNull(); + }); + + it("renders no grips in view mode", () => { + render(); + expect(screen.queryByRole("button", { name: /drag to reorder/i })).toBeNull(); + }); + + it("draws the drop indicator across every column, pinned ones included", () => { + render(); + + // Simulate the row being dragged over by setting the state the drop + // handler sets, then assert every cell in that row carries the line. + const row = document.querySelectorAll("[data-slot=line-items-row]")[1] as HTMLElement; + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + + fireEvent.dragStart(grip); + fireEvent.dragOver(row); + + const cells = Array.from(row.querySelectorAll("td")); + expect(cells.length).toBeGreaterThan(3); + for (const td of cells) { + expect(td.className).toContain("shadow-[inset_0_2px_0_0_var(--color-primary)]"); + } + }); + + it("repaints the row background on pinned cells so a selected row tints under them", async () => { + const user = userEvent.setup(); + render(); + + const skuCell = cell("1", "sku"); // pinned left + expect(skuCell.className).toContain("bg-card"); + + await user.click(screen.getByRole("checkbox", { name: "Select line 1" })); + expect(cell("1", "sku").className).toContain("bg-muted"); + }); + + it("moves a line down with the keyboard and reports a reorder op", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + grip.focus(); + await user.keyboard("{Enter}{ArrowDown}"); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["2", "1", "3"]); + expect(api?.getChangeSet().lineChanges.filter((c) => c.action === "reorder")).toEqual([ + { action: "reorder", lineId: "2", position: 0 }, + { action: "reorder", lineId: "1", position: 1 }, + ]); + }); + + it("moving a line back to its slot leaves the document clean", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + grip.focus(); + await user.keyboard("{Enter}{ArrowDown}{ArrowUp}"); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + expect(api?.getChangeSet().isEmpty).toBe(true); + }); + + it("ignores arrow keys until the row is lifted", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + screen.getByRole("button", { name: "Drag to reorder line 1" }).focus(); + await user.keyboard("{ArrowDown}"); + + expect(api?.allLines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + }); + + it("marks the lifted row with aria-pressed", async () => { + const user = userEvent.setup(); + render(); + + const grip = screen.getByRole("button", { name: "Drag to reorder line 1" }); + expect(grip.getAttribute("aria-pressed")).toBe("false"); + + grip.focus(); + await user.keyboard("{Enter}"); + expect(grip.getAttribute("aria-pressed")).toBe("true"); + + await user.keyboard("{Escape}"); + expect(grip.getAttribute("aria-pressed")).toBe("false"); + }); + }); + + describe("sorting", () => { + it("exposes a sortable header as a button with aria-sort", () => { + render(); + const header = screen.getByRole("columnheader", { name: /qty/i }); + expect(header.getAttribute("aria-sort")).toBe("none"); + expect(within(header).getByRole("button")).toBeDefined(); + }); + + it("leaves a column without a comparator unsortable", () => { + render(); + const header = screen.getByRole("columnheader", { name: "Total" }); + expect(header.getAttribute("aria-sort")).toBeNull(); + }); + + it("cycles the rows through ascending, descending and document order", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + const sortButton = within(screen.getByRole("columnheader", { name: /qty/i })).getByRole( + "button", + ); + + await user.click(sortButton); + expect(api?.lines.map((l) => l.quantity)).toEqual([1, 2, 5]); + + await user.click(sortButton); + expect(api?.lines.map((l) => l.quantity)).toEqual([5, 2, 1]); + + await user.click(sortButton); + expect(api?.lines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + }); + + it("renders no sort affordance under manual ordering", () => { + render(); + const header = screen.getByRole("columnheader", { name: /qty/i }); + expect(header.getAttribute("aria-sort")).toBeNull(); + }); + }); + + describe("parts", () => { + it("filters rows from the search box", async () => { + const user = userEvent.setup(); + render(); + + await user.type(screen.getByRole("searchbox", { name: /search lines/i }), "banana"); + + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(1); + expect(editorIn("2", "sku").value).toBe("BANANA-02"); + }); + + it("SearchToggle expands into an input", async () => { + const user = userEvent.setup(); + render(); + + // Collapsed, the toggle *is* the button; expanded, it wraps an input. + const toggle = document.querySelector("[data-slot=line-items-search-toggle]"); + expect(toggle?.tagName.toLowerCase()).toBe("button"); + await user.click(toggle!); + + const expanded = document.querySelector("[data-slot=line-items-search-toggle]"); + expect(expanded?.querySelector("input")).not.toBeNull(); + }); + + it("FullscreenToggle reports its pressed state", async () => { + const user = userEvent.setup(); + render(); + + const toggle = screen.getByRole("button", { name: "Expand to fullscreen" }); + expect(toggle.getAttribute("aria-pressed")).toBe("false"); + + await user.click(toggle); + expect(screen.getByRole("button", { name: "Exit fullscreen" })).toBeDefined(); + }); + + it("AddRow hosts consumer controls below the table", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add product" })); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(4); + }); + + it("hides AddRow in view mode", () => { + render(); + expect(screen.queryByRole("button", { name: "Add product" })).toBeNull(); + }); + + it("surfaces the dirty bar once something changes", async () => { + const user = userEvent.setup(); + render(); + + expect(screen.queryByText(/unsaved/)).toBeNull(); + await user.click(screen.getByRole("button", { name: "Add product" })); + expect(screen.getByText("1 unsaved change")).toBeDefined(); + }); + + it("discards changes from the dirty bar", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Add product" })); + await user.click(screen.getByRole("button", { name: "Discard" })); + + expect(screen.queryByText(/unsaved/)).toBeNull(); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(3); + }); + + it("hands the change-set to onSave", async () => { + const user = userEvent.setup(); + const onSave = vi.fn(); + render(); + + await user.click(screen.getByRole("button", { name: "Add product" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + expect(onSave).toHaveBeenCalledTimes(1); + const cs = onSave.mock.calls[0]![0] as LineItemsChangeSet; + expect(cs.lineChanges[0]?.action).toBe("add"); + }); + + it("TotalsRow renders column-aligned cells in a tfoot", () => { + render(); + const totals = document.querySelector("[data-slot=line-items-totals]"); + expect(totals?.tagName.toLowerCase()).toBe("tfoot"); + expect(within(totals as HTMLElement).getByText("8")).toBeDefined(); + expect(within(totals as HTMLElement).getByText("39.00")).toBeDefined(); + }); + + it("runs a row action", async () => { + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole("button", { name: "Remove BANANA-02" })); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(2); + }); + }); + + describe("virtualization", () => { + it("mounts only a slice of a long document", () => { + const many: Line[] = Array.from({ length: 500 }, (_, i) => ({ + lineRef: `L${i}`, + sku: `SKU-${i}`, + uom: "EA", + quantity: i, + note: "", + total: i, + })); + + function Big() { + const lineItems = useLineItems({ fields, data: many }); + return ( + + + + ); + } + render(); + + expect(document.querySelectorAll("[data-slot=line-items-row]").length).toBeLessThan( + many.length, + ); + }); + + it("mounts every row when virtualization is off", () => { + render(); + expect(document.querySelectorAll("[data-slot=line-items-row]")).toHaveLength(3); + }); + }); +}); diff --git a/packages/core/src/components/line-items/line-items.tsx b/packages/core/src/components/line-items/line-items.tsx new file mode 100644 index 000000000..052b3a719 --- /dev/null +++ b/packages/core/src/components/line-items/line-items.tsx @@ -0,0 +1,186 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { + LineItemsContextProvider, + type LineItemsContextValue, + type LineItemsTotalsRenderer, +} from "./line-items-context"; +import { + LineItemsAddRow, + LineItemsBulkActions, + LineItemsDirtyBar, + LineItemsFloatingDock, + LineItemsFullscreenToggle, + LineItemsSaveActions, + LineItemsSearch, + LineItemsSearchToggle, + LineItemsSelectionBar, + LineItemsTotalsRow, +} from "./line-items-parts"; +import { LineItemsTable } from "./line-items-table"; +import type { LineItemsRowData, UseLineItemsReturn } from "./types"; + +export type LineItemsRootProps = { + /** The `useLineItems()` return value this subtree renders. */ + value: UseLineItemsReturn; + children?: React.ReactNode; + /** Controlled fullscreen state. Omit to let Root own it. */ + fullscreen?: boolean; + onFullscreenChange?: (next: boolean) => void; + className?: string; +}; + +/** + * Fades the backdrop in and lifts the card, so entering fullscreen reads as the + * panel rising rather than the page snapping. Closing is deliberately instant. + */ +const FULLSCREEN_KEYFRAMES = ` +@keyframes line-items-fs-backdrop{from{opacity:0}to{opacity:1}} +@keyframes line-items-fs-card{from{opacity:0;transform:translateY(24px)}to{opacity:1;transform:none}} +`; + +/** + * Provides the line-items document to the parts beneath it. Root holds no row + * data of its own — `useLineItems()` owns all of it. + * + * @example + * const lineItems = useLineItems({ fields, data: lines }); + * + * + * + * + * + * + * + * } /> + * + * } /> + * {(lines) => ({ total: sum(lines) })} + * + * lineItems.addLine(item)} /> + * + * + * + * + * + * + * + * + */ +function LineItemsRoot({ + value, + children, + fullscreen: fullscreenProp, + onFullscreenChange, + className, +}: LineItemsRootProps) { + const [uncontrolledFullscreen, setUncontrolledFullscreen] = React.useState(false); + const fullscreen = fullscreenProp ?? uncontrolledFullscreen; + + const setFullscreen = React.useCallback( + (next: boolean) => { + if (fullscreenProp === undefined) setUncontrolledFullscreen(next); + onFullscreenChange?.(next); + }, + [fullscreenProp, onFullscreenChange], + ); + + const [totalsRenderer, setTotalsRenderer] = React.useState( + undefined, + ); + + // Stored as a thunk: a plain setState would call the renderer as an updater. + const registerTotals = React.useCallback((renderer: LineItemsTotalsRenderer | undefined) => { + setTotalsRenderer(() => renderer); + }, []); + + const ctx = React.useMemo>( + () => ({ + lineItems: value, + fullscreen, + setFullscreen, + totalsRenderer, + setTotalsRenderer: registerTotals, + }), + [fullscreen, registerTotals, setFullscreen, totalsRenderer, value], + ); + + React.useEffect(() => { + if (!fullscreen) return; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + e.stopPropagation(); + setFullscreen(false); + }; + globalThis.addEventListener("keydown", onKeyDown); + return () => globalThis.removeEventListener("keydown", onKeyDown); + }, [fullscreen, setFullscreen]); + + const body = ( +
[data-slot=card]]:flex astw:[&>[data-slot=card]]:h-full astw:[&>[data-slot=card]]:min-h-0 astw:[&>[data-slot=card]]:flex-col astw:[&>[data-slot=card]]:overflow-hidden", + "astw:[&_[data-slot=card-content]]:flex astw:[&_[data-slot=card-content]]:min-h-0 astw:[&_[data-slot=card-content]]:flex-1 astw:[&_[data-slot=card-content]]:flex-col", + ), + className, + )} + > + {children} +
+ ); + + return ( + + {fullscreen ? ( + <> + +
{ + if (e.target === e.currentTarget) setFullscreen(false); + }} + className="astw:fixed astw:inset-0 astw:z-50 astw:flex astw:flex-col astw:bg-black/80 astw:p-6 astw:backdrop-blur-sm" + style={{ animation: "line-items-fs-backdrop 220ms ease-out" }} + > +
+ {body} +
+
+ + ) : ( + body + )} +
+ ); +} +LineItemsRoot.displayName = "LineItems.Root"; + +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/spreadsheet-logic.test.ts b/packages/core/src/components/line-items/spreadsheet-logic.test.ts new file mode 100644 index 000000000..290eed902 --- /dev/null +++ b/packages/core/src/components/line-items/spreadsheet-logic.test.ts @@ -0,0 +1,190 @@ +import { describe, expect, it } from "vitest"; + +import { + coordsToRowsMatrix, + moveSelectionCoord, + parseClipboardTsv, + rectangularCells, + sameCoord, + serializeMatrixTsv, +} from "./spreadsheet-logic"; + +const refs = ["a", "b", "c"]; +const cols = ["sku", "qty", "price"]; + +describe("moveSelectionCoord", () => { + it("moves within bounds", () => { + expect(moveSelectionCoord("ArrowDown", { lineRef: "a", columnId: "qty" }, refs, cols)).toEqual({ + lineRef: "b", + columnId: "qty", + }); + expect(moveSelectionCoord("ArrowRight", { lineRef: "a", columnId: "sku" }, refs, cols)).toEqual( + { + lineRef: "a", + columnId: "qty", + }, + ); + }); + + it("clamps at the grid edges rather than wrapping", () => { + expect(moveSelectionCoord("ArrowUp", { lineRef: "a", columnId: "sku" }, refs, cols)).toEqual({ + lineRef: "a", + columnId: "sku", + }); + expect(moveSelectionCoord("ArrowDown", { lineRef: "c", columnId: "sku" }, refs, cols)).toEqual({ + lineRef: "c", + columnId: "sku", + }); + expect( + moveSelectionCoord("ArrowRight", { lineRef: "a", columnId: "price" }, refs, cols), + ).toEqual({ lineRef: "a", columnId: "price" }); + }); + + it("returns null for a coordinate outside the grid", () => { + expect( + moveSelectionCoord("ArrowDown", { lineRef: "zz", columnId: "qty" }, refs, cols), + ).toBeNull(); + expect( + moveSelectionCoord("ArrowDown", { lineRef: "a", columnId: "zz" }, refs, cols), + ).toBeNull(); + }); +}); + +describe("rectangularCells", () => { + it("fills the rectangle regardless of anchor direction", () => { + const forward = rectangularCells( + refs, + cols, + { lineRef: "a", columnId: "sku" }, + { + lineRef: "b", + columnId: "qty", + }, + ); + const reversed = rectangularCells( + refs, + cols, + { lineRef: "b", columnId: "qty" }, + { + lineRef: "a", + columnId: "sku", + }, + ); + + expect(forward).toHaveLength(4); + expect(forward).toEqual(reversed); + expect(forward).toEqual([ + { lineRef: "a", columnId: "sku" }, + { lineRef: "a", columnId: "qty" }, + { lineRef: "b", columnId: "sku" }, + { lineRef: "b", columnId: "qty" }, + ]); + }); + + it("returns a single cell when both anchors match", () => { + const cells = rectangularCells( + refs, + cols, + { lineRef: "b", columnId: "qty" }, + { + lineRef: "b", + columnId: "qty", + }, + ); + expect(cells).toEqual([{ lineRef: "b", columnId: "qty" }]); + }); + + it("returns nothing for an unknown coordinate", () => { + expect( + rectangularCells( + refs, + cols, + { lineRef: "zz", columnId: "qty" }, + { lineRef: "b", columnId: "qty" }, + ), + ).toEqual([]); + }); +}); + +const display = (lineRef: string, columnId: string) => `${lineRef}/${columnId}`; + +describe("coordsToRowsMatrix", () => { + it("reads out the bounding box in document and schema order", () => { + const matrix = coordsToRowsMatrix( + refs, + cols, + [ + { lineRef: "b", columnId: "price" }, + { lineRef: "a", columnId: "qty" }, + ], + display, + ); + + expect(matrix).toEqual([ + ["a/qty", "a/price"], + ["b/qty", "b/price"], + ]); + }); + + it("returns nothing for empty input", () => { + expect(coordsToRowsMatrix(refs, cols, [], display)).toEqual([]); + expect(coordsToRowsMatrix([], cols, [{ lineRef: "a", columnId: "qty" }], display)).toEqual([]); + }); +}); + +describe("TSV round-trip", () => { + it("serializes a matrix to tab and newline delimited text", () => { + expect( + serializeMatrixTsv([ + ["1", "2"], + ["3", "4"], + ]), + ).toBe("1\t2\n3\t4"); + }); + + it("parses back to the same matrix", () => { + expect(parseClipboardTsv("1\t2\n3\t4")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("normalizes CRLF and CR line endings", () => { + expect(parseClipboardTsv("1\t2\r\n3\t4")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + expect(parseClipboardTsv("1\t2\r3\t4")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("drops the trailing blank line spreadsheets append on copy", () => { + expect(parseClipboardTsv("1\t2\n3\t4\n")).toEqual([ + ["1", "2"], + ["3", "4"], + ]); + }); + + it("returns nothing for blank text", () => { + expect(parseClipboardTsv("")).toEqual([]); + expect(parseClipboardTsv(" \n ")).toEqual([]); + }); + + it("preserves empty cells inside a row", () => { + expect(parseClipboardTsv("1\t\t3")).toEqual([["1", "", "3"]]); + }); +}); + +describe("sameCoord", () => { + it("compares by value and treats null as unequal", () => { + expect(sameCoord({ lineRef: "a", columnId: "qty" }, { lineRef: "a", columnId: "qty" })).toBe( + true, + ); + expect(sameCoord({ lineRef: "a", columnId: "qty" }, { lineRef: "b", columnId: "qty" })).toBe( + false, + ); + expect(sameCoord(null, null)).toBe(false); + }); +}); diff --git a/packages/core/src/components/line-items/spreadsheet-logic.ts b/packages/core/src/components/line-items/spreadsheet-logic.ts new file mode 100644 index 000000000..f93b1f375 --- /dev/null +++ b/packages/core/src/components/line-items/spreadsheet-logic.ts @@ -0,0 +1,120 @@ +/** Logical cell coordinate within the editable grid area. */ +export type GridCoord = { lineRef: string; columnId: string }; + +export type GridDirection = "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight"; + +/** Moves one cell in `dir`, clamped to the grid bounds. */ +export function moveSelectionCoord( + dir: GridDirection, + active: GridCoord, + orderedLineRefs: readonly string[], + columnIds: readonly string[], +): GridCoord | null { + const ri = orderedLineRefs.indexOf(active.lineRef); + const ci = columnIds.indexOf(active.columnId); + if (ri < 0 || ci < 0) return null; + + let nr = ri; + let nc = ci; + if (dir === "ArrowUp") nr = Math.max(0, ri - 1); + if (dir === "ArrowDown") nr = Math.min(orderedLineRefs.length - 1, ri + 1); + if (dir === "ArrowLeft") nc = Math.max(0, ci - 1); + if (dir === "ArrowRight") nc = Math.min(columnIds.length - 1, ci + 1); + + const lineRef = orderedLineRefs[nr]; + const columnId = columnIds[nc]; + if (!lineRef || !columnId) return null; + return { lineRef, columnId }; +} + +/** Every cell in the rectangle bounded by `a` and `b` (drag-select, shift-click). */ +export function rectangularCells( + orderedLineRefs: readonly string[], + columnIds: readonly string[], + a: GridCoord, + b: GridCoord, +): GridCoord[] { + const ri1 = orderedLineRefs.indexOf(a.lineRef); + const ri2 = orderedLineRefs.indexOf(b.lineRef); + const ci1 = columnIds.indexOf(a.columnId); + const ci2 = columnIds.indexOf(b.columnId); + if (ri1 < 0 || ri2 < 0 || ci1 < 0 || ci2 < 0) return []; + + const r0 = Math.min(ri1, ri2); + const r1 = Math.max(ri1, ri2); + const c0 = Math.min(ci1, ci2); + const c1 = Math.max(ci1, ci2); + + const out: GridCoord[] = []; + for (let r = r0; r <= r1; r++) { + const lineRef = orderedLineRefs[r]; + if (!lineRef) continue; + for (let c = c0; c <= c1; c++) { + const columnId = columnIds[c]; + if (columnId) out.push({ lineRef, columnId }); + } + } + return out; +} + +/** + * Projects arbitrary coordinates onto their bounding box and reads it out in + * document and schema order, which is what a TSV copy of a selection needs. + */ +export function coordsToRowsMatrix( + orderedLineRefs: readonly string[], + columnIdsOrdered: readonly string[], + coords: readonly GridCoord[], + getDisplay: (lineRef: string, columnId: string) => string, +): string[][] { + if (!coords.length || !orderedLineRefs.length || !columnIdsOrdered.length) return []; + + let r0 = Number.POSITIVE_INFINITY; + let r1 = Number.NEGATIVE_INFINITY; + let c0 = Number.POSITIVE_INFINITY; + let c1 = Number.NEGATIVE_INFINITY; + let found = false; + + for (const { lineRef, columnId } of coords) { + const ri = orderedLineRefs.indexOf(lineRef); + const ci = columnIdsOrdered.indexOf(columnId); + if (ri < 0 || ci < 0) continue; + found = true; + r0 = Math.min(r0, ri); + r1 = Math.max(r1, ri); + c0 = Math.min(c0, ci); + c1 = Math.max(c1, ci); + } + if (!found) return []; + + const rowRefs: string[] = []; + for (let r = r0; r <= r1; r++) { + const ref = orderedLineRefs[r]; + if (ref) rowRefs.push(ref); + } + + const columnSpan: string[] = []; + for (let c = c0; c <= c1; c++) { + const id = columnIdsOrdered[c]; + if (id) columnSpan.push(id); + } + + return rowRefs.map((ref) => columnSpan.map((id) => getDisplay(ref, id))); +} + +export function serializeMatrixTsv(rows: readonly string[][]): string { + return rows.map((r) => r.join("\t")).join("\n"); +} + +export function parseClipboardTsv(text: string): string[][] { + const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); + if (!normalized.trim()) return []; + const lines = normalized.split("\n"); + while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + return lines.map((line) => line.split("\t")); +} + +export function sameCoord(a: GridCoord | null, b: GridCoord | null): boolean { + if (!a || !b) return false; + return a.lineRef === b.lineRef && a.columnId === b.columnId; +} diff --git a/packages/core/src/components/line-items/types.ts b/packages/core/src/components/line-items/types.ts new file mode 100644 index 000000000..9d9a4a4f6 --- /dev/null +++ b/packages/core/src/components/line-items/types.ts @@ -0,0 +1,267 @@ +import type { ReactNode } from "react"; + +/** + * One typed line-change op produced by `getChangeSet()`. The shape is + * transport-agnostic — apps map it onto GraphQL mutations or REST calls. + * + * - `add` → `{ tempId, data }`; `tempId` is client-only, the server mints the real id. + * - `update` → `{ lineId, patch }`; only changed fields appear in `patch`. + * - `remove` → `{ lineId }`. + * - `reorder` → `{ lineId, position }`; zero-based final index (manual ordering only). + */ +export type LineItemsLineChange = + | { action: "add"; tempId: string; data: LineItemsRowPatch } + | { action: "update"; lineId: string; patch: LineItemsRowPatch } + | { action: "remove"; lineId: string } + | { action: "reorder"; lineId: string; position: number }; + +export type LineItemsRowPatch = Record; + +/** Result of `useLineItems().getChangeSet()`. `isEmpty` short-circuits no-op submits. */ +export type LineItemsChangeSet = { + isEmpty: boolean; + lineChanges: LineItemsLineChange[]; +}; + +/** + * `"view"` renders every cell read-only. `"edit"` and `"amend"` each render an + * editor for fields whose `editable` list includes that mode, so `"amend"` is a + * partial lock: a document past confirmation where only a few fields (a memo, a + * received quantity) may still be corrected. Cells locked in `"amend"` are + * tinted so it is obvious what is still touchable. + */ +export type LineItemsMode = "view" | "edit" | "amend"; + +/** + * `"sort"` (default) lets column headers drive row order and emits no reorder + * ops. `"manual"` preserves document order, enables drag-reordering, and emits + * `reorder` ops in the change-set. + */ +export type LineItemsOrderingMode = "sort" | "manual"; + +export type LineItemsColumnAlign = "left" | "center" | "right"; + +/** + * Where a field's edits go. + * + * - `"document"` (default) bundles them into `getChangeSet()`. + * - `"metadata"` keeps them out of the change-set and emits them through + * `onMetadataCommit` instead, for values that save out-of-band — a journal + * memo that stays editable while the rest of the document is locked. + */ +export type LineItemsFieldCommit = "document" | "metadata"; + +/** One out-of-band cell edit, emitted for `commit: "metadata"` fields. */ +export type LineItemsMetadataCommit = { + lineRef: string; + patch: LineItemsRowPatch; + previous: LineItemsRowPatch; + row: T; +}; + +export type LineItemsSortDirection = "asc" | "desc"; + +/** Which column the grid is sorted by, under `ordering: "sort"`. */ +export type LineItemsSortState = { + columnId: string; + direction: LineItemsSortDirection; +}; + +/** Every row exposes a stable `lineRef` — the server id when persisted, else a generated temp id. */ +export type LineItemsRowData = Record & { lineRef: string }; + +/** One option for a `kind: "select"` field (SKU pickers, unit of measure, etc.). */ +export type LineItemsSelectOption = { + value: string; + label: string; + description?: string; +}; + +/** + * Context handed to a `kind: "custom"` field's `renderEditor`. Mirrors the + * contract the built-in editors follow so keyboard nav, commit, and + * dirty-tracking keep working around a custom editor. + */ +export type LineItemsCustomEditorContext = { + /** Current cell value, already passed through the field's `normalize` when set. */ + value: unknown; + /** Commit a new value into the line. Call on blur or Enter, not per keystroke. */ + onCommit: (next: unknown) => void; + /** Abandon the in-flight edit (Escape). The previous value is kept. */ + onCancel: () => void; + row: T; + mode: LineItemsMode; + field: LineItemsField; +}; + +/** + * Drives the editor input, formatting, normalization, and equality for an + * editable field. Omit for read-only or computed columns. + */ +export type LineItemsFieldType = + | { kind: "text" } + | { kind: "number"; decimals?: number } + | { + kind: "select"; + options: ReadonlyArray; + placeholder?: string; + } + | { kind: "boolean"; trueLabel?: string; falseLabel?: string } + | { kind: "date"; min?: string; max?: string } + | { + kind: "custom"; + /** Renders the in-cell editor only — the grid owns the selection ring and fill handle. */ + renderEditor: (ctx: LineItemsCustomEditorContext) => ReactNode; + /** Value coercion before equality. Mirrors `LineItemsField.normalize`. */ + normalize?: (value: unknown, row: T) => unknown; + /** Dirty-equality. Mirrors `LineItemsField.equals`. */ + equals?: (a: unknown, b: unknown, row: T) => boolean; + }; + +/** + * One column in the line-items table. + * + * `key` doubles as the column id and, for editable fields, the property name on + * `T`. Computed columns (a derived "total", say) pick any unique string for + * `key` and omit `editable` and `type` — they are excluded from the change-set. + * + * Build these with `createLineItemHelper().field({ … })` to get full + * inference of `key`, `render`, and `sort.comparator`. + */ +export type LineItemsField = { + /** Stable column id. For editable fields, use a real key on `T`. */ + key: (keyof T & string) | (string & {}); + label: ReactNode; + /** Cell content in view mode, and in edit mode whenever no editor is shown. */ + render: (line: T) => ReactNode; + /** + * Modes in which an editor replaces `render`. Omitted or empty means + * read-only. Typical editable line columns pass `["edit"]`. + */ + editable?: LineItemsMode[]; + /** Required when the field is editable; drives the editor input and equality semantics. */ + type?: LineItemsFieldType; + /** + * `"document"` (default) bundles edits into the change-set; `"metadata"` + * routes them to `onMetadataCommit` and omits them from it. + */ + commit?: LineItemsFieldCommit; + /** + * Makes the column header sortable, cycling ascending, descending, then + * unsorted. Ignored when `ordering` is `"manual"`, which preserves document + * order. + */ + sort?: { comparator: (a: T, b: T) => number }; + /** Returns `true` when `line` matches the active search query. */ + search?: (line: T, query: string) => boolean; + /** + * Horizontal alignment. When omitted, `number` fields default to `"right"` + * and `boolean` fields to `"center"`; everything else to `"left"`. + */ + align?: LineItemsColumnAlign; + className?: string | ((line: T) => string | undefined); + /** + * Coerce raw values before the dirty-equality compare and before they are + * emitted into the change-set. Defaults to trimming strings; `number` fields + * with `decimals` also get rounding. Override for app rules such as money + * rounding per currency. + */ + normalize?: (value: unknown, row: T) => unknown; + /** + * Dirty-equality. Defaults to `Object.is`, with a 1e-9 epsilon for `number` + * fields so cosmetic input like `"1"` vs `"1.00"` is not a document change. + */ + equals?: (a: unknown, b: unknown, row: T) => boolean; + /** Resting column width in pixels. Auto-sized when omitted. */ + width?: number; + /** + * Widens the column to this pixel width while the pointer is anywhere in it, + * with a short transition. For dense columns whose content is normally + * truncated. A `flex` column is frozen at its current width during the + * expansion so the table grows instead of the flex column collapsing. + */ + hoverExpandWidth?: number; + /** + * Freeze the column to the left or right edge so it stays visible during + * horizontal scroll. Stacked pinned columns have their sticky offsets + * measured from the rendered layout, so `width` is not required — though + * setting it keeps pinned columns from resizing as row content changes. + */ + pin?: "left" | "right"; + /** + * When `true`, this column absorbs leftover horizontal space. Set it on the + * column with the longest content (a description, typically). Without it, a + * trailing spacer column absorbs the leftover instead so declared widths + * stay pixel-exact. + */ + flex?: boolean; +}; + +export type UseLineItemsOptions = { + /** Column schema. Build with `createLineItemHelper().field({ … })`. */ + fields: LineItemsField[]; + /** Uncontrolled seed rows, also used as the dirty-tracking baseline. */ + data?: readonly T[]; + /** Controlled rows. When set, `data` is ignored. */ + lines?: readonly T[]; + onLinesChange?: (next: T[]) => void; + /** Defaults to `"edit"`. */ + mode?: LineItemsMode; + /** Defaults to `"sort"`. Pass `"manual"` to enable drag-reordering. */ + ordering?: LineItemsOrderingMode; + /** Enables the row selection checkbox column. */ + selection?: boolean; + /** Receives edits to `commit: "metadata"` fields, which bypass the change-set. */ + onMetadataCommit?: (event: LineItemsMetadataCommit) => void; +}; + +export type UseLineItemsReturn = { + /** Document order with the active search filter applied. */ + lines: T[]; + /** Full document order, ignoring the search filter. */ + allLines: T[]; + fields: LineItemsField[]; + mode: LineItemsMode; + ordering: LineItemsOrderingMode; + /** `true` when current state diverges from the baseline. */ + isDirty: boolean; + filter: string; + selectionEnabled: boolean; + selectedIds: string[]; + /** Active sort, or `null` when in document order. Always `null` under manual ordering. */ + sortState: LineItemsSortState | null; + + setMode: (mode: LineItemsMode) => void; + setFilter: (query: string) => void; + /** Cycles the column through ascending, descending, then unsorted. */ + toggleSort: (columnId: string) => void; + /** Inserts one line and returns its new `lineRef`. */ + addLine: (data: Partial>, opts?: { afterLineRef?: string | null }) => string; + /** + * Inserts several lines in a single render, returning the new `lineRef`s in + * input order. Prefer this over looping `addLine` — a bulk picker adding N + * rows should produce one render and N `add` ops, not N renders. + */ + addLines: ( + items: ReadonlyArray>>, + opts?: { afterLineRef?: string | null }, + ) => string[]; + removeLine: (lineRef: string) => void; + updateField: (lineRef: string, key: K, value: T[K]) => void; + /** Batched updates — one render for a whole paste or fill-drag. */ + updateLines: (patches: { lineRef: string; patch: Partial }[]) => void; + /** Moves a line after `afterLineRef`, or to the head when `null`. Manual ordering only. */ + reorderLine: (lineRef: string, afterLineRef: string | null) => void; + toggleSelect: (lineRef: string) => void; + /** Selects every row currently passing the search filter. */ + selectAllVisible: () => void; + clearSelection: () => void; + bulkUpdate: (patch: Partial) => void; + bulkRemove: () => void; + duplicateLastLine: (derive?: (prev: T, newRef: string) => T) => string | undefined; + /** Snaps the baseline forward to current state. Call after a successful save. */ + reset: () => void; + /** Discards every uncommitted edit, insert, and removal back to the baseline. */ + revert: () => void; + getChangeSet: () => LineItemsChangeSet; +}; diff --git a/packages/core/src/components/line-items/use-grid-selection.ts b/packages/core/src/components/line-items/use-grid-selection.ts new file mode 100644 index 000000000..7c55f68e7 --- /dev/null +++ b/packages/core/src/components/line-items/use-grid-selection.ts @@ -0,0 +1,157 @@ +import * as React from "react"; + +import { + moveSelectionCoord, + rectangularCells, + sameCoord, + type GridCoord, + type GridDirection, +} from "./spreadsheet-logic"; + +/** Flattens a coordinate into a Set key so per-cell lookups stay O(1). */ +export function coordKey(lineRef: string, columnId: string): string { + return `${lineRef}::${columnId}`; +} + +export type GridSelection = { + /** The focused cell — the only one that mounts an editor. */ + active: GridCoord | null; + /** Keys of every cell in the selected rectangle. */ + selected: ReadonlySet; + /** True while a pointer range-drag is in progress. */ + dragging: boolean; +}; + +export type UseGridSelectionResult = GridSelection & { + setActive: (coord: GridCoord | null) => void; + /** Pointer-down on a cell: starts a new range, or extends it when `shift` is held. */ + beginSelection: (coord: GridCoord, opts?: { shift?: boolean }) => void; + /** Pointer-enter during a drag: extends the rectangle to `coord`. */ + extendSelection: (coord: GridCoord) => void; + endSelection: () => void; + /** Arrow-key navigation. Returns the coord moved to, or `null` at the edge. */ + moveActive: (dir: GridDirection, opts?: { shift?: boolean }) => GridCoord | null; + clearSelection: () => void; + isSelected: (lineRef: string, columnId: string) => boolean; +}; + +/** + * Owns the editable grid's cell selection. + * + * The selection anchor is only ever written by pointer-down and keyboard + * navigation. A stray native `focus` event must never move it, or a shift-click + * would collapse the very range it was meant to extend. + * + * `orderedLineRefs` and `columnIds` are expected to be memoized by the caller — + * a new array identity every render would rebuild every handler here and + * remount the active cell's editor mid-keystroke. + */ +export function useGridSelection( + orderedLineRefs: readonly string[], + columnIds: readonly string[], +): UseGridSelectionResult { + const [active, setActiveState] = React.useState(null); + const [selected, setSelected] = React.useState>(() => new Set()); + const [dragging, setDragging] = React.useState(false); + + const anchor = React.useRef(null); + + const paintRange = React.useCallback( + (from: GridCoord, to: GridCoord) => { + const cells = rectangularCells(orderedLineRefs, columnIds, from, to); + const next = new Set(); + for (const c of cells) next.add(coordKey(c.lineRef, c.columnId)); + setSelected(next); + }, + [columnIds, orderedLineRefs], + ); + + const setActive = React.useCallback((coord: GridCoord | null) => { + setActiveState(coord); + anchor.current = coord; + setSelected(coord ? new Set([coordKey(coord.lineRef, coord.columnId)]) : new Set()); + }, []); + + const beginSelection = React.useCallback( + (coord: GridCoord, opts?: { shift?: boolean }) => { + if (opts?.shift && anchor.current) { + // Shift-clicking across cells would otherwise leave the browser's own + // text selection painted over the range. Drop it before painting ours. + globalThis.getSelection?.()?.removeAllRanges(); + setActiveState(coord); + paintRange(anchor.current, coord); + return; + } + setActiveState(coord); + anchor.current = coord; + setSelected(new Set([coordKey(coord.lineRef, coord.columnId)])); + setDragging(true); + }, + [paintRange], + ); + + const extendSelection = React.useCallback( + (coord: GridCoord) => { + if (!dragging || !anchor.current) return; + if (sameCoord(anchor.current, coord)) return; + paintRange(anchor.current, coord); + }, + [dragging, paintRange], + ); + + const endSelection = React.useCallback(() => { + setDragging(false); + }, []); + + const moveActive = React.useCallback( + (dir: GridDirection, opts?: { shift?: boolean }): GridCoord | null => { + if (!active) return null; + const next = moveSelectionCoord(dir, active, orderedLineRefs, columnIds); + if (!next || sameCoord(next, active)) return null; + + setActiveState(next); + if (opts?.shift && anchor.current) { + paintRange(anchor.current, next); + } else { + anchor.current = next; + setSelected(new Set([coordKey(next.lineRef, next.columnId)])); + } + return next; + }, + [active, columnIds, orderedLineRefs, paintRange], + ); + + const clearSelection = React.useCallback(() => { + setActiveState(null); + anchor.current = null; + setSelected(new Set()); + setDragging(false); + }, []); + + // A pointer released outside the grid still has to end the drag, or the next + // hover over a cell would keep extending a range the user already dropped. + React.useEffect(() => { + if (!dragging) return; + const stop = () => setDragging(false); + globalThis.addEventListener("pointerup", stop); + return () => globalThis.removeEventListener("pointerup", stop); + }, [dragging]); + + const isSelected = React.useCallback( + (lineRef: string, columnId: string) => selected.has(coordKey(lineRef, columnId)), + [selected], + ); + + return { + active, + selected, + dragging, + setActive, + beginSelection, + extendSelection, + endSelection, + moveActive, + clearSelection, + isSelected, + }; +} diff --git a/packages/core/src/components/line-items/use-line-items.test.ts b/packages/core/src/components/line-items/use-line-items.test.ts new file mode 100644 index 000000000..149e8cd7f --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items.test.ts @@ -0,0 +1,464 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { createLineItemHelper } from "./field"; +import type { LineItemsField, LineItemsMode, UseLineItemsOptions } from "./types"; +import { useLineItems } from "./use-line-items"; + +type Line = { + lineRef: string; + sku: string; + quantity: number; + total: number; +}; + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + sort: { comparator: (a, b) => a.quantity - b.quantity }, + }), + f.field({ key: "total", label: "Total", render: (l) => l.total.toFixed(2) }), +]; + +const seed: Line[] = [ + { lineRef: "1", sku: "APPLE-01", quantity: 2, total: 10 }, + { lineRef: "2", sku: "BANANA-02", quantity: 5, total: 25 }, +]; + +function setup(options?: Partial>) { + return renderHook(() => useLineItems({ fields, data: seed, selection: true, ...options })); +} + +describe("useLineItems", () => { + it("seeds lines from data and starts clean", () => { + const { result } = setup(); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().isEmpty).toBe(true); + }); + + it("generates a lineRef for rows that arrive without one", () => { + const { result } = setup({ + data: [{ sku: "NO-REF", quantity: 1, total: 0 } as unknown as Line], + }); + expect(result.current.allLines[0]?.lineRef).toBeTruthy(); + }); + + describe("mutations", () => { + it("adds a line and reports it as an add op", () => { + const { result } = setup(); + let newRef = ""; + act(() => { + newRef = result.current.addLine({ sku: "CHERRY-03", quantity: 1 }); + }); + + expect(result.current.allLines).toHaveLength(3); + expect(result.current.isDirty).toBe(true); + expect(result.current.getChangeSet().lineChanges).toContainEqual({ + action: "add", + tempId: newRef, + data: { sku: "CHERRY-03", quantity: 1 }, + }); + }); + + it("adds many lines in one batch, producing one add op each", () => { + const { result } = setup(); + act(() => { + result.current.addLines([{ sku: "A" }, { sku: "B" }, { sku: "C" }]); + }); + + expect(result.current.allLines).toHaveLength(5); + const adds = result.current.getChangeSet().lineChanges.filter((c) => c.action === "add"); + expect(adds).toHaveLength(3); + }); + + it("inserts after a given line when asked", () => { + const { result } = setup(); + act(() => { + result.current.addLine({ sku: "MIDDLE" }, { afterLineRef: "1" }); + }); + expect(result.current.allLines.map((l) => l.sku)).toEqual([ + "APPLE-01", + "MIDDLE", + "BANANA-02", + ]); + }); + + it("prepends when afterLineRef is null", () => { + const { result } = setup(); + act(() => { + result.current.addLine({ sku: "FIRST" }, { afterLineRef: null }); + }); + expect(result.current.allLines[0]?.sku).toBe("FIRST"); + }); + + it("updates a field and reports only that field", () => { + const { result } = setup(); + act(() => { + result.current.updateField("1", "quantity", 9); + }); + + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "update", lineId: "1", patch: { quantity: 9 } }, + ]); + }); + + it("removes a persisted line as a remove op", () => { + const { result } = setup(); + act(() => { + result.current.removeLine("2"); + }); + + expect(result.current.allLines).toHaveLength(1); + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "remove", lineId: "2" }, + ]); + }); + + it("removing a freshly added line leaves no trace in the change-set", () => { + const { result } = setup(); + let ref = ""; + act(() => { + ref = result.current.addLine({ sku: "TEMP" }); + }); + act(() => { + result.current.removeLine(ref); + }); + + expect(result.current.getChangeSet().isEmpty).toBe(true); + expect(result.current.isDirty).toBe(false); + }); + + it("applies batched updates in a single commit", () => { + const { result } = setup(); + act(() => { + result.current.updateLines([ + { lineRef: "1", patch: { quantity: 11 } }, + { lineRef: "2", patch: { quantity: 22 } }, + ]); + }); + + const changes = result.current.getChangeSet().lineChanges; + expect(changes).toHaveLength(2); + expect(changes.every((c) => c.action === "update")).toBe(true); + }); + + it("duplicates the last line as a new add", () => { + const { result } = setup(); + act(() => { + result.current.duplicateLastLine(); + }); + + expect(result.current.allLines).toHaveLength(3); + expect(result.current.allLines[2]?.sku).toBe("BANANA-02"); + expect(result.current.allLines[2]?.lineRef).not.toBe("2"); + }); + }); + + describe("ordering", () => { + it("ignores reorderLine under sort ordering", () => { + const { result } = setup({ ordering: "sort" }); + act(() => { + result.current.reorderLine("2", null); + }); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("moves a line and emits reorder ops under manual ordering", () => { + const { result } = setup({ ordering: "manual" }); + act(() => { + result.current.reorderLine("2", null); + }); + + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["2", "1"]); + expect(result.current.getChangeSet().lineChanges).toEqual([ + { action: "reorder", lineId: "2", position: 0 }, + { action: "reorder", lineId: "1", position: 1 }, + ]); + }); + + it("emits nothing when a line is moved back to where it started", () => { + const { result } = setup({ ordering: "manual" }); + act(() => { + result.current.reorderLine("1", "2"); + }); + act(() => { + result.current.reorderLine("1", null); + }); + + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + expect(result.current.getChangeSet().isEmpty).toBe(true); + }); + }); + + describe("sort", () => { + it("starts unsorted, in document order", () => { + const { result } = setup(); + expect(result.current.sortState).toBeNull(); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("cycles ascending, descending, then back to document order", () => { + const { result } = setup(); + + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toEqual({ columnId: "quantity", direction: "asc" }); + expect(result.current.lines.map((l) => l.quantity)).toEqual([2, 5]); + + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toEqual({ columnId: "quantity", direction: "desc" }); + expect(result.current.lines.map((l) => l.quantity)).toEqual([5, 2]); + + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toBeNull(); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("leaves allLines in document order while sorted", () => { + const { result } = setup(); + act(() => { + result.current.toggleSort("quantity"); + result.current.toggleSort("quantity"); + }); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["2", "1"]); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("ignores a column with no comparator", () => { + const { result } = setup(); + act(() => { + result.current.toggleSort("total"); + }); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("is inert under manual ordering, which owns row order", () => { + const { result } = setup({ ordering: "manual" }); + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.sortState).toBeNull(); + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["1", "2"]); + }); + + it("does not make the document dirty", () => { + const { result } = setup(); + act(() => { + result.current.toggleSort("quantity"); + }); + expect(result.current.isDirty).toBe(false); + expect(result.current.getChangeSet().isEmpty).toBe(true); + }); + }); + + describe("baseline", () => { + it("reset snaps the baseline forward and clears dirty", () => { + const { result } = setup(); + act(() => { + result.current.updateField("1", "quantity", 9); + }); + expect(result.current.isDirty).toBe(true); + + act(() => { + result.current.reset(); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.allLines[0]?.quantity).toBe(9); + }); + + it("revert restores rows and drops inserts and removals", () => { + const { result } = setup(); + act(() => { + result.current.updateField("1", "quantity", 9); + result.current.addLine({ sku: "TEMP" }); + result.current.removeLine("2"); + }); + expect(result.current.isDirty).toBe(true); + + act(() => { + result.current.revert(); + }); + + expect(result.current.isDirty).toBe(false); + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2"]); + expect(result.current.allLines[0]?.quantity).toBe(2); + }); + }); + + describe("filter", () => { + it("narrows lines by the searchable fields but leaves allLines intact", () => { + const { result } = setup(); + act(() => { + result.current.setFilter("banana"); + }); + + expect(result.current.lines.map((l) => l.lineRef)).toEqual(["2"]); + expect(result.current.allLines).toHaveLength(2); + }); + + it("returns everything for a blank query", () => { + const { result } = setup(); + act(() => { + result.current.setFilter(" "); + }); + expect(result.current.lines).toHaveLength(2); + }); + }); + + describe("selection", () => { + it("toggles a row on and off", () => { + const { result } = setup(); + act(() => { + result.current.toggleSelect("1"); + }); + expect(result.current.selectedIds).toEqual(["1"]); + + act(() => { + result.current.toggleSelect("1"); + }); + expect(result.current.selectedIds).toEqual([]); + }); + + it("ignores selection when disabled", () => { + const { result } = setup({ selection: false }); + act(() => { + result.current.toggleSelect("1"); + }); + expect(result.current.selectedIds).toEqual([]); + }); + + it("selects only the rows passing the filter", () => { + const { result } = setup(); + act(() => { + result.current.setFilter("banana"); + }); + act(() => { + result.current.selectAllVisible(); + }); + expect(result.current.selectedIds).toEqual(["2"]); + }); + + it("bulk-updates every selected row", () => { + const { result } = setup(); + act(() => { + result.current.selectAllVisible(); + }); + act(() => { + result.current.bulkUpdate({ quantity: 42 }); + }); + + expect(result.current.allLines.every((l) => l.quantity === 42)).toBe(true); + }); + + it("bulk-removes every selected row and clears the selection", () => { + const { result } = setup(); + act(() => { + result.current.selectAllVisible(); + }); + act(() => { + result.current.bulkRemove(); + }); + + expect(result.current.allLines).toEqual([]); + expect(result.current.selectedIds).toEqual([]); + }); + + it("drops a removed row from the selection", () => { + const { result } = setup(); + act(() => { + result.current.toggleSelect("2"); + }); + act(() => { + result.current.removeLine("2"); + }); + expect(result.current.selectedIds).toEqual([]); + }); + }); + + describe("controlled mode", () => { + it("notifies the parent once per mutation", () => { + const onLinesChange = vi.fn(); + const { result } = renderHook(() => + useLineItems({ fields, lines: seed, onLinesChange }), + ); + + expect(onLinesChange).not.toHaveBeenCalled(); + + act(() => { + result.current.updateField("1", "quantity", 3); + }); + + expect(onLinesChange).toHaveBeenCalledTimes(1); + expect(onLinesChange.mock.calls[0]?.[0]?.[0]?.quantity).toBe(3); + }); + + it("re-seeds when the set of lineRefs changes", () => { + const { result, rerender } = renderHook( + ({ lines }: { lines: Line[] }) => useLineItems({ fields, lines }), + { initialProps: { lines: seed } }, + ); + + rerender({ lines: [...seed, { lineRef: "3", sku: "CHERRY-03", quantity: 1, total: 5 }] }); + + expect(result.current.allLines.map((l) => l.lineRef)).toEqual(["1", "2", "3"]); + expect(result.current.isDirty).toBe(false); + }); + + it("keeps local edits when the parent re-renders with the same lineRefs", () => { + const { result, rerender } = renderHook( + ({ lines }: { lines: Line[] }) => useLineItems({ fields, lines }), + { initialProps: { lines: seed } }, + ); + + act(() => { + result.current.updateField("1", "quantity", 99); + }); + rerender({ lines: [...seed] }); + + expect(result.current.allLines[0]?.quantity).toBe(99); + expect(result.current.isDirty).toBe(true); + }); + }); + + describe("mode", () => { + it("defaults to edit and follows the prop", () => { + const { result, rerender } = renderHook( + ({ mode }: { mode: LineItemsMode }) => useLineItems({ fields, data: seed, mode }), + { initialProps: { mode: "edit" as LineItemsMode } }, + ); + expect(result.current.mode).toBe("edit"); + + rerender({ mode: "view" }); + expect(result.current.mode).toBe("view"); + }); + + it("can be driven imperatively", () => { + const { result } = setup(); + act(() => { + result.current.setMode("view"); + }); + expect(result.current.mode).toBe("view"); + }); + }); +}); diff --git a/packages/core/src/components/line-items/use-line-items.ts b/packages/core/src/components/line-items/use-line-items.ts new file mode 100644 index 000000000..2232868ce --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items.ts @@ -0,0 +1,497 @@ +import * as React from "react"; + +import { fieldCommitScope, fieldsToColumnDefs } from "./field"; +import { + buildChangeSet, + cloneBaseline, + cloneRow, + type LineItemsBaseline, + type LineItemsColumnDef, +} from "./internals"; +import type { + LineItemsChangeSet, + LineItemsMetadataCommit, + LineItemsMode, + LineItemsRowData, + LineItemsSortState, + UseLineItemsOptions, + UseLineItemsReturn, +} from "./types"; + +type RowState = { + byRef: Record; + order: string[]; +}; + +/** + * Dirty-tracking state. `inserted` and `removed` track lines added or deleted + * since the baseline, which lets the change-set engine tell an insert apart + * from an edit of a persisted line. + */ +type DirtyState = { + baseline: LineItemsBaseline; + inserted: ReadonlySet; + removed: ReadonlySet; +}; + +/** + * The single source of truth for a line-items document: row order and lookup, + * the dirty-tracking baseline behind `isDirty` and `getChangeSet()`, selection + * and filter state, and every mutation. + * + * Pair with `` to render, or drive the + * mutations directly from your own controls. + */ +export function useLineItems( + options: UseLineItemsOptions, +): UseLineItemsReturn { + const { + fields, + data, + lines: controlledLines, + onLinesChange, + mode: modeProp = "edit", + ordering = "sort", + selection = false, + onMetadataCommit, + } = options; + + const controlled = controlledLines !== undefined; + const seed = controlled ? controlledLines : data; + + const [rows, setRows] = React.useState>(() => packLines(sanitizeRows(seed ?? []))); + const [dirty, setDirty] = React.useState>(() => ({ + baseline: cloneBaseline(rows.order, rows.byRef), + inserted: new Set(), + removed: new Set(), + })); + + /** + * Re-seeds from a controlled `lines` prop only when its set of `lineRef`s + * changes, not on every value change — a parent re-rendering with equal rows + * must not clobber in-flight local edits. + */ + const seededRefs = React.useRef(undefined); + React.useEffect(() => { + if (!controlled) return; + const key = serializeRefs(controlledLines ?? []); + if (seededRefs.current === key) return; + seededRefs.current = key; + const next = packLines(sanitizeRows(controlledLines ?? [])); + setRows(next); + setDirty({ + baseline: cloneBaseline(next.order, next.byRef), + inserted: new Set(), + removed: new Set(), + }); + }, [controlled, controlledLines]); + + const [mode, setModeState] = React.useState(modeProp); + React.useEffect(() => { + setModeState(modeProp); + }, [modeProp]); + + const [filter, setFilterState] = React.useState(""); + const [sortState, setSortState] = React.useState(null); + const [selectedSet, setSelectedSet] = React.useState>( + () => new Set(), + ); + + const columns = React.useMemo[]>( + () => fieldsToColumnDefs(fields), + [fields], + ); + + const onLinesChangeRef = React.useRef(onLinesChange); + React.useEffect(() => { + onLinesChangeRef.current = onLinesChange; + }, [onLinesChange]); + + const onMetadataCommitRef = React.useRef(onMetadataCommit); + React.useEffect(() => { + onMetadataCommitRef.current = onMetadataCommit; + }, [onMetadataCommit]); + + /** Keys whose edits bypass the change-set and are reported out-of-band instead. */ + const metadataKeys = React.useMemo(() => { + const keys = new Set(); + for (const f of fields) { + if ((f.editable?.length ?? 0) > 0 && fieldCommitScope(f) === "metadata") keys.add(f.key); + } + return keys; + }, [fields]); + + /** + * Set by every local mutation so the notify effect below can tell a real edit + * apart from the initial mount or a re-seed from the `lines` prop, neither of + * which should echo back to the parent. + */ + const pendingNotify = React.useRef(false); + + const metadataKeysRef = React.useRef(metadataKeys); + metadataKeysRef.current = metadataKeys; + + const rowsRef = React.useRef(rows); + rowsRef.current = rows; + + const addLines = React.useCallback( + ( + items: ReadonlyArray>>, + opts?: { afterLineRef?: string | null }, + ): string[] => { + if (items.length === 0) return []; + const newRefs = items.map(() => newLineRef()); + pendingNotify.current = true; + + setRows((prev) => { + const byRef = { ...prev.byRef }; + items.forEach((partial, i) => { + const id = newRefs[i]!; + byRef[id] = { ...(partial as Record), lineRef: id } as T; + }); + return { byRef, order: insertAfter(prev.order, newRefs, opts?.afterLineRef) }; + }); + + setDirty((prev) => { + const inserted = new Set(prev.inserted); + for (const id of newRefs) inserted.add(id); + return { ...prev, inserted }; + }); + + return newRefs; + }, + [], + ); + + const addLine = React.useCallback( + (partial: Partial>, opts?: { afterLineRef?: string | null }): string => + addLines([partial], opts)[0]!, + [addLines], + ); + + const removeLines = React.useCallback((refs: readonly string[]) => { + if (refs.length === 0) return; + const doomed = new Set(refs); + pendingNotify.current = true; + + setRows((prev) => { + const byRef = { ...prev.byRef }; + for (const id of doomed) delete byRef[id]; + return { byRef, order: prev.order.filter((id) => !doomed.has(id)) }; + }); + + setDirty((prev) => { + const inserted = new Set(prev.inserted); + const removed = new Set(prev.removed); + for (const id of doomed) { + inserted.delete(id); + if (prev.baseline.rows[id]) removed.add(id); + } + return { ...prev, inserted, removed }; + }); + + setSelectedSet((prev) => { + if (prev.size === 0) return prev; + const next = new Set(prev); + for (const id of doomed) next.delete(id); + return next.size === prev.size ? prev : next; + }); + }, []); + + const removeLine = React.useCallback((lineRef: string) => removeLines([lineRef]), [removeLines]); + + const updateLines = React.useCallback((patches: { lineRef: string; patch: Partial }[]) => { + if (patches.length === 0) return; + pendingNotify.current = true; + + if (metadataKeysRef.current.size > 0 && onMetadataCommitRef.current) { + const current = rowsRef.current; + for (const { lineRef, patch } of patches) { + const row = current.byRef[lineRef]; + if (!row) continue; + const metaPatch: Record = {}; + const previous: Record = {}; + for (const key of Object.keys(patch)) { + if (!metadataKeysRef.current.has(key)) continue; + metaPatch[key] = (patch as Record)[key]; + previous[key] = (row as Record)[key]; + } + if (Object.keys(metaPatch).length === 0) continue; + onMetadataCommitRef.current({ + lineRef, + patch: metaPatch, + previous, + row: { ...row, ...patch } as T, + } satisfies LineItemsMetadataCommit); + } + } + + setRows((prev) => { + const byRef = { ...prev.byRef }; + let touched = false; + for (const { lineRef, patch } of patches) { + const cur = byRef[lineRef]; + if (!cur) continue; + byRef[lineRef] = { ...cur, ...patch } as T; + touched = true; + } + if (!touched) return prev; + return { byRef, order: prev.order }; + }); + }, []); + + const updateField = React.useCallback( + (lineRef: string, key: K, value: T[K]) => { + updateLines([{ lineRef, patch: { [key]: value } as unknown as Partial }]); + }, + [updateLines], + ); + + const reorderLine = React.useCallback( + (lineRef: string, afterLineRef: string | null) => { + if (ordering !== "manual") return; + pendingNotify.current = true; + setRows((prev) => { + if (!prev.byRef[lineRef]) return prev; + const without = prev.order.filter((id) => id !== lineRef); + return { byRef: prev.byRef, order: insertAfter(without, [lineRef], afterLineRef) }; + }); + }, + [ordering], + ); + + const duplicateLastLine = React.useCallback( + (derive?: (prev: T, newRef: string) => T): string | undefined => { + const last = rows.order.at(-1); + const src = last ? rows.byRef[last] : undefined; + if (!src) return undefined; + const id = newLineRef(); + const row = derive ? derive(cloneRow(src), id) : { ...cloneRow(src), lineRef: id }; + pendingNotify.current = true; + + setRows((prev) => ({ byRef: { ...prev.byRef, [id]: row }, order: [...prev.order, id] })); + setDirty((prev) => ({ ...prev, inserted: new Set(prev.inserted).add(id) })); + return id; + }, + [rows], + ); + + const reset = React.useCallback(() => { + setDirty({ + baseline: cloneBaseline(rows.order, rows.byRef), + inserted: new Set(), + removed: new Set(), + }); + }, [rows]); + + const revert = React.useCallback(() => { + const order = [...dirty.baseline.order]; + const byRef: Record = {}; + for (const id of order) { + const row = dirty.baseline.rows[id]; + if (row) byRef[id] = cloneRow(row); + } + pendingNotify.current = true; + setRows({ byRef, order }); + setDirty((prev) => ({ + ...prev, + inserted: new Set(), + removed: new Set(), + })); + setSelectedSet((prev) => { + if (prev.size === 0) return prev; + const next = new Set(); + for (const id of prev) if (byRef[id]) next.add(id); + return next.size === prev.size ? prev : next; + }); + }, [dirty.baseline]); + + const changeSet = React.useMemo( + (): LineItemsChangeSet => + buildChangeSet( + columns, + dirty.baseline, + rows.order, + rows.byRef, + dirty.removed, + dirty.inserted, + ordering, + ), + [columns, dirty, ordering, rows], + ); + + const getChangeSet = React.useCallback((): LineItemsChangeSet => changeSet, [changeSet]); + + const allLines = React.useMemo( + (): T[] => rows.order.map((id) => rows.byRef[id]).filter(Boolean) as T[], + [rows], + ); + + React.useEffect(() => { + if (!pendingNotify.current) return; + pendingNotify.current = false; + onLinesChangeRef.current?.(allLines); + }, [allLines]); + + const filteredLines = React.useMemo((): T[] => { + const query = filter.trim(); + if (query === "") return allLines; + const searchable = fields.filter((f) => typeof f.search === "function"); + if (searchable.length === 0) return allLines; + return allLines.filter((row) => searchable.some((f) => f.search!(row, query))); + }, [allLines, fields, filter]); + + const lines = React.useMemo((): T[] => { + if (!sortState) return filteredLines; + const comparator = fields.find((f) => f.key === sortState.columnId)?.sort?.comparator; + if (!comparator) return filteredLines; + // Inverting the comparator rather than reversing keeps the sort stable, so + // rows the comparator considers equal stay in document order either way. + const direction = sortState.direction === "desc" ? -1 : 1; + return filteredLines.toSorted((a, b) => direction * comparator(a, b)); + }, [fields, filteredLines, sortState]); + + /** + * Cycles ascending, descending, then back to document order. Manual ordering + * owns row order outright, so sorting is inert there. + */ + const toggleSort = React.useCallback( + (columnId: string) => { + if (ordering !== "sort") return; + setSortState((prev) => { + if (prev?.columnId !== columnId) return { columnId, direction: "asc" }; + if (prev.direction === "asc") return { columnId, direction: "desc" }; + return null; + }); + }, + [ordering], + ); + + const selectedIds = React.useMemo( + () => + selectedSet.size === 0 + ? [] + : rows.order.filter((id) => selectedSet.has(id) && rows.byRef[id]), + [rows, selectedSet], + ); + + const toggleSelect = React.useCallback( + (lineRef: string) => { + if (!selection) return; + setSelectedSet((prev) => { + const next = new Set(prev); + if (!next.delete(lineRef)) next.add(lineRef); + return next; + }); + }, + [selection], + ); + + const visibleRefs = React.useMemo(() => lines.map((l) => l.lineRef), [lines]); + + const selectAllVisible = React.useCallback(() => { + if (!selection) return; + setSelectedSet(new Set(visibleRefs)); + }, [selection, visibleRefs]); + + const clearSelection = React.useCallback(() => { + setSelectedSet((prev) => (prev.size === 0 ? prev : new Set())); + }, []); + + const bulkUpdate = React.useCallback( + (patch: Partial) => { + if (selectedIds.length === 0) return; + updateLines(selectedIds.map((lineRef) => ({ lineRef, patch }))); + }, + [selectedIds, updateLines], + ); + + const bulkRemove = React.useCallback(() => { + removeLines(selectedIds); + }, [removeLines, selectedIds]); + + const setMode = React.useCallback((m: LineItemsMode) => setModeState(m), []); + const setFilter = React.useCallback((q: string) => setFilterState(q), []); + + return { + lines, + allLines, + fields, + mode, + ordering, + isDirty: !changeSet.isEmpty, + filter, + selectionEnabled: selection, + selectedIds, + sortState: ordering === "sort" ? sortState : null, + + setMode, + setFilter, + toggleSort, + addLine, + addLines, + removeLine, + updateField, + updateLines, + reorderLine, + toggleSelect, + selectAllVisible, + clearSelection, + bulkUpdate, + bulkRemove, + duplicateLastLine, + reset, + revert, + getChangeSet, + }; +} + +export function newLineRef(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID(); + return `line-${Math.random().toString(36).slice(2, 11)}`; +} + +/** + * Splices `ids` into `order`: appended when `after` is `undefined`, prepended + * when `null`, otherwise placed directly after that ref. + */ +function insertAfter( + order: readonly string[], + ids: readonly string[], + after: string | null | undefined, +): string[] { + const next = [...order]; + if (after === undefined) { + next.push(...ids); + } else if (after === null) { + next.unshift(...ids); + } else { + const at = next.indexOf(after); + if (at === -1) next.push(...ids); + else next.splice(at + 1, 0, ...ids); + } + return next; +} + +/** Backfills a generated `lineRef` for any row that arrives without one. */ +function sanitizeRows(rows: readonly T[]): T[] { + return rows.map((row) => { + const ref = + typeof row.lineRef === "string" && row.lineRef.length > 0 ? row.lineRef : newLineRef(); + return { ...row, lineRef: ref }; + }); +} + +function packLines(rows: readonly T[]): RowState { + const byRef: Record = {}; + const order: string[] = []; + for (const row of rows) { + byRef[row.lineRef] = row; + order.push(row.lineRef); + } + return { byRef, order }; +} + +function serializeRefs(rows: readonly T[]): string { + return `${rows.length}:${rows.map((r) => r.lineRef).join(",")}`; +} diff --git a/packages/core/src/components/line-items/use-row-virtualizer.ts b/packages/core/src/components/line-items/use-row-virtualizer.ts new file mode 100644 index 000000000..b76f4f19f --- /dev/null +++ b/packages/core/src/components/line-items/use-row-virtualizer.ts @@ -0,0 +1,81 @@ +import * as React from "react"; + +import { computeVirtualWindow, sameVirtualWindow, type VirtualWindow } from "./virtual-window"; + +export type UseRowVirtualizerOptions = { + rowCount: number; + rowHeight: number; + overscan?: number; + /** Set `false` to mount every row — used by tests and by short tables. */ + enabled?: boolean; +}; + +const EMPTY_WINDOW: VirtualWindow = { + startIndex: 0, + endIndex: 0, + paddingTop: 0, + paddingBottom: 0, +}; + +/** + * Tracks the scroll container and reports which row range to mount. + * + * Scroll events are coalesced into one animation frame, and state is only + * written when the row range actually changes — scrolling within a single row's + * height must not re-render the grid, or typing in a cell would drop focus + * whenever the container scrolls. + */ +export function useRowVirtualizer( + containerRef: React.RefObject, + { rowCount, rowHeight, overscan = 8, enabled = true }: UseRowVirtualizerOptions, +): VirtualWindow { + const [window_, setWindow] = React.useState(EMPTY_WINDOW); + + const measure = React.useCallback(() => { + const el = containerRef.current; + if (!el) return; + const next = computeVirtualWindow({ + rowCount, + rowHeight, + scrollTop: el.scrollTop, + viewportHeight: el.clientHeight, + overscan, + }); + setWindow((prev) => + sameVirtualWindow(prev, next) && prev.paddingTop === next.paddingTop ? prev : next, + ); + }, [containerRef, overscan, rowCount, rowHeight]); + + React.useEffect(() => { + if (!enabled) return; + const el = containerRef.current; + if (!el) return; + + measure(); + + let frame = 0; + const onScroll = () => { + if (frame) return; + frame = requestAnimationFrame(() => { + frame = 0; + measure(); + }); + }; + + el.addEventListener("scroll", onScroll, { passive: true }); + const observer = new ResizeObserver(measure); + observer.observe(el); + + return () => { + if (frame) cancelAnimationFrame(frame); + el.removeEventListener("scroll", onScroll); + observer.disconnect(); + }; + }, [containerRef, enabled, measure]); + + if (!enabled) { + return { startIndex: 0, endIndex: rowCount, paddingTop: 0, paddingBottom: 0 }; + } + + return window_; +} diff --git a/packages/core/src/components/line-items/virtual-window.test.ts b/packages/core/src/components/line-items/virtual-window.test.ts new file mode 100644 index 000000000..ec0501f89 --- /dev/null +++ b/packages/core/src/components/line-items/virtual-window.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; + +import { computeVirtualWindow, sameVirtualWindow } from "./virtual-window"; + +const base = { rowCount: 1000, rowHeight: 32, viewportHeight: 320, overscan: 4 }; + +describe("computeVirtualWindow", () => { + it("mounts only the visible slice plus overscan", () => { + const w = computeVirtualWindow({ ...base, scrollTop: 0 }); + expect(w.startIndex).toBe(0); + expect(w.endIndex).toBe(15); + expect(w.endIndex - w.startIndex).toBeLessThan(base.rowCount); + }); + + it("spacer heights always account for the unmounted rows", () => { + const w = computeVirtualWindow({ ...base, scrollTop: 3200 }); + const totalHeight = + w.paddingTop + (w.endIndex - w.startIndex) * base.rowHeight + w.paddingBottom; + expect(totalHeight).toBe(base.rowCount * base.rowHeight); + }); + + it("advances the window as the container scrolls", () => { + const top = computeVirtualWindow({ ...base, scrollTop: 0 }); + const mid = computeVirtualWindow({ ...base, scrollTop: 3200 }); + expect(mid.startIndex).toBeGreaterThan(top.startIndex); + expect(mid.paddingTop).toBe(mid.startIndex * base.rowHeight); + }); + + it("clamps at the top rather than going negative", () => { + const w = computeVirtualWindow({ ...base, scrollTop: -500 }); + expect(w.startIndex).toBe(0); + expect(w.paddingTop).toBe(0); + }); + + it("clamps at the bottom with no trailing spacer", () => { + const w = computeVirtualWindow({ ...base, scrollTop: base.rowCount * base.rowHeight }); + expect(w.endIndex).toBe(base.rowCount); + expect(w.paddingBottom).toBe(0); + expect(w.startIndex).toBeLessThan(base.rowCount); + }); + + it("returns an empty window for an empty document", () => { + expect(computeVirtualWindow({ ...base, rowCount: 0, scrollTop: 0 })).toEqual({ + startIndex: 0, + endIndex: 0, + paddingTop: 0, + paddingBottom: 0, + }); + }); + + it("mounts a leading slice before layout has measured the viewport", () => { + const w = computeVirtualWindow({ ...base, viewportHeight: 0, scrollTop: 0 }); + expect(w.endIndex).toBeGreaterThan(0); + }); + + it("mounts every row when the document is shorter than the viewport", () => { + const w = computeVirtualWindow({ ...base, rowCount: 3, scrollTop: 0 }); + expect(w.startIndex).toBe(0); + expect(w.endIndex).toBe(3); + expect(w.paddingBottom).toBe(0); + }); +}); + +describe("sameVirtualWindow", () => { + it("compares by mounted range so sub-row scrolling does not re-render", () => { + const a = computeVirtualWindow({ ...base, scrollTop: 100 }); + const b = computeVirtualWindow({ ...base, scrollTop: 110 }); + expect(sameVirtualWindow(a, b)).toBe(true); + + const c = computeVirtualWindow({ ...base, scrollTop: 900 }); + expect(sameVirtualWindow(a, c)).toBe(false); + }); +}); diff --git a/packages/core/src/components/line-items/virtual-window.ts b/packages/core/src/components/line-items/virtual-window.ts new file mode 100644 index 000000000..defc84146 --- /dev/null +++ b/packages/core/src/components/line-items/virtual-window.ts @@ -0,0 +1,57 @@ +/** The slice of rows to mount, plus the spacer heights that stand in for the rest. */ +export type VirtualWindow = { + startIndex: number; + /** Exclusive. */ + endIndex: number; + paddingTop: number; + paddingBottom: number; +}; + +export type VirtualWindowInput = { + rowCount: number; + rowHeight: number; + scrollTop: number; + viewportHeight: number; + /** Rows rendered beyond each edge of the viewport, to cover fast scrolling. */ + overscan: number; +}; + +/** + * Windowing math for a uniform row height. Rows outside the window are replaced + * by a single spacer row above and below, so the scrollbar still reflects the + * full document while only the visible slice is mounted. + * + * A viewport height of 0 (the first render, before layout) yields a small + * leading slice rather than nothing, so the table paints content immediately + * instead of flashing empty. + */ +export function computeVirtualWindow({ + rowCount, + rowHeight, + scrollTop, + viewportHeight, + overscan, +}: VirtualWindowInput): VirtualWindow { + if (rowCount <= 0 || rowHeight <= 0) { + return { startIndex: 0, endIndex: 0, paddingTop: 0, paddingBottom: 0 }; + } + + const safeScrollTop = Math.max(0, scrollTop); + const visibleCount = + viewportHeight > 0 ? Math.ceil(viewportHeight / rowHeight) + 1 : Math.min(rowCount, overscan); + + const firstVisible = Math.floor(safeScrollTop / rowHeight); + const startIndex = Math.max(0, Math.min(firstVisible - overscan, rowCount - 1)); + const endIndex = Math.min(rowCount, firstVisible + visibleCount + overscan); + + return { + startIndex, + endIndex, + paddingTop: startIndex * rowHeight, + paddingBottom: Math.max(0, (rowCount - endIndex) * rowHeight), + }; +} + +export function sameVirtualWindow(a: VirtualWindow, b: VirtualWindow): boolean { + return a.startIndex === b.startIndex && a.endIndex === b.endIndex; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9c9783153..c96008e25 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -163,7 +163,34 @@ export { useOverrideBreadcrumb } from "./hooks/use-override-breadcrumb"; export { Alert, alertVariants, type AlertProps } from "./components/alert"; export { Badge, badgeVariants, type BadgeProps } from "./components/badge"; export type { BadgeVariant, BadgeOptions } from "./components/badge-list"; +export { + BulkItemPicker, + type BulkItemPickerProps, + type BulkItemPickerNode, +} from "./components/bulk-item-picker"; export { DescriptionCard, type DescriptionCardProps } from "./components/description-card"; +export { + LineItems, + useLineItems, + createLineItemHelper, + lineItemsFloatingBarStyles, + type LineItemsChangeSet, + type LineItemsCustomEditorContext, + type LineItemsField, + type LineItemsFieldType, + type LineItemsLineChange, + type LineItemsMode, + type LineItemsOrderingMode, + type LineItemsRootProps, + type LineItemsRowData, + type LineItemsSelectOption, + type LineItemsTableProps, + type LineItemsFieldCommit, + type LineItemsMetadataCommit, + type LineItemsSortState, + type UseLineItemsOptions, + type UseLineItemsReturn, +} from "./components/line-items"; export { ActivityCard, type ActivityCardProps, From f6b6715b5edd2f5d8affb744c44910d117154cb2 Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:11:19 +0530 Subject: [PATCH 02/25] docs(line-items): add LineItems and BulkItemPicker outlines Co-Authored-By: Claude Opus 5.5 --- docs-manifest.json | 59 ++++- .../bulk-item-picker.docs.outline.md | 142 ++++++++++++ .../components/line-items.docs.outline.md | 216 ++++++++++++++++++ docs/components/bulk-item-picker.md | 140 ++++++++++++ docs/components/line-items.md | 214 +++++++++++++++++ 5 files changed, 770 insertions(+), 1 deletion(-) create mode 100644 docs-src/components/bulk-item-picker.docs.outline.md create mode 100644 docs-src/components/line-items.docs.outline.md create mode 100644 docs/components/bulk-item-picker.md create mode 100644 docs/components/line-items.md diff --git a/docs-manifest.json b/docs-manifest.json index 163a15912..fcd32cee3 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -213,6 +213,23 @@ "examples": "edd9868442daa0b0" } }, + "bulk-item-picker": { + "slug": "bulk-item-picker", + "kind": "code-backed", + "outline": "docs-src/components/bulk-item-picker.docs.outline.md", + "output": "docs/components/bulk-item-picker.md", + "examples": null, + "sources": ["packages/core/src/components/bulk-item-picker/**"], + "claims": [], + "symbols": ["BulkItemPicker", "BulkItemPickerNode", "BulkItemPickerProps"], + "hashes": { + "typeSurface": "b824752584c0ec62", + "outline": "283588ffb3179619", + "snapshot": "70112a170b43395a", + "outputMd": "2aded34a5e216c7a", + "examples": null + } + }, "button": { "slug": "button", "kind": "code-backed", @@ -987,6 +1004,44 @@ "examples": "2a4aa694c4d48324" } }, + "line-items": { + "slug": "line-items", + "kind": "code-backed", + "outline": "docs-src/components/line-items.docs.outline.md", + "output": "docs/components/line-items.md", + "examples": null, + "sources": ["packages/core/src/components/line-items/**"], + "claims": [], + "symbols": [ + "LineItems", + "LineItemsChangeSet", + "LineItemsCustomEditorContext", + "LineItemsField", + "LineItemsFieldCommit", + "LineItemsFieldType", + "LineItemsLineChange", + "LineItemsMetadataCommit", + "LineItemsMode", + "LineItemsOrderingMode", + "LineItemsRootProps", + "LineItemsRowData", + "LineItemsSelectOption", + "LineItemsSortState", + "LineItemsTableProps", + "UseLineItemsOptions", + "UseLineItemsReturn", + "createLineItemHelper", + "lineItemsFloatingBarStyles", + "useLineItems" + ], + "hashes": { + "typeSurface": "d9ec449a4cf030d8", + "outline": "041b094ffbf70772", + "snapshot": "7bda9a2537aa6263", + "outputMd": "6516099e63d212b2", + "examples": null + } + }, "list-dense-scan": { "slug": "list-dense-scan", "kind": "prose", @@ -1670,6 +1725,7 @@ "packages/core/skills/app-shell-patterns/references/components/autocomplete.md": "bd6ae01bf39d1ad6", "packages/core/skills/app-shell-patterns/references/components/avatar.md": "a874b74c176bcb47", "packages/core/skills/app-shell-patterns/references/components/badge.md": "0cb27fbce6f76e2e", + "packages/core/skills/app-shell-patterns/references/components/bulk-item-picker.md": "2aded34a5e216c7a", "packages/core/skills/app-shell-patterns/references/components/button.md": "bce0e2fada1d1638", "packages/core/skills/app-shell-patterns/references/components/card.md": "675dc665e5082ff0", "packages/core/skills/app-shell-patterns/references/components/checkbox.md": "14f7ba23f441df38", @@ -1688,6 +1744,7 @@ "packages/core/skills/app-shell-patterns/references/components/grid.md": "9051eb53dde4179f", "packages/core/skills/app-shell-patterns/references/components/input.md": "a3a6c9679af99d37", "packages/core/skills/app-shell-patterns/references/components/layout.md": "66696139a46fabb0", + "packages/core/skills/app-shell-patterns/references/components/line-items.md": "6516099e63d212b2", "packages/core/skills/app-shell-patterns/references/components/menu.md": "ceb4a05822538d20", "packages/core/skills/app-shell-patterns/references/components/metric-card.md": "5a1c557768c6590f", "packages/core/skills/app-shell-patterns/references/components/select.md": "ae287e2bb5abc1c6", @@ -1739,6 +1796,6 @@ "packages/core/skills/app-shell-patterns/references/patterns/list-dense-scan.md": "916baa3cd2b9cbf1", "packages/core/skills/app-shell-patterns/references/pages/document-detail.md": "717ef4e7ae835aff", "packages/core/skills/app-shell-patterns/references/migrations.md": "013c3fef2303b096", - "packages/core/skills/app-shell-patterns/SKILL.md": "12527965a47cf42f" + "packages/core/skills/app-shell-patterns/SKILL.md": "5c7889c92f0b5592" } } diff --git a/docs-src/components/bulk-item-picker.docs.outline.md b/docs-src/components/bulk-item-picker.docs.outline.md new file mode 100644 index 000000000..a5b4cc2e1 --- /dev/null +++ b/docs-src/components/bulk-item-picker.docs.outline.md @@ -0,0 +1,142 @@ +--- +kind: code-backed +group: bulk-item-picker +title: BulkItemPicker +description: Tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch +sources: + - packages/core/src/components/bulk-item-picker/** +--- + +# BulkItemPicker + +`BulkItemPicker` is a dialog for picking many items out of a hierarchical catalog — products with variants, nested categories — and handing them back in one batch. Ticking a parent selects every leaf beneath it, and searching auto-expands to the matches. + +It is generic and standalone: it knows nothing about line items, or about what your nodes contain. `data` is opaque, handed back only to your render and search callbacks. + +## Import + +```tsx +import { BulkItemPicker } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: BulkItemPickerNode[] = [ + { + id: "tees", + data: { sku: "TEES", name: "T-Shirts", price: 0 }, + children: [ + { id: "TEE-WHT-S", data: { sku: "TEE-WHT-S", name: "Tee, White, Small", price: 18.5 } }, + { id: "TEE-WHT-M", data: { sku: "TEE-WHT-M", name: "Tee, White, Medium", price: 18.5 } }, + ], + }, +]; + + + open={open} + onOpenChange={setOpen} + title="Add items from catalog" + items={CATALOG} + renderRow={(node) => node.data.name} + renderMetric={(node) => (node.children ? null : `$${node.data.price.toFixed(2)}`)} + rowLabel="Product" + metricLabel="Unit price" + matchesSearch={(node, q) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(q.toLowerCase()) + } + onCommit={(picked) => addToOrder(picked.map((n) => n.data))} +/>; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------- | --------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------ | +| `open` | `boolean` | required | Controls dialog visibility. | +| `onOpenChange` | `(open: boolean) => void` | required | Fires on every user-driven open or close, including after a commit. | +| `items` | `ReadonlyArray>` | required | The catalog tree. Selection is leaf-only. | +| `renderRow` | `(node, depth) => ReactNode` | required | The row's own content. The library draws the checkbox, caret, and indentation. | +| `onCommit` | `(selected: BulkItemPickerNode[]) => void` | required | Receives the selected leaf nodes in tree order. | +| `title` | `ReactNode` | `"Select items"` | Dialog title. Also labels the tree for assistive tech. | +| `renderMetric` | `(node, depth) => ReactNode` | – | Optional right-aligned metric column. | +| `rowLabel` | `ReactNode` | `"Item"` | Header above the `renderRow` column. | +| `metricLabel` | `ReactNode` | – | Header above the metric column. | +| `matchesSearch` | `(node, query) => boolean` | substring on `data` | Search predicate. **Override whenever `data` is an object.** | +| `filterSlot` | `ReactNode` | – | Slot beside the search input, e.g. a category filter. | +| `ctaLabel` | `(count: number) => string` | `"Add N items"` | Primary button label. | +| `emptyText` | `ReactNode` | `"No matching items."` | Shown when the filtered tree is empty. | +| `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. | + +### BulkItemPickerNode + +```tsx +type BulkItemPickerNode = { + id: string; + data: T; + children?: BulkItemPickerNode[]; +}; +``` + +`id` must be unique across the whole tree — it doubles as the selection key. A node with no `children` is a leaf. + +The default `matchesSearch` stringifies `node.data`, which only works for primitives. With object payloads every node becomes `"[object Object]"` and nothing matches, so pass your own predicate. + +## Behavior + +**Selection is leaf-only.** Only leaves are ever selected, and only leaves reach `onCommit`. A parent's checkbox is derived from its descendants: checked when all of them are selected, indeterminate when some are. Ticking a parent adds or removes all its leaves at once, and the CTA counts leaves, not rows. + +**Search auto-expands.** A node survives the filter if it matches or if any descendant does. Every ancestor of a match is expanded for the duration of the query, so matches are visible without clicking; manually expanded rows stay expanded once the query clears. + +**State resets on close.** Query, selection, and expansion are cleared when the dialog closes, so reopening starts fresh. + +**Keyboard.** The tree is a `role="tree"` with roving focus: Arrow Up/Down move between visible rows, Arrow Right expands a collapsed parent or steps into it, Arrow Left collapses or steps out to the parent, Space toggles the focused row, and Enter commits — except while typing in the search box. + +## Loading an async catalog + +There is deliberately no async variant: auto-expanding to matches needs the whole tree up front. Fetch once before or on open, and drive `emptyText` from your own loading flag. + +```tsx +const { data, isLoading } = useCatalogQuery(); + + node.data.name} + onCommit={handleCommit} +/>; +``` + +## Pairing with LineItems + +`onCommit` hands back leaf nodes; map them into rows and pass them to `addLines`, which inserts them in a single render and produces one `add` op per line. + +```tsx +const lineItems = useLineItems({ fields, data: lines }); + + node.data.name} + onCommit={(picked) => + lineItems.addLines( + picked.map((node) => ({ + sku: node.data.sku, + quantity: 1, + unitPrice: node.data.price, + })), + ) + } +/>; +``` + +## Related + +- [LineItems](./line-items.md) — the editable document grid this commonly feeds +- [Combobox](./combobox.md) — inline single or multi select for flat option lists +- [Dialog](./dialog.md) — the modal shell this composes diff --git a/docs-src/components/line-items.docs.outline.md b/docs-src/components/line-items.docs.outline.md new file mode 100644 index 000000000..044fb622d --- /dev/null +++ b/docs-src/components/line-items.docs.outline.md @@ -0,0 +1,216 @@ +--- +kind: code-backed +group: line-items +title: LineItems +description: Spreadsheet-grade editable table for the lines of a document, with a change-set for submitting only what changed +sources: + - packages/core/src/components/line-items/** +--- + +# LineItems + +`LineItems` edits the rows of a document — purchase orders, invoices, journals, work orders, stock transfers — with spreadsheet ergonomics: inline cell editors, range selection, TSV copy/paste, keyboard navigation, drag reordering, and row virtualization for long documents. + +State lives in the `useLineItems` hook; `LineItems.*` renders it. The component is UI-only — it takes no opinion on transport. `getChangeSet()` returns a transport-agnostic description of what changed, which you map onto your own mutations. + +## Import + +```tsx +import { LineItems, useLineItems, createLineItemHelper } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type POLine = { + lineRef: string; + sku: string; + quantity: number; + unitPrice: number; +}; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 90, + }), + f.field({ + key: "total", + label: "Total", + render: (l) => `$${((l.quantity ?? 0) * (l.unitPrice ?? 0)).toFixed(2)}`, + width: 120, + }), +]; + +function PurchaseOrderLines({ lines }: { lines: POLine[] }) { + const lineItems = useLineItems({ fields, data: lines, selection: true }); + + return ( + + + + submit(changeSet)} /> + + ); +} +``` + +Every row needs a stable `lineRef` — the server id for a persisted line. Rows that arrive without one are assigned a generated id. + +## Parts + +| Part | Description | +| ------------------------ | --------------------------------------------------------------------------------- | +| `LineItems.Root` | Provides the document to the parts beneath it. Holds no row data of its own. | +| `LineItems.Table` | The editable grid: virtualized rows, pinned columns, cell editors, drag reorder. | +| `LineItems.Search` | Search box bound to the filter. Only fields with a `search` function participate. | +| `LineItems.AddRow` | Appends a line. Renders nothing in view mode. | +| `LineItems.SaveActions` | Save and discard buttons, both disabled while the document is clean. | +| `LineItems.FloatingDock` | Sticky container for the bars below. | +| `LineItems.DirtyBar` | Appears while there are uncommitted changes. Renders nothing when clean. | +| `LineItems.SelectionBar` | Appears while rows are selected. Mirrors DataTable's selection-footer layout. | + +## useLineItems(options) + +| Option | Type | Default | Description | +| --------------- | --------------------- | -------- | ----------------------------------------------------------------- | +| `fields` | `LineItemsField[]` | required | Column schema. Build with `createLineItemHelper().field({…})`. | +| `data` | `readonly T[]` | `[]` | Uncontrolled seed rows, also the dirty-tracking baseline. | +| `lines` | `readonly T[]` | – | Controlled rows. When set, `data` is ignored. | +| `onLinesChange` | `(next: T[]) => void` | – | Called once per mutation with the new rows. | +| `mode` | `"view" \| "edit"` | `"edit"` | `"view"` renders every cell read-only. | +| `ordering` | `"sort" \| "manual"` | `"sort"` | `"manual"` enables drag reordering and emits `reorder` ops. | +| `selection` | `boolean` | `false` | Adds the row selection checkbox column. | + +### Return value + +| Member | Description | +| ------------------------------------ | ------------------------------------------------------------------------------ | +| `lines` / `allLines` | Rows with the search filter applied, and the full document order. | +| `isDirty` | Whether current state diverges from the baseline. | +| `selectedIds` | `lineRef`s of the selected rows. | +| `sortState` | Active sort, or `null` in document order. Always `null` under manual ordering. | +| `toggleSort(columnId)` | Cycles a column ascending, descending, then unsorted. | +| `addLine(data, opts?)` | Inserts one line, returning its new `lineRef`. | +| `addLines(items, opts?)` | Inserts many in one render. Use this for bulk pickers. | +| `removeLine(lineRef)` | Deletes a line. | +| `updateField(lineRef, key, value)` | Typed single-cell update. | +| `updateLines(patches)` | Batched updates — one render for a whole paste or fill. | +| `reorderLine(lineRef, afterLineRef)` | Moves a line. Manual ordering only; pass `null` to move to the head. | +| `bulkUpdate(patch)` / `bulkRemove()` | Apply to every selected row. | +| `reset()` | Snaps the baseline forward. Call after a successful save. | +| `revert()` | Discards every uncommitted change back to the baseline. | +| `getChangeSet()` | What changed, ready to submit. | + +`addLine` and `addLines` take `{ afterLineRef }`: omit it to append, pass `null` to prepend, or pass a `lineRef` to insert directly after that line. + +## Field schema + +| Property | Type | Description | +| ----------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | +| `key` | `string` | Column id, and the property name on `T` for editable fields. | +| `label` | `ReactNode` | Header content. | +| `render` | `(line: T) => ReactNode` | Cell content when no editor is shown. | +| `editable` | `LineItemsMode[]` | Modes that show an editor. Omit for a read-only or computed column. | +| `type` | `LineItemsFieldType` | Required when editable. Drives the editor and equality semantics. | +| `sort` | `{ comparator }` | Makes the header sortable (asc, desc, unsorted). Ignored under manual ordering. | +| `search` | `(line: T, query: string) => boolean` | Opts the column into `LineItems.Search`. | +| `align` | `"left" \| "center" \| "right"` | Defaults to right for numbers, center for booleans. | +| `className` | `string \| ((line: T) => string \| undefined)` | Per-cell classes — useful for flagging discrepancies. | +| `normalize` | `(value, row) => unknown` | Coerce before comparison and before entering the change-set. | +| `equals` | `(a, b, row) => boolean` | Custom dirty-equality. | +| `width` | `number` | Resting width in pixels. | +| `pin` | `"left" \| "right"` | Freezes the column during horizontal scroll. | +| `flex` | `boolean` | Absorbs leftover horizontal space. | + +### Field types + +```tsx +{ kind: "text" } +{ kind: "number", decimals?: number } +{ kind: "select", options: { value, label, description? }[], placeholder?: string } +{ kind: "boolean", trueLabel?: string, falseLabel?: string } +{ kind: "date", min?: string, max?: string } +{ kind: "custom", renderEditor: (ctx) => ReactNode, normalize?, equals? } +``` + +Numeric fields normalize strings to numbers, round by `decimals`, and compare with a small epsilon, so `"1"` and `"1.00"` are not a change. Strings are trimmed. A field's own `normalize` and `equals` win over both. + +## LineItems.Table Props + +| Prop | Type | Default | Description | +| ------------- | --------------------------- | ------- | ------------------------------------------------------------ | +| `rowHeight` | `number` | `36` | Uniform row height, which the virtualizer's math depends on. | +| `maxHeight` | `number \| string` | `480` | Scroll viewport height. | +| `virtualized` | `boolean` | `true` | Set `false` to mount every row. | +| `rowActions` | `(row: T) => ReactNode` | – | Trailing pinned column of per-row controls. | +| `totalsRow` | `(lines: T[]) => ReactNode` | – | Sticky `` row, column-aligned with the grid. | +| `emptyState` | `ReactNode` | – | Shown when the document has no lines. | + +`LineItems.Table` cannot infer `T` from `Root`, so annotate the callback parameters (`rowActions={(row: POLine) => …}`) or pass the type argument explicitly (` … />`). + +## Change-set + +`getChangeSet()` returns `{ isEmpty, lineChanges }`, where each change is one of: + +```tsx +{ action: "add", tempId: string, data: Record } +{ action: "update", lineId: string, patch: Record } +{ action: "remove", lineId: string } +{ action: "reorder", lineId: string, position: number } +``` + +`tempId` is client-only — the server mints the real id on insert. `patch` carries only the fields that actually changed. `reorder` is emitted for manual ordering only, and only for persisted lines whose index moved, so adding or removing a line does not read as a move of everything below it. Read-only and computed columns never appear. + +```tsx + { + if (changeSet.isEmpty) return; + await submitLines(changeSet.lineChanges); + lineItems.reset(); // baseline moves forward; the document is clean again + }} +/> +``` + +## Spreadsheet behavior + +Only editable columns take part in the grid's selection. + +| Interaction | Result | +| ------------------------------ | --------------------------------------------------------------------------------------- | +| Click a cell | Selects it and opens its editor with the value selected | +| Shift-click, or click and drag | Selects the rectangle between the anchor and that cell | +| Arrow keys | Moves the active cell; hold Shift to extend the selection | +| Enter / Tab | Commits and moves down / right (Shift reverses) | +| Escape | Abandons the edit, keeping the previous value | +| Copy | Writes the selected rectangle as TSV | +| Paste | Writes a TSV block from the active cell; a single value broadcasts across the selection | + +Edits commit on blur or Enter, never per keystroke. + +## Sorting + +Under the default `ordering: "sort"`, a field with a `sort.comparator` gets a clickable header that cycles ascending, descending, then back to document order, and carries `aria-sort` throughout. Sorting only reorders what you see: `allLines` stays in document order, sorting never marks the document dirty, and it emits no `reorder` ops. Under `ordering: "manual"` the document owns row order outright, so sort affordances are not rendered. + +## Drag reordering + +Set `ordering: "manual"` to get a grip handle on each row. Reordering uses native HTML5 drag-and-drop, which works with virtualized rows. Because native drag has no keyboard equivalent, the grip also responds to the keyboard: Enter or Space lifts the row, Arrow Up/Down moves it, and Enter or Escape drops it. Touch drag is not supported. + +## Related + +- [DataTable](./data-table.md) — read-oriented collection table with sorting, filtering, and pagination +- [BulkItemPicker](./bulk-item-picker.md) — tree-select dialog that pairs with `addLines` diff --git a/docs/components/bulk-item-picker.md b/docs/components/bulk-item-picker.md new file mode 100644 index 000000000..cab3c1a05 --- /dev/null +++ b/docs/components/bulk-item-picker.md @@ -0,0 +1,140 @@ +--- +title: BulkItemPicker +description: Tree-select dialog for picking many items from a hierarchical catalog and committing them in one batch +--- + + + +# BulkItemPicker + +`BulkItemPicker` is a dialog for picking many items out of a hierarchical catalog — products with variants, nested categories — and handing them back in one batch. Ticking a parent selects every leaf beneath it, and searching auto-expands to the matches. + +It is generic and standalone: it knows nothing about line items, or about what your nodes contain. `data` is opaque, handed back only to your render and search callbacks. + +## Import + +```tsx +import { BulkItemPicker } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: BulkItemPickerNode[] = [ + { + id: "tees", + data: { sku: "TEES", name: "T-Shirts", price: 0 }, + children: [ + { id: "TEE-WHT-S", data: { sku: "TEE-WHT-S", name: "Tee, White, Small", price: 18.5 } }, + { id: "TEE-WHT-M", data: { sku: "TEE-WHT-M", name: "Tee, White, Medium", price: 18.5 } }, + ], + }, +]; + + + open={open} + onOpenChange={setOpen} + title="Add items from catalog" + items={CATALOG} + renderRow={(node) => node.data.name} + renderMetric={(node) => (node.children ? null : `$${node.data.price.toFixed(2)}`)} + rowLabel="Product" + metricLabel="Unit price" + matchesSearch={(node, q) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(q.toLowerCase()) + } + onCommit={(picked) => addToOrder(picked.map((n) => n.data))} +/>; +``` + +## Props + +| Prop | Type | Default | Description | +| ------------------- | --------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------ | +| `open` | `boolean` | required | Controls dialog visibility. | +| `onOpenChange` | `(open: boolean) => void` | required | Fires on every user-driven open or close, including after a commit. | +| `items` | `ReadonlyArray>` | required | The catalog tree. Selection is leaf-only. | +| `renderRow` | `(node, depth) => ReactNode` | required | The row's own content. The library draws the checkbox, caret, and indentation. | +| `onCommit` | `(selected: BulkItemPickerNode[]) => void` | required | Receives the selected leaf nodes in tree order. | +| `title` | `ReactNode` | `"Select items"` | Dialog title. Also labels the tree for assistive tech. | +| `renderMetric` | `(node, depth) => ReactNode` | – | Optional right-aligned metric column. | +| `rowLabel` | `ReactNode` | `"Item"` | Header above the `renderRow` column. | +| `metricLabel` | `ReactNode` | – | Header above the metric column. | +| `matchesSearch` | `(node, query) => boolean` | substring on `data` | Search predicate. **Override whenever `data` is an object.** | +| `filterSlot` | `ReactNode` | – | Slot beside the search input, e.g. a category filter. | +| `ctaLabel` | `(count: number) => string` | `"Add N items"` | Primary button label. | +| `emptyText` | `ReactNode` | `"No matching items."` | Shown when the filtered tree is empty. | +| `searchPlaceholder` | `string` | `"Search"` | Search input placeholder. | + +### BulkItemPickerNode + +```tsx +type BulkItemPickerNode = { + id: string; + data: T; + children?: BulkItemPickerNode[]; +}; +``` + +`id` must be unique across the whole tree — it doubles as the selection key. A node with no `children` is a leaf. + +The default `matchesSearch` stringifies `node.data`, which only works for primitives. With object payloads every node becomes `"[object Object]"` and nothing matches, so pass your own predicate. + +## Behavior + +**Selection is leaf-only.** Only leaves are ever selected, and only leaves reach `onCommit`. A parent's checkbox is derived from its descendants: checked when all of them are selected, indeterminate when some are. Ticking a parent adds or removes all its leaves at once, and the CTA counts leaves, not rows. + +**Search auto-expands.** A node survives the filter if it matches or if any descendant does. Every ancestor of a match is expanded for the duration of the query, so matches are visible without clicking; manually expanded rows stay expanded once the query clears. + +**State resets on close.** Query, selection, and expansion are cleared when the dialog closes, so reopening starts fresh. + +**Keyboard.** The tree is a `role="tree"` with roving focus: Arrow Up/Down move between visible rows, Arrow Right expands a collapsed parent or steps into it, Arrow Left collapses or steps out to the parent, Space toggles the focused row, and Enter commits — except while typing in the search box. + +## Loading an async catalog + +There is deliberately no async variant: auto-expanding to matches needs the whole tree up front. Fetch once before or on open, and drive `emptyText` from your own loading flag. + +```tsx +const { data, isLoading } = useCatalogQuery(); + + node.data.name} + onCommit={handleCommit} +/>; +``` + +## Pairing with LineItems + +`onCommit` hands back leaf nodes; map them into rows and pass them to `addLines`, which inserts them in a single render and produces one `add` op per line. + +```tsx +const lineItems = useLineItems({ fields, data: lines }); + + node.data.name} + onCommit={(picked) => + lineItems.addLines( + picked.map((node) => ({ + sku: node.data.sku, + quantity: 1, + unitPrice: node.data.price, + })), + ) + } +/>; +``` + +## Related + +- [LineItems](./line-items.md) — the editable document grid this commonly feeds +- [Combobox](./combobox.md) — inline single or multi select for flat option lists +- [Dialog](./dialog.md) — the modal shell this composes diff --git a/docs/components/line-items.md b/docs/components/line-items.md new file mode 100644 index 000000000..a54d861cd --- /dev/null +++ b/docs/components/line-items.md @@ -0,0 +1,214 @@ +--- +title: LineItems +description: Spreadsheet-grade editable table for the lines of a document, with a change-set for submitting only what changed +--- + + + +# LineItems + +`LineItems` edits the rows of a document — purchase orders, invoices, journals, work orders, stock transfers — with spreadsheet ergonomics: inline cell editors, range selection, TSV copy/paste, keyboard navigation, drag reordering, and row virtualization for long documents. + +State lives in the `useLineItems` hook; `LineItems.*` renders it. The component is UI-only — it takes no opinion on transport. `getChangeSet()` returns a transport-agnostic description of what changed, which you map onto your own mutations. + +## Import + +```tsx +import { LineItems, useLineItems, createLineItemHelper } from "@tailor-platform/app-shell"; +``` + +## Basic Usage + +```tsx +type POLine = { + lineRef: string; + sku: string; + quantity: number; + unitPrice: number; +}; + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => l.sku, + editable: ["edit"], + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "quantity", + label: "Qty", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 90, + }), + f.field({ + key: "total", + label: "Total", + render: (l) => `$${((l.quantity ?? 0) * (l.unitPrice ?? 0)).toFixed(2)}`, + width: 120, + }), +]; + +function PurchaseOrderLines({ lines }: { lines: POLine[] }) { + const lineItems = useLineItems({ fields, data: lines, selection: true }); + + return ( + + + + submit(changeSet)} /> + + ); +} +``` + +Every row needs a stable `lineRef` — the server id for a persisted line. Rows that arrive without one are assigned a generated id. + +## Parts + +| Part | Description | +| ------------------------ | --------------------------------------------------------------------------------- | +| `LineItems.Root` | Provides the document to the parts beneath it. Holds no row data of its own. | +| `LineItems.Table` | The editable grid: virtualized rows, pinned columns, cell editors, drag reorder. | +| `LineItems.Search` | Search box bound to the filter. Only fields with a `search` function participate. | +| `LineItems.AddRow` | Appends a line. Renders nothing in view mode. | +| `LineItems.SaveActions` | Save and discard buttons, both disabled while the document is clean. | +| `LineItems.FloatingDock` | Sticky container for the bars below. | +| `LineItems.DirtyBar` | Appears while there are uncommitted changes. Renders nothing when clean. | +| `LineItems.SelectionBar` | Appears while rows are selected. Mirrors DataTable's selection-footer layout. | + +## useLineItems(options) + +| Option | Type | Default | Description | +| --------------- | --------------------- | -------- | ----------------------------------------------------------------- | +| `fields` | `LineItemsField[]` | required | Column schema. Build with `createLineItemHelper().field({…})`. | +| `data` | `readonly T[]` | `[]` | Uncontrolled seed rows, also the dirty-tracking baseline. | +| `lines` | `readonly T[]` | – | Controlled rows. When set, `data` is ignored. | +| `onLinesChange` | `(next: T[]) => void` | – | Called once per mutation with the new rows. | +| `mode` | `"view" \| "edit"` | `"edit"` | `"view"` renders every cell read-only. | +| `ordering` | `"sort" \| "manual"` | `"sort"` | `"manual"` enables drag reordering and emits `reorder` ops. | +| `selection` | `boolean` | `false` | Adds the row selection checkbox column. | + +### Return value + +| Member | Description | +| ------------------------------------ | ------------------------------------------------------------------------------ | +| `lines` / `allLines` | Rows with the search filter applied, and the full document order. | +| `isDirty` | Whether current state diverges from the baseline. | +| `selectedIds` | `lineRef`s of the selected rows. | +| `sortState` | Active sort, or `null` in document order. Always `null` under manual ordering. | +| `toggleSort(columnId)` | Cycles a column ascending, descending, then unsorted. | +| `addLine(data, opts?)` | Inserts one line, returning its new `lineRef`. | +| `addLines(items, opts?)` | Inserts many in one render. Use this for bulk pickers. | +| `removeLine(lineRef)` | Deletes a line. | +| `updateField(lineRef, key, value)` | Typed single-cell update. | +| `updateLines(patches)` | Batched updates — one render for a whole paste or fill. | +| `reorderLine(lineRef, afterLineRef)` | Moves a line. Manual ordering only; pass `null` to move to the head. | +| `bulkUpdate(patch)` / `bulkRemove()` | Apply to every selected row. | +| `reset()` | Snaps the baseline forward. Call after a successful save. | +| `revert()` | Discards every uncommitted change back to the baseline. | +| `getChangeSet()` | What changed, ready to submit. | + +`addLine` and `addLines` take `{ afterLineRef }`: omit it to append, pass `null` to prepend, or pass a `lineRef` to insert directly after that line. + +## Field schema + +| Property | Type | Description | +| ----------- | ---------------------------------------------- | ------------------------------------------------------------------------------- | +| `key` | `string` | Column id, and the property name on `T` for editable fields. | +| `label` | `ReactNode` | Header content. | +| `render` | `(line: T) => ReactNode` | Cell content when no editor is shown. | +| `editable` | `LineItemsMode[]` | Modes that show an editor. Omit for a read-only or computed column. | +| `type` | `LineItemsFieldType` | Required when editable. Drives the editor and equality semantics. | +| `sort` | `{ comparator }` | Makes the header sortable (asc, desc, unsorted). Ignored under manual ordering. | +| `search` | `(line: T, query: string) => boolean` | Opts the column into `LineItems.Search`. | +| `align` | `"left" \| "center" \| "right"` | Defaults to right for numbers, center for booleans. | +| `className` | `string \| ((line: T) => string \| undefined)` | Per-cell classes — useful for flagging discrepancies. | +| `normalize` | `(value, row) => unknown` | Coerce before comparison and before entering the change-set. | +| `equals` | `(a, b, row) => boolean` | Custom dirty-equality. | +| `width` | `number` | Resting width in pixels. | +| `pin` | `"left" \| "right"` | Freezes the column during horizontal scroll. | +| `flex` | `boolean` | Absorbs leftover horizontal space. | + +### Field types + +```tsx +{ kind: "text" } +{ kind: "number", decimals?: number } +{ kind: "select", options: { value, label, description? }[], placeholder?: string } +{ kind: "boolean", trueLabel?: string, falseLabel?: string } +{ kind: "date", min?: string, max?: string } +{ kind: "custom", renderEditor: (ctx) => ReactNode, normalize?, equals? } +``` + +Numeric fields normalize strings to numbers, round by `decimals`, and compare with a small epsilon, so `"1"` and `"1.00"` are not a change. Strings are trimmed. A field's own `normalize` and `equals` win over both. + +## LineItems.Table Props + +| Prop | Type | Default | Description | +| ------------- | --------------------------- | ------- | ------------------------------------------------------------ | +| `rowHeight` | `number` | `36` | Uniform row height, which the virtualizer's math depends on. | +| `maxHeight` | `number \| string` | `480` | Scroll viewport height. | +| `virtualized` | `boolean` | `true` | Set `false` to mount every row. | +| `rowActions` | `(row: T) => ReactNode` | – | Trailing pinned column of per-row controls. | +| `totalsRow` | `(lines: T[]) => ReactNode` | – | Sticky `` row, column-aligned with the grid. | +| `emptyState` | `ReactNode` | – | Shown when the document has no lines. | + +`LineItems.Table` cannot infer `T` from `Root`, so annotate the callback parameters (`rowActions={(row: POLine) => …}`) or pass the type argument explicitly (` … />`). + +## Change-set + +`getChangeSet()` returns `{ isEmpty, lineChanges }`, where each change is one of: + +```tsx +{ action: "add", tempId: string, data: Record } +{ action: "update", lineId: string, patch: Record } +{ action: "remove", lineId: string } +{ action: "reorder", lineId: string, position: number } +``` + +`tempId` is client-only — the server mints the real id on insert. `patch` carries only the fields that actually changed. `reorder` is emitted for manual ordering only, and only for persisted lines whose index moved, so adding or removing a line does not read as a move of everything below it. Read-only and computed columns never appear. + +```tsx + { + if (changeSet.isEmpty) return; + await submitLines(changeSet.lineChanges); + lineItems.reset(); // baseline moves forward; the document is clean again + }} +/> +``` + +## Spreadsheet behavior + +Only editable columns take part in the grid's selection. + +| Interaction | Result | +| ------------------------------ | --------------------------------------------------------------------------------------- | +| Click a cell | Selects it and opens its editor with the value selected | +| Shift-click, or click and drag | Selects the rectangle between the anchor and that cell | +| Arrow keys | Moves the active cell; hold Shift to extend the selection | +| Enter / Tab | Commits and moves down / right (Shift reverses) | +| Escape | Abandons the edit, keeping the previous value | +| Copy | Writes the selected rectangle as TSV | +| Paste | Writes a TSV block from the active cell; a single value broadcasts across the selection | + +Edits commit on blur or Enter, never per keystroke. + +## Sorting + +Under the default `ordering: "sort"`, a field with a `sort.comparator` gets a clickable header that cycles ascending, descending, then back to document order, and carries `aria-sort` throughout. Sorting only reorders what you see: `allLines` stays in document order, sorting never marks the document dirty, and it emits no `reorder` ops. Under `ordering: "manual"` the document owns row order outright, so sort affordances are not rendered. + +## Drag reordering + +Set `ordering: "manual"` to get a grip handle on each row. Reordering uses native HTML5 drag-and-drop, which works with virtualized rows. Because native drag has no keyboard equivalent, the grip also responds to the keyboard: Enter or Space lifts the row, Arrow Up/Down moves it, and Enter or Escape drops it. Touch drag is not supported. + +## Related + +- [DataTable](./data-table.md) — read-oriented collection table with sorting, filtering, and pagination +- [BulkItemPicker](./bulk-item-picker.md) — tree-select dialog that pairs with `addLines` From 8770e3c51506831049de50125d5d9455ac0145a5 Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:12:45 +0530 Subject: [PATCH 03/25] feat(line-items): add useLineItemsGroup for multi-collection documents Ported from #238, with the group change-set nested under changeSets so a member named isEmpty can't overwrite the rolled-up flag. Co-Authored-By: Claude Opus 5.5 --- .changeset/wise-lines-gather.md | 2 + docs-manifest.json | 14 ++- .../components/line-items.docs.outline.md | 29 +++++ docs/components/line-items.md | 29 +++++ .../core/src/components/line-items/index.ts | 6 + .../line-items/use-line-items-group.test.ts | 110 ++++++++++++++++++ .../line-items/use-line-items-group.ts | 76 ++++++++++++ packages/core/src/index.ts | 4 + 8 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 packages/core/src/components/line-items/use-line-items-group.test.ts create mode 100644 packages/core/src/components/line-items/use-line-items-group.ts diff --git a/.changeset/wise-lines-gather.md b/.changeset/wise-lines-gather.md index 23c504844..2288fe69a 100644 --- a/.changeset/wise-lines-gather.md +++ b/.changeset/wise-lines-gather.md @@ -45,4 +45,6 @@ function Lines({ lines }: { lines: POLine[] }) { } ``` +For documents with more than one list of lines (journal debits and credits, work-order components and operations), `useLineItemsGroup({ debits, credits })` puts several `useLineItems` collections under one save boundary: a shared `isDirty`, one keyed `getChangeSet()`, and group-wide `reset()` / `revert()`. + Includes view and edit modes, inline cell editors that commit on blur or Enter, range selection with TSV copy/paste, pinned columns, a sticky totals row, row selection with bulk actions, row virtualization for long documents, and drag reordering with a keyboard fallback on the grip handle. diff --git a/docs-manifest.json b/docs-manifest.json index fcd32cee3..d1c7bc690 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -1019,6 +1019,8 @@ "LineItemsField", "LineItemsFieldCommit", "LineItemsFieldType", + "LineItemsGroupChangeSet", + "LineItemsGroupMembers", "LineItemsLineChange", "LineItemsMetadataCommit", "LineItemsMode", @@ -1028,17 +1030,19 @@ "LineItemsSelectOption", "LineItemsSortState", "LineItemsTableProps", + "UseLineItemsGroupReturn", "UseLineItemsOptions", "UseLineItemsReturn", "createLineItemHelper", "lineItemsFloatingBarStyles", - "useLineItems" + "useLineItems", + "useLineItemsGroup" ], "hashes": { - "typeSurface": "d9ec449a4cf030d8", - "outline": "041b094ffbf70772", + "typeSurface": "631d6823569171c0", + "outline": "48c04756f0bab926", "snapshot": "7bda9a2537aa6263", - "outputMd": "6516099e63d212b2", + "outputMd": "10befa3561ca3531", "examples": null } }, @@ -1744,7 +1748,7 @@ "packages/core/skills/app-shell-patterns/references/components/grid.md": "9051eb53dde4179f", "packages/core/skills/app-shell-patterns/references/components/input.md": "a3a6c9679af99d37", "packages/core/skills/app-shell-patterns/references/components/layout.md": "66696139a46fabb0", - "packages/core/skills/app-shell-patterns/references/components/line-items.md": "6516099e63d212b2", + "packages/core/skills/app-shell-patterns/references/components/line-items.md": "10befa3561ca3531", "packages/core/skills/app-shell-patterns/references/components/menu.md": "ceb4a05822538d20", "packages/core/skills/app-shell-patterns/references/components/metric-card.md": "5a1c557768c6590f", "packages/core/skills/app-shell-patterns/references/components/select.md": "ae287e2bb5abc1c6", diff --git a/docs-src/components/line-items.docs.outline.md b/docs-src/components/line-items.docs.outline.md index 044fb622d..501603c8d 100644 --- a/docs-src/components/line-items.docs.outline.md +++ b/docs-src/components/line-items.docs.outline.md @@ -186,6 +186,35 @@ Numeric fields normalize strings to numbers, round by `decimals`, and compare wi /> ``` +## Multiple collections: useLineItemsGroup + +Some documents own more than one list of lines — a Journal Entry with debits and credits, a Work Order with components and operations. Give each list its own `useLineItems` and `LineItems.Root`, then put them under one save boundary with `useLineItemsGroup`. Members can have different row types. + +```tsx +import { useLineItems, useLineItemsGroup } from "@tailor-platform/app-shell"; + +const debits = useLineItems({ fields, data: entry.debits }); +const credits = useLineItems({ fields, data: entry.credits }); +const group = useLineItemsGroup({ debits, credits }); + +const onSave = async () => { + const { isEmpty, changeSets } = group.getChangeSet(); + if (isEmpty) return; + await saveJournalEntry(entry.id, changeSets.debits.lineChanges, changeSets.credits.lineChanges); + group.reset(); +}; +``` + +| Return | Description | +| -------------- | ----------------------------------------------------------------------------------- | +| `members` | The hooks passed in, for forwarding into each `LineItems.Root` | +| `isDirty` | True when any member has uncommitted changes | +| `getChangeSet` | Returns `{ isEmpty, changeSets }`; `changeSets` is keyed by the names you passed in | +| `reset` | Snaps every member's baseline forward — call after a successful save | +| `revert` | Discards every member's changes — call on Discard | + +Each member keeps its own selection, search and dirty tracking. The group only adds the shared `isDirty` and the single Save / Discard. + ## Spreadsheet behavior Only editable columns take part in the grid's selection. diff --git a/docs/components/line-items.md b/docs/components/line-items.md index a54d861cd..4c74e701f 100644 --- a/docs/components/line-items.md +++ b/docs/components/line-items.md @@ -184,6 +184,35 @@ Numeric fields normalize strings to numbers, round by `decimals`, and compare wi /> ``` +## Multiple collections: useLineItemsGroup + +Some documents own more than one list of lines — a Journal Entry with debits and credits, a Work Order with components and operations. Give each list its own `useLineItems` and `LineItems.Root`, then put them under one save boundary with `useLineItemsGroup`. Members can have different row types. + +```tsx +import { useLineItems, useLineItemsGroup } from "@tailor-platform/app-shell"; + +const debits = useLineItems({ fields, data: entry.debits }); +const credits = useLineItems({ fields, data: entry.credits }); +const group = useLineItemsGroup({ debits, credits }); + +const onSave = async () => { + const { isEmpty, changeSets } = group.getChangeSet(); + if (isEmpty) return; + await saveJournalEntry(entry.id, changeSets.debits.lineChanges, changeSets.credits.lineChanges); + group.reset(); +}; +``` + +| Return | Description | +| -------------- | ----------------------------------------------------------------------------------- | +| `members` | The hooks passed in, for forwarding into each `LineItems.Root` | +| `isDirty` | True when any member has uncommitted changes | +| `getChangeSet` | Returns `{ isEmpty, changeSets }`; `changeSets` is keyed by the names you passed in | +| `reset` | Snaps every member's baseline forward — call after a successful save | +| `revert` | Discards every member's changes — call on Discard | + +Each member keeps its own selection, search and dirty tracking. The group only adds the shared `isDirty` and the single Save / Discard. + ## Spreadsheet behavior Only editable columns take part in the grid's selection. diff --git a/packages/core/src/components/line-items/index.ts b/packages/core/src/components/line-items/index.ts index f6d345d04..e55a64274 100644 --- a/packages/core/src/components/line-items/index.ts +++ b/packages/core/src/components/line-items/index.ts @@ -1,5 +1,11 @@ export { LineItems, type LineItemsRootProps } from "./line-items"; export { useLineItems } from "./use-line-items"; +export { + useLineItemsGroup, + type LineItemsGroupChangeSet, + type LineItemsGroupMembers, + type UseLineItemsGroupReturn, +} from "./use-line-items-group"; export { createLineItemHelper } from "./field"; export { lineItemsFloatingBarStyles } from "./line-items-parts"; export type { LineItemsTableProps } from "./line-items-table"; diff --git a/packages/core/src/components/line-items/use-line-items-group.test.ts b/packages/core/src/components/line-items/use-line-items-group.test.ts new file mode 100644 index 000000000..5b7c7e4fe --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items-group.test.ts @@ -0,0 +1,110 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createLineItemHelper } from "./field"; +import type { LineItemsField } from "./types"; +import { useLineItems } from "./use-line-items"; +import { useLineItemsGroup } from "./use-line-items-group"; + +type JournalLine = { lineRef: string; account: string; amount: number }; + +const f = createLineItemHelper(); + +const fields: LineItemsField[] = [ + f.field({ + key: "account", + label: "Account", + render: (l) => l.account, + editable: ["edit"], + type: { kind: "text" }, + }), + f.field({ + key: "amount", + label: "Amount", + render: (l) => l.amount, + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + }), +]; + +function setup() { + return renderHook(() => { + const debits = useLineItems({ + fields, + data: [{ lineRef: "d1", account: "Cash", amount: 100 }], + }); + const credits = useLineItems({ + fields, + data: [{ lineRef: "c1", account: "Receivables", amount: 100 }], + }); + return { debits, credits, group: useLineItemsGroup({ debits, credits }) }; + }); +} + +describe("useLineItemsGroup", () => { + it("is dirty when any member is dirty", () => { + const { result } = setup(); + expect(result.current.group.isDirty).toBe(false); + + act(() => result.current.debits.updateField("d1", "amount", 200)); + expect(result.current.group.isDirty).toBe(true); + }); + + it("returns each member's change-set keyed by name, with isEmpty rolled up", () => { + const { result } = setup(); + let cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(true); + expect(cs.changeSets.debits.lineChanges).toEqual([]); + expect(cs.changeSets.credits.lineChanges).toEqual([]); + + act(() => result.current.credits.updateField("c1", "amount", 250)); + cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(false); + expect(cs.changeSets.debits.isEmpty).toBe(true); + expect(cs.changeSets.credits.lineChanges).toEqual([ + { action: "update", lineId: "c1", patch: { amount: 250 } }, + ]); + }); + + it("does not let a member named isEmpty clobber the rolled-up flag", () => { + const { result } = renderHook(() => { + const isEmpty = useLineItems({ + fields, + data: [{ lineRef: "x1", account: "Cash", amount: 1 }], + }); + return { isEmpty, group: useLineItemsGroup({ isEmpty }) }; + }); + act(() => result.current.isEmpty.updateField("x1", "amount", 2)); + + const cs = result.current.group.getChangeSet(); + expect(cs.isEmpty).toBe(false); + expect(cs.changeSets.isEmpty.isEmpty).toBe(false); + }); + + it("revert() rolls back every member", () => { + const { result } = setup(); + act(() => { + result.current.debits.updateField("d1", "amount", 999); + result.current.credits.updateField("c1", "amount", 999); + }); + expect(result.current.group.isDirty).toBe(true); + + act(() => result.current.group.revert()); + expect(result.current.group.isDirty).toBe(false); + expect(result.current.debits.allLines[0]!.amount).toBe(100); + expect(result.current.credits.allLines[0]!.amount).toBe(100); + }); + + it("reset() snaps every member's baseline forward", () => { + const { result } = setup(); + act(() => { + result.current.debits.updateField("d1", "amount", 300); + result.current.credits.updateField("c1", "amount", 300); + }); + + act(() => result.current.group.reset()); + expect(result.current.group.isDirty).toBe(false); + expect(result.current.group.getChangeSet().isEmpty).toBe(true); + expect(result.current.debits.allLines[0]!.amount).toBe(300); + }); +}); diff --git a/packages/core/src/components/line-items/use-line-items-group.ts b/packages/core/src/components/line-items/use-line-items-group.ts new file mode 100644 index 000000000..8b5eeda79 --- /dev/null +++ b/packages/core/src/components/line-items/use-line-items-group.ts @@ -0,0 +1,76 @@ +import type { LineItemsChangeSet, UseLineItemsReturn } from "./types"; + +/** + * `useLineItems` returns keyed by collection name. The row type is erased so + * one document can mix collections with different row shapes (component lines + * and operation lines under one Work Order). + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- heterogeneous row types per member +export type LineItemsGroupMembers = Record>; + +/** The group's change-set: one entry per member, plus a rolled-up `isEmpty`. */ +export type LineItemsGroupChangeSet = { + /** True when every member's change-set is empty. */ + isEmpty: boolean; + /** Each member's change-set, keyed by the names passed to `useLineItemsGroup`. */ + changeSets: { [K in keyof G]: LineItemsChangeSet }; +}; + +export type UseLineItemsGroupReturn = { + /** The members passed in, for forwarding into each `LineItems.Root`. */ + members: G; + /** True when any member has uncommitted changes. */ + isDirty: boolean; + /** Collects every member's change-set for one document-level submit. */ + getChangeSet: () => LineItemsGroupChangeSet; + /** Snaps every member's baseline forward. Call after a successful save. */ + reset: () => void; + /** Discards every member's changes back to its baseline. */ + revert: () => void; +}; + +/** + * Puts several `useLineItems` collections under one save boundary — for a + * document that owns more than one list of lines, like a Journal Entry with + * debits and credits, or a Work Order with components and operations. Each + * collection keeps its own table, selection and dirty tracking; the group adds + * a shared `isDirty`, a keyed change-set, and one Save / Discard. + * + * @example + * ```tsx + * const debits = useLineItems({ fields, data: entry.debits }); + * const credits = useLineItems({ fields, data: entry.credits }); + * const group = useLineItemsGroup({ debits, credits }); + * + * const onSave = async () => { + * const { isEmpty, changeSets } = group.getChangeSet(); + * if (isEmpty) return; + * await saveJournalEntry(entry.id, changeSets.debits, changeSets.credits); + * group.reset(); + * }; + * ``` + */ +export function useLineItemsGroup( + members: G, +): UseLineItemsGroupReturn { + const all = Object.values(members); + + const getChangeSet = (): LineItemsGroupChangeSet => { + const changeSets = {} as { [K in keyof G]: LineItemsChangeSet }; + for (const key of Object.keys(members) as (keyof G)[]) { + changeSets[key] = members[key].getChangeSet(); + } + return { + isEmpty: Object.values(changeSets).every((cs) => cs.isEmpty), + changeSets, + }; + }; + + return { + members, + isDirty: all.some((m) => m.isDirty), + getChangeSet, + reset: () => all.forEach((m) => m.reset()), + revert: () => all.forEach((m) => m.revert()), + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c96008e25..74e5be041 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -172,6 +172,10 @@ export { DescriptionCard, type DescriptionCardProps } from "./components/descrip export { LineItems, useLineItems, + useLineItemsGroup, + type LineItemsGroupChangeSet, + type LineItemsGroupMembers, + type UseLineItemsGroupReturn, createLineItemHelper, lineItemsFloatingBarStyles, type LineItemsChangeSet, From ab596d6787d3d5aad0a631fbc5c9a2e260d5d6cf Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:20:55 +0530 Subject: [PATCH 04/25] test(line-items): cover Enter commit in the last row Co-Authored-By: Claude Opus 5.5 --- .../components/line-items/line-items.test.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/core/src/components/line-items/line-items.test.tsx b/packages/core/src/components/line-items/line-items.test.tsx index 16bc5807d..e60e05c94 100644 --- a/packages/core/src/components/line-items/line-items.test.tsx +++ b/packages/core/src/components/line-items/line-items.test.tsx @@ -298,6 +298,25 @@ describe("LineItems", () => { expect(api?.allLines[0]?.quantity).toBe(42); }); + it("commits on Enter in the last row, where focus has nowhere to move", async () => { + const user = userEvent.setup(); + let api: ReturnType> | undefined; + render( + { + api = a; + }} + />, + ); + + const editor = editorIn("3", "quantity"); + await user.clear(editor); + await user.type(editor, "42{Enter}"); + + expect(api?.allLines[2]?.quantity).toBe(42); + expect(document.activeElement).toBe(editor); + }); + it("commits on Tab", async () => { const user = userEvent.setup(); let api: ReturnType> | undefined; From 46b6fc5e2b5369c9dabd5ebaec0420b93f7b6dc1 Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:20:58 +0530 Subject: [PATCH 05/25] docs(examples): add LineItems PO and Journal Entry showcase pages Co-Authored-By: Claude Opus 5.5 --- examples/vite-app/src/App.tsx | 2 + .../showcase/line-items-journal/page.tsx | 194 ++++++++ .../src/pages/showcase/line-items/page.tsx | 456 ++++++++++++++++++ examples/vite-app/src/routes.generated.ts | 2 + 4 files changed, 654 insertions(+) create mode 100644 examples/vite-app/src/pages/showcase/line-items-journal/page.tsx create mode 100644 examples/vite-app/src/pages/showcase/line-items/page.tsx diff --git a/examples/vite-app/src/App.tsx b/examples/vite-app/src/App.tsx index d57e21936..a32534d84 100644 --- a/examples/vite-app/src/App.tsx +++ b/examples/vite-app/src/App.tsx @@ -79,6 +79,8 @@ const AppInner = () => { + + diff --git a/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx b/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx new file mode 100644 index 000000000..56aa00c11 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx @@ -0,0 +1,194 @@ +import { + Badge, + Button, + Card, + createLineItemHelper, + Layout, + LineItems, + useLineItems, + useLineItemsGroup, + type AppShellPageProps, + type LineItemsGroupChangeSet, + type UseLineItemsReturn, +} from "@tailor-platform/app-shell"; +import { useState } from "react"; + +// 🧪 Dummy Data: Replace with real data later +type JournalLine = { + lineRef: string; + account: string; + memo: string; + amount: number; +}; + +const ACCOUNT_OPTIONS = [ + { value: "1000", label: "1000 · Cash" }, + { value: "1200", label: "1200 · Accounts Receivable" }, + { value: "2000", label: "2000 · Accounts Payable" }, + { value: "4000", label: "4000 · Sales Revenue" }, + { value: "5000", label: "5000 · Cost of Goods Sold" }, + { value: "6100", label: "6100 · Freight Expense" }, +]; + +// 🧪 Dummy Data: Replace with real data later +const INITIAL_DEBITS: JournalLine[] = [ + { lineRef: "d1", account: "1000", memo: "Customer payment INV-1042", amount: 1250 }, + { lineRef: "d2", account: "6100", memo: "Inbound freight PO-2231", amount: 180 }, +]; + +// 🧪 Dummy Data: Replace with real data later +const INITIAL_CREDITS: JournalLine[] = [ + { lineRef: "c1", account: "1200", memo: "Clear receivable INV-1042", amount: 1250 }, + { lineRef: "c2", account: "2000", memo: "Carrier bill PO-2231", amount: 180 }, +]; + +const money = (n: number) => + n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +const sum = (lines: JournalLine[]) => lines.reduce((s, l) => s + (l.amount ?? 0), 0); + +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "account", + label: "Account", + render: (l) => ACCOUNT_OPTIONS.find((o) => o.value === l.account)?.label ?? l.account, + editable: ["edit"], + type: { kind: "select", options: ACCOUNT_OPTIONS, placeholder: "Pick an account" }, + width: 260, + }), + f.field({ + key: "memo", + label: "Memo", + render: (l) => l.memo, + editable: ["edit"], + type: { kind: "text" }, + flex: true, + }), + f.field({ + key: "amount", + label: "Amount", + render: (l) => money(l.amount ?? 0), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + align: "right", + width: 140, + }), +]; + +const emptyLine = { account: "", memo: "", amount: 0 }; + +// ✅ Reusable Component: Can be moved to components/ui/ +const JournalSide = ({ + title, + value, +}: { + title: string; + value: UseLineItemsReturn; +}) => ( + + + +
+ +
+
+ + ( + + )} + /> + + {(lines: JournalLine[]) => ({ memo: "Total", amount: money(sum(lines)) })} + + +
+
+); + +const LineItemsJournalDemoPage = () => { + const [lastSaved, setLastSaved] = useState; + credits: UseLineItemsReturn; + }> | null>(null); + + const debits = useLineItems({ fields, data: INITIAL_DEBITS }); + const credits = useLineItems({ fields, data: INITIAL_CREDITS }); + const group = useLineItemsGroup({ debits, credits }); + + const difference = sum(debits.allLines) - sum(credits.allLines); + const balanced = Math.abs(difference) < 0.005; + + const onSave = () => { + const changeSet = group.getChangeSet(); + if (changeSet.isEmpty || !balanced) return; + setLastSaved(changeSet); + group.reset(); + }; + + return ( + + + {/* 🔽 Document header: one Save / Discard for both collections */} +
+
+ JE-2026-0187 + {balanced ? ( + Balanced + ) : ( + {`Out of balance by ${money(difference)}`} + )} + {group.isDirty && Unsaved changes} +
+
+ + +
+
+ + {/* 🔽 Debits */} + + + {/* 🔽 Credits */} + + + {/* 🔽 Last submitted change-set */} + {lastSaved && ( + + + +
{JSON.stringify(lastSaved, null, 2)}
+
+
+ )} +
+
+ ); +}; + +LineItemsJournalDemoPage.appShellPageProps = { + meta: { + title: "Line Items: Journal Entry", + }, +} satisfies AppShellPageProps; + +export default LineItemsJournalDemoPage; diff --git a/examples/vite-app/src/pages/showcase/line-items/page.tsx b/examples/vite-app/src/pages/showcase/line-items/page.tsx new file mode 100644 index 000000000..d2b433818 --- /dev/null +++ b/examples/vite-app/src/pages/showcase/line-items/page.tsx @@ -0,0 +1,456 @@ +import { + BulkItemPicker, + Button, + Card, + Combobox, + createLineItemHelper, + Layout, + LineItems, + useLineItems, + type AppShellPageProps, + type BulkItemPickerNode, + type LineItemsChangeSet, + type LineItemsMode, +} from "@tailor-platform/app-shell"; +import { ExternalLink, Upload, X } from "lucide-react"; +import { useState } from "react"; + +// 🧪 Dummy Data: Replace with real data later +type PurchaseOrderLine = { + lineRef: string; + sku: string; + description: string; + uom: string; + quantity: number; + unitPrice: number; + receivedQty: number; + expected: string; + note: string; +}; + +// No descriptions here on purpose: the select cell's input shows label plus +// description (so SKU cells are searchable by product name), which would read +// as "Case 12 units per case" for a unit of measure. +const UOM_OPTIONS = [ + { value: "EA", label: "Each" }, + { value: "CS", label: "Case" }, + { value: "PLT", label: "Pallet" }, +]; + +// 🧪 Dummy Data: Replace with real data later — the catalog both pickers browse +type CatalogItem = { sku: string; name: string; price: number }; + +const CATALOG: CatalogItem[] = [ + { sku: "DT-TEE-WHT-S", name: "Cotton Wreath Tee, White, Small", price: 18.5 }, + { sku: "DT-TEE-WHT-M", name: "Cotton Wreath Tee, White, Medium", price: 18.5 }, + { sku: "DT-TEE-GRY-XL", name: "Cotton Wreath Tee, Grey, XL", price: 19.75 }, + { sku: "DT-HOOD-BLK-M", name: "Heavyweight Hoodie, Black, Medium", price: 62 }, + { sku: "DT-JKT-DNM-L", name: "Denim Chore Jacket, Indigo, Large", price: 148 }, + { sku: "DT-CAP-BLK-OS", name: "Embroidered Cap, Black", price: 14.25 }, + { sku: "DT-TOTE-NAT-OS", name: "Canvas Tote, Natural", price: 22 }, +]; + +const CATALOG_TREE: BulkItemPickerNode[] = [ + { + id: "cat-tees", + data: { sku: "TEES", name: "T-Shirts", price: 0 }, + children: CATALOG.filter((c) => c.sku.includes("TEE")).map((c) => ({ id: c.sku, data: c })), + }, + { + id: "cat-outerwear", + data: { sku: "OUTER", name: "Outerwear", price: 0 }, + children: CATALOG.filter((c) => c.sku.includes("HOOD") || c.sku.includes("JKT")).map((c) => ({ + id: c.sku, + data: c, + })), + }, + { + id: "cat-accessories", + data: { sku: "ACC", name: "Accessories", price: 0 }, + children: CATALOG.filter((c) => c.sku.includes("CAP") || c.sku.includes("TOTE")).map((c) => ({ + id: c.sku, + data: c, + })), + }, +]; + +// 🧪 Dummy Data: Replace with real data later +const INITIAL_LINES: PurchaseOrderLine[] = [ + { + lineRef: "pol-1001", + sku: "DT-TEE-BLK-M", + description: "Cotton Wreath Tee, Black, Medium", + uom: "CS", + quantity: 24, + unitPrice: 18.5, + receivedQty: 24, + expected: "2026-04-30", + note: "", + }, + { + lineRef: "pol-1002", + sku: "DT-TEE-BLK-L", + description: "Cotton Wreath Tee, Black, Large", + uom: "CS", + quantity: 18, + unitPrice: 18.5, + receivedQty: 12, + expected: "2026-05-01", + note: "Short shipped", + }, + { + lineRef: "pol-1003", + sku: "DT-HOOD-CRM-L", + description: "Heavyweight Hoodie, Cream, Large", + uom: "EA", + quantity: 40, + unitPrice: 62, + receivedQty: 40, + expected: "2026-05-02", + note: "", + }, + { + lineRef: "pol-1004", + sku: "DT-CAP-NVY-OS", + description: "Embroidered Cap, Navy, One Size", + uom: "EA", + quantity: 120, + unitPrice: 14.25, + receivedQty: 96, + expected: "2026-05-03", + note: "", + }, + { + lineRef: "pol-1005", + sku: "DT-SOCK-WHT-OS", + description: "Crew Socks, White, One Size", + uom: "PLT", + quantity: 6, + unitPrice: 210, + receivedQty: 6, + expected: "2026-05-04", + note: "", + }, +]; + +const money = (n: number) => `$${(n ?? 0).toFixed(2)}`; +const lineTotal = (l: PurchaseOrderLine) => (l.quantity ?? 0) * (l.unitPrice ?? 0); + +// ✅ Reusable Component: Can be moved to components/ui/ +const f = createLineItemHelper(); + +const fields = [ + f.field({ + key: "sku", + label: "SKU", + render: (l) => {l.sku}, + editable: ["edit"], + type: { + kind: "select", + options: CATALOG.map((c) => ({ value: c.sku, label: c.sku, description: c.name })), + placeholder: "Pick a product", + }, + width: 200, + // Hover the column to read the full SKU + product without truncation. + hoverExpandWidth: 320, + pin: "left", + search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), + sort: { comparator: (a, b) => a.sku.localeCompare(b.sku) }, + }), + f.field({ + key: "description", + label: "Product", + render: (l) => l.description ?? "", + editable: ["edit"], + type: { kind: "text" }, + flex: true, + search: (l, q) => (l.description ?? "").toLowerCase().includes(q.toLowerCase()), + }), + f.field({ + key: "uom", + label: "UoM", + render: (l) => UOM_OPTIONS.find((o) => o.value === l.uom)?.label ?? l.uom ?? "", + editable: ["edit"], + type: { kind: "select", options: UOM_OPTIONS, placeholder: "Unit" }, + width: 120, + }), + f.field({ + key: "quantity", + label: "Ordered", + render: (l) => l.quantity ?? 0, + editable: ["edit"], + type: { kind: "number", decimals: 0 }, + width: 100, + sort: { comparator: (a, b) => (a.quantity ?? 0) - (b.quantity ?? 0) }, + }), + f.field({ + key: "receivedQty", + label: "Received", + // Still correctable after the PO is confirmed, so it opts into amend. + editable: ["edit", "amend"], + render: (l) => l.receivedQty ?? 0, + type: { kind: "number", decimals: 0 }, + width: 100, + className: (l) => + (l.receivedQty ?? 0) !== (l.quantity ?? 0) ? "bg-destructive/10 text-destructive" : undefined, + }), + f.field({ + key: "unitPrice", + label: "Unit price", + render: (l) => money(l.unitPrice), + editable: ["edit"], + type: { kind: "number", decimals: 2 }, + width: 120, + }), + f.field({ + key: "expected", + label: "Expected", + render: (l) => l.expected ?? "", + editable: ["edit"], + type: { kind: "date" }, + width: 150, + }), + f.field({ + key: "note", + label: "Note", + // 🧪 Notes save out-of-band, so they never enter the document change-set. + render: (l) => l.note ?? "", + editable: ["edit", "amend"], + commit: "metadata", + type: { kind: "text" }, + width: 160, + }), + f.field({ + key: "lineTotal", + label: "Total", + render: (l) => {money(lineTotal(l))}, + width: 120, + align: "right", + pin: "right", + }), +]; + +const MODES: LineItemsMode[] = ["edit", "view", "amend"]; + +const LineItemsDemoPage = () => { + const [mode, setMode] = useState("edit"); + const [ordering, setOrdering] = useState<"sort" | "manual">("manual"); + const [pickerOpen, setPickerOpen] = useState(false); + const [lastSaved, setLastSaved] = useState(null); + const [metadataLog, setMetadataLog] = useState([]); + + const lineItems = useLineItems({ + fields, + data: INITIAL_LINES, + mode, + ordering, + selection: true, + onMetadataCommit: (event) => + setMetadataLog((log) => + [`${event.lineRef}: note → ${JSON.stringify(event.patch.note)}`, ...log].slice(0, 5), + ), + }); + + const addFromCatalog = (item: CatalogItem) => + lineItems.addLine({ + sku: item.sku, + description: item.name, + uom: "EA", + quantity: 1, + unitPrice: item.price, + receivedQty: 0, + expected: "", + note: "", + }); + + return ( + + + {/* 🔽 Demo controls */} +
+ {MODES.map((m) => ( + + ))} + +
+ + + {/* 🔽 Line items section */} + + +
+ + + + +
+
+ + + ( + <> + + + + )} + /> + + + {(lines: PurchaseOrderLine[]) => ({ + description: `${lines.length} lines`, + quantity: lines.reduce((s, l) => s + (l.quantity ?? 0), 0), + receivedQty: lines.reduce((s, l) => s + (l.receivedQty ?? 0), 0), + lineTotal: money(lines.reduce((s, l) => s + lineTotal(l), 0)), + })} + + + {/* 🔽 Add a line from the bottom of the table */} + + ({ label: `${item.sku} ${item.name}`, key: item.sku })} + onValueChange={(item) => { + if (item) addFromCatalog(item); + }} + /> + + + +
+ + + + {({ selectedIds, bulkRemove }) => ( + + )} + + setLastSaved(cs)} /> + +
+ + {/* 🔽 Change-set inspector — what a real submit would send */} + {lastSaved ? ( + + + +
+                {JSON.stringify(lastSaved, null, 2)}
+              
+
+
+ ) : null} + + {/* 🔽 Out-of-band note commits, which bypass the change-set */} + {metadataLog.length > 0 ? ( + + + +
    + {metadataLog.map((entry, i) => ( +
  • {entry}
  • + ))} +
+
+
+ ) : null} + + + open={pickerOpen} + onOpenChange={setPickerOpen} + title="Bulk line item picker" + items={CATALOG_TREE} + rowLabel="Product name" + metricLabel="Unit price" + renderRow={(node) => ( + + {node.data.name} + {node.children ? null : ( + {node.data.sku} + )} + + )} + renderMetric={(node) => (node.children ? null : money(node.data.price))} + matchesSearch={(node, q) => + `${node.data.sku} ${node.data.name}`.toLowerCase().includes(q.toLowerCase()) + } + ctaLabel={(n) => (n === 0 ? "Add items" : `Add ${n} item${n === 1 ? "" : "s"}`)} + onCommit={(picked) => { + lineItems.addLines( + picked.map((node) => ({ + sku: node.data.sku, + description: node.data.name, + uom: "EA", + quantity: 1, + unitPrice: node.data.price, + receivedQty: 0, + expected: "", + note: "", + })), + ); + }} + /> +
+
+ ); +}; + +LineItemsDemoPage.appShellPageProps = { + meta: { + title: "Line Items", + }, +} satisfies AppShellPageProps; + +export default LineItemsDemoPage; diff --git a/examples/vite-app/src/routes.generated.ts b/examples/vite-app/src/routes.generated.ts index e29ab4a89..fbd1093be 100644 --- a/examples/vite-app/src/routes.generated.ts +++ b/examples/vite-app/src/routes.generated.ts @@ -40,6 +40,8 @@ export type GeneratedRouteParams = { "/showcase/forms/zod-rhf": {}; "/showcase/grid": {}; "/showcase/layouts": {}; + "/showcase/line-items": {}; + "/showcase/line-items-journal": {}; "/showcase/metric-card": {}; "/showcase/primitives": {}; "/showcase/spinner": {}; From 6bf953ddbab18fffcd706deac86de04eb05d9bfe Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:27:35 +0530 Subject: [PATCH 06/25] fix(line-items): pin and fill every totals-row cell so scrolled content can't show through Demo: right-align the toolbar, match Bulk add to the combobox height, drop hover-expand on the product column. Co-Authored-By: Claude Opus 5.5 --- .../showcase/line-items-journal/page.tsx | 2 +- .../src/pages/showcase/line-items/page.tsx | 6 ++--- .../line-items/line-items-table.tsx | 27 +++++++++++++++---- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx b/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx index 56aa00c11..5c17d78bd 100644 --- a/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx +++ b/examples/vite-app/src/pages/showcase/line-items-journal/page.tsx @@ -93,7 +93,7 @@ const JournalSide = ({ title={title} description={`${value.allLines.length} lines · ${money(sum(value.allLines))}`} > -
+
diff --git a/examples/vite-app/src/pages/showcase/line-items/page.tsx b/examples/vite-app/src/pages/showcase/line-items/page.tsx index d2b433818..bdf1415e1 100644 --- a/examples/vite-app/src/pages/showcase/line-items/page.tsx +++ b/examples/vite-app/src/pages/showcase/line-items/page.tsx @@ -151,8 +151,6 @@ const fields = [ placeholder: "Pick a product", }, width: 200, - // Hover the column to read the full SKU + product without truncation. - hoverExpandWidth: 320, pin: "left", search: (l, q) => l.sku.toLowerCase().includes(q.toLowerCase()), sort: { comparator: (a, b) => a.sku.localeCompare(b.sku) }, @@ -296,7 +294,7 @@ const LineItemsDemoPage = () => { lineItems.isDirty ? "Unsaved changes" : "All saved" }`} > -
+
diff --git a/packages/core/src/components/line-items/line-items-table.tsx b/packages/core/src/components/line-items/line-items-table.tsx index 8a212a88b..3c4689710 100644 --- a/packages/core/src/components/line-items/line-items-table.tsx +++ b/packages/core/src/components/line-items/line-items-table.tsx @@ -994,14 +994,24 @@ export function LineItemsTable({ totalsRow(lines) ) : ( - {showSelection ? : null} - {canReorder ? : null} + {showSelection ? ( + + ) : null} + {canReorder ? ( + + ) : null} {layout.columns.map((c) => ( ({ {totalsRenderer?.(lines as LineItemsRowData[])[c.id] ?? null} ))} - {rowActions ? : null} - {layout.needsSpacer ? : null} + {rowActions ? ( + + ) : null} + {layout.needsSpacer ? ( + + ) : null} )} From 2e8fb7d206b3c53a80ff372f57ad83017e7248ae Mon Sep 17 00:00:00 2001 From: itsprade Date: Mon, 12 Oct 2026 13:35:26 +0530 Subject: [PATCH 07/25] fix(line-items): ellipsis truncation and header column dividers Demo: add a 1,000-line dataset toggle to the PO page. Co-Authored-By: Claude Opus 5.5 --- .../src/pages/showcase/line-items/page.tsx | 59 ++++++++++++++++++- ...ents__line-items__line-items.test.tsx.snap | 8 +-- .../components/line-items/line-items-cell.tsx | 4 +- .../line-items/line-items-table.tsx | 8 +-- 4 files changed, 69 insertions(+), 10 deletions(-) diff --git a/examples/vite-app/src/pages/showcase/line-items/page.tsx b/examples/vite-app/src/pages/showcase/line-items/page.tsx index bdf1415e1..73a0e392f 100644 --- a/examples/vite-app/src/pages/showcase/line-items/page.tsx +++ b/examples/vite-app/src/pages/showcase/line-items/page.tsx @@ -230,7 +230,61 @@ const fields = [ const MODES: LineItemsMode[] = ["edit", "view", "amend"]; +// 🧪 Dummy Data: Replace with real data later — a long PO for checking scroll, +// virtualization and pinned columns at volume. +const LARGE_LINE_COUNT = 1000; +const NOTES = [ + "", + "", + "", + "Short shipped", + "Backordered until next week", + "Damaged carton on arrival", +]; + +function generateLines(count: number): PurchaseOrderLine[] { + return Array.from({ length: count }, (_, i) => { + const item = CATALOG[i % CATALOG.length]!; + const quantity = 1 + ((i * 37) % 240); + const day = 1 + (i % 28); + return { + lineRef: `pol-${2000 + i}`, + sku: item.sku, + description: item.name, + uom: UOM_OPTIONS[i % UOM_OPTIONS.length]!.value, + quantity, + unitPrice: item.price, + receivedQty: i % 5 === 0 ? Math.floor(quantity * 0.75) : quantity, + expected: `2026-06-${String(day).padStart(2, "0")}`, + note: NOTES[i % NOTES.length]!, + }; + }); +} + +const LARGE_LINES = generateLines(LARGE_LINE_COUNT); + const LineItemsDemoPage = () => { + const [large, setLarge] = useState(false); + // Uncontrolled `data` only seeds the hook, so swapping datasets remounts it. + return ( + setLarge((v) => !v)} + /> + ); +}; + +const PurchaseOrderDemo = ({ + initialLines, + large, + onToggleLarge, +}: { + initialLines: PurchaseOrderLine[]; + large: boolean; + onToggleLarge: () => void; +}) => { const [mode, setMode] = useState("edit"); const [ordering, setOrdering] = useState<"sort" | "manual">("manual"); const [pickerOpen, setPickerOpen] = useState(false); @@ -239,7 +293,7 @@ const LineItemsDemoPage = () => { const lineItems = useLineItems({ fields, - data: INITIAL_LINES, + data: initialLines, mode, ordering, selection: true, @@ -283,6 +337,9 @@ const LineItemsDemoPage = () => { > {ordering === "manual" ? "Manual order" : "Sorted"} +
diff --git a/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap b/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap index cdf008f30..30300b143 100644 --- a/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap +++ b/packages/core/__snapshots__/src__components__line-items__line-items.test.tsx.snap @@ -7,7 +7,7 @@ exports[`LineItems > snapshots > amend mode 1`] = ` [data-slot="line-items-table-scroll"]::-webkit-scrollbar-track, [data-slot="line-items-table-scroll"]::-webkit-scrollbar-corner{background:transparent} [data-slot="line-items-table-scroll"]::-webkit-scrollbar-thumb{background:rgba(128,128,128,0.4);border-radius:4px} -
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
SKUUoMNoteTotalActions
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
" -`; +exports[`LineItems > snapshots > amend mode 1`] = `""`; -exports[`LineItems > snapshots > edit mode with selection and row actions 1`] = ` -"
SKUUoMNoteTotalActions
10.00
25.00
4.00
" -`; +exports[`LineItems > snapshots > edit mode with selection and row actions 1`] = `""`; -exports[`LineItems > snapshots > manual ordering renders drag handles 1`] = ` -"
ReorderSKUUoMQtyNoteTotal
10.00
25.00
4.00
" -`; +exports[`LineItems > snapshots > manual ordering renders drag handles 1`] = `""`; -exports[`LineItems > snapshots > view mode 1`] = ` -"
SKUUoMNoteTotal
APPLE-01EA210.00
BANANA-02CS525.00
CHERRY-03EA14.00
" -`; +exports[`LineItems > snapshots > view mode 1`] = `""`; diff --git a/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx b/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx index 85096d873..017fcc7d5 100644 --- a/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx +++ b/packages/core/src/components/bulk-item-picker/BulkItemPicker.test.tsx @@ -72,7 +72,8 @@ const rowFor = (label: string): HTMLElement => screen.getByText(label).closest(" const checkboxFor = (label: string): HTMLElement => rowFor(label).querySelector("[role=checkbox]") as HTMLElement; -const expandButton = () => screen.getByRole("button", { name: /expand/i }); +// Mouse-only (hidden from assistive tech, which uses the row), so found by its tooltip. +const expandButton = () => screen.getByTitle(/expand/i); describe("BulkItemPicker", () => { // ========================================================================== @@ -115,7 +116,7 @@ describe("BulkItemPicker", () => { it("empty state", async () => { const user = userEvent.setup(); const { baseElement } = render(); - await user.type(screen.getByLabelText("Search"), "zzz"); + await user.type(screen.getByLabelText("Search items"), "zzz"); await waitFor(() => expect(screen.getByText(/no matching items/i)).toBeDefined()); expect(baseElement.innerHTML).toMatchSnapshot(); }); @@ -230,15 +231,15 @@ describe("BulkItemPicker", () => { await user.click(expandButton()); await user.click(checkboxFor("UK 8 / Black / Women")); - await user.type(screen.getByLabelText("Search"), "UK"); + await user.type(screen.getByLabelText("Search items"), "UK"); await user.click(screen.getByRole("button", { name: /^cancel$/i })); - await waitFor(() => expect(screen.queryByLabelText("Search")).toBeNull()); + await waitFor(() => expect(screen.queryByLabelText("Search items")).toBeNull()); await user.click(screen.getByRole("button", { name: "Reopen" })); await waitFor(() => expect(screen.getByText("Nike Vomero 18")).toBeDefined()); - expect((screen.getByLabelText("Search") as HTMLInputElement).value).toBe(""); + expect((screen.getByLabelText("Search items") as HTMLInputElement).value).toBe(""); expect((screen.getByRole("button", { name: /add items/i }) as HTMLButtonElement).disabled).toBe( true, ); @@ -256,7 +257,7 @@ describe("BulkItemPicker", () => { expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); - await user.type(screen.getByLabelText("Search"), "UK 9"); + await user.type(screen.getByLabelText("Search items"), "UK 9"); expect(screen.getByText("Adidas Ultraboost 22")).toBeDefined(); expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); @@ -273,7 +274,7 @@ describe("BulkItemPicker", () => { await user.click(expandButton()); expect(screen.getByText("UK 9 / Black / Men")).toBeDefined(); - const search = screen.getByLabelText("Search"); + const search = screen.getByLabelText("Search items"); await user.type(search, "Hoka"); expect(screen.queryByText("UK 9 / Black / Men")).toBeNull(); @@ -285,7 +286,7 @@ describe("BulkItemPicker", () => { const user = userEvent.setup(); render(); - await user.type(screen.getByLabelText("Search"), "zzz no match"); + await user.type(screen.getByLabelText("Search items"), "zzz no match"); expect(screen.getByText(/no matching items/i)).toBeDefined(); expect(screen.queryByRole("tree")).toBeNull(); }); @@ -460,7 +461,7 @@ describe("BulkItemPicker", () => { await waitFor(() => expect(screen.getAllByRole("treeitem")).toHaveLength(3)); await user.click(checkboxFor("Nike Vomero 18")); - await user.click(screen.getByLabelText("Search")); + await user.click(screen.getByLabelText("Search items")); await user.keyboard("{Enter}"); expect(onCommit).not.toHaveBeenCalled(); diff --git a/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx b/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx index 950e58673..f9423b567 100644 --- a/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx +++ b/packages/core/src/components/bulk-item-picker/BulkItemPicker.tsx @@ -4,10 +4,12 @@ import { SearchIcon } from "lucide-react"; import { Button } from "@/components/button"; import { Dialog } from "@/components/dialog"; import { Input } from "@/components/input"; +import { cn } from "@/lib/utils"; import { BulkItemPickerRow } from "./BulkItemPickerRow"; import { flattenVisible, leafIds } from "./tree-utils"; import type { BulkItemPickerNode, BulkItemPickerProps } from "./types"; +import { useBulkItemPickerT } from "./i18n"; import { useTreeFocus } from "./use-tree-focus"; import { useTreeSelection } from "./use-tree-selection"; @@ -88,20 +90,22 @@ import { useTreeSelection } from "./use-tree-selection"; function BulkItemPicker({ open, onOpenChange, - title = "Select items", + title, items, renderRow, renderMetric, - rowLabel = "Item", + rowLabel, metricLabel, matchesSearch, filterSlot, onCommit, ctaLabel, - emptyText = "No matching items.", - searchPlaceholder = "Search", + emptyText, + searchPlaceholder, + className, }: BulkItemPickerProps) { const titleId = React.useId(); + const t = useBulkItemPickerT(); const { query, @@ -144,21 +148,24 @@ function BulkItemPicker({ commit, }); - const defaultCta = count > 0 ? `Add ${count} item${count === 1 ? "" : "s"}` : "Add items"; + const defaultCta = count > 0 ? t("addCount", { count }) : t("addItems"); const cta = ctaLabel ? ctaLabel(count) : defaultCta; return ( - {title} + {title ?? t("title")}
({ setQuery(event.target.value)} - placeholder={searchPlaceholder} - aria-label="Search" + placeholder={searchPlaceholder ?? t("searchPlaceholder")} + aria-label={t("searchLabel")} // `dark:bg-transparent` is not redundant: Input ships `dark:bg-input/30`, // which tailwind-merge will not dedupe against a bare `bg-transparent`, // so in dark mode it paints a second fill over the wrapper's own. @@ -189,7 +196,7 @@ function BulkItemPicker({ {/* Mirrors the row's checkbox + caret so the labels line up. */} -
{rowLabel}
+
{rowLabel ?? t("rowLabel")}
{metricLabel ?
{metricLabel}
: null}
@@ -198,7 +205,7 @@ function BulkItemPicker({ data-slot="bulk-item-picker-empty" className="astw:text-muted-foreground astw:px-6 astw:py-8 astw:text-center astw:text-sm" > - {emptyText} + {emptyText ?? t("emptyText")} ) : (
({ className="astw:border-border astw:border-t astw:px-6 astw:py-4" >
{grip} @@ -291,6 +299,7 @@ function LineItemsCellInner({ ({ return ( <>