Skip to content

feat(line-items): LineItems document-line editor and BulkItemPicker - #595

Open
itsprade wants to merge 27 commits into
feat/tree-pickerfrom
feat/line-items
Open

itsprade wants to merge 27 commits into
feat/tree-pickerfrom
feat/line-items

Conversation

@itsprade

@itsprade itsprade commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds LineItems, a spreadsheet-grade editor for the lines of a document (purchase orders, invoices, journals, work orders).

API

const lineItems = useLineItems<POLine>({
  fields,                              // createLineItemHelper<POLine>().field({ … })
  defaultLines: lines,                 // uncontrolled; or `lines` + `onLinesChange` (required together)
  mode: "edit",                        // "edit" | "view" | "amend"
  ordering: "manual",                  // "sort" | "manual"
  selection: { actions: [receive, remove] }, // or `true` for checkboxes only
});

<LineItems.Root value={lineItems}>
  <LineItems.Table renderRowActions={(row: POLine) => …} />
  <LineItems.TotalsRow>{(lines) => ({ total: sum(lines) })}</LineItems.TotalsRow>
  <LineItems.AddRow>…</LineItems.AddRow>
  <LineItems.SaveActions onSave={(changeSet) => submit(changeSet.lineChanges)} />
</LineItems.Root>
  • Same shape as DataTable: useLineItems() → <LineItems.Root value> → compound parts.
  • getChangeSet() returns a transport-agnostic { isEmpty, lineChanges } with add / update / remove / reorder ops, so only real edits get submitted.
  • useLineItemsGroup({ debits, credits }) puts several collections under one Save / Discard.
  • Bulk actions use DataTable's SelectionAction shape and the same footer bar.
  • Save surface: SaveActions in the card is the default. DirtyBar is the floating alternative for long pages; with warnOnNav, it holds in-app navigation through React Router's useBlocker.

Behaviour

  • Editors: text, number, select, boolean, date and custom. They commit on blur, Enter or Tab, never per keystroke.
  • Input parsing: numbers read pasted 1,234, 1,5, currency symbols and full-width digits. Dates accept YYYY/MM/DD and reject out-of-range values.
  • Spreadsheet behaviour:
    • range selection, TSV copy/paste, a fill handle, and Cmd/Ctrl+D fill-down
    • arrow navigation
    • Tab steps through editable cells and leaves the grid at its edges
  • Layout:
    • pinned columns that take the row hover tint
    • a sticky header and totals row with scroll-edge shadows
    • ellipsis truncation
    • a fullscreen mode, which is a modal dialog that keeps drafts
  • Performance: row virtualization (1,000 lines keep about 31 rows in the DOM), and memoized cells with stable handlers.
  • Reordering by drag, with a keyboard fallback and announcements.
  • Labels in English and Japanese, a screen-reader name on every editor, and reduced motion respected throughout.
  • No new dependencies.

Why it's built this way

  • renderRowActions is a render function, not DataTable's RowAction[]. Document lines often need per-row controls that aren't menu actions: a link to the item, inline remove, a status chip. The name is deliberately different from DataTable's rowActions so the two aren't confused. Bulk actions do use DataTable's SelectionAction shape.
  • Fullscreen is its own overlay, not Dialog. Dialog portals its content, which would remount the grid and lose half-typed cells and focus. The overlay keeps the same element tree and adds role="dialog", aria-modal, a focus wrap, a scroll lock, and an Escape scoped to unhandled keys.
  • mode is "edit" | "view" | "amend", not readOnly/disabled flags. These are stages of a document's life: draft, posted, and confirmed-but-partly-editable. A field opts into the modes it's editable in.
  • The option is named fields, not columns. A field carries editing semantics (editor type, normalize/equals, commit scope, amend rules), not just display.
  • Custom virtualizer instead of TanStack. The repo has a no-new-dependencies rule; it's small and covered by virtual-window.test.ts.

Shared selection bar: needs a maintainer's OK

To reuse DataTable's bulk-action bar, its view and pending-action runner moved into components/internals/selection-actions/, and DataTable now uses the shared copy (data-table.tsx, selection-bar.tsx, use-data-table.ts).

  • No behaviour change: all 344 DataTable tests pass unchanged, snapshots included.
  • Not exported.
  • Waiting on @IzumiSy to confirm this refactor is acceptable before review goes further. It can be split into its own PR if preferred.

Known follow-ups

  • Shared cell editing with DataTable: DataTable is getting inline cell editing in feat(data-table): inline cell editing #560, which @IzumiSy is taking over. Once it settles, we'll compare how cell editing works in both (editors, commit on blur/Enter, Escape to cancel, keyboard navigation, value normalization) and decide which pieces LineItems should borrow or share, rather than keep two implementations.
  • Import-order workaround: a side-effect import "@/contexts/appshell-context" sits in two i18n modules and the shared bar. It works around a pre-existing @/hooks/i18n module cycle and will be removed when that cycle is fixed separately.
  • Per-cell validation / error display is deferred.
  • Dialog-level open/close motion is proposed separately in draft feat(dialog): clearer open and close motion #598.

Test plan

  • vitest: full suite green, including the controlled round-trip, Tab/focus, paste-after-typing, fill-down, selection actions, warnOnNav (memory router), fullscreen, and number/date parsing
  • tsc, oxlint (0 warnings), pnpm docs:check, check-dts
  • Checked in the vite example at /showcase/line-items (PO, with a 1,000-line toggle) and /showcase/line-items-journal (group)

🤖 Generated with Claude Code

itsprade and others added 15 commits October 12, 2026 13:10
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nt 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 <noreply@anthropic.com>
Demo: add a 1,000-line dataset toggle to the PO page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
useLineItems takes selectionActions (DataTable's SelectionAction shape);
LineItems.Table shows the shared bar at its foot while lines are
selected. The bar view and pending-action runner move to a shared
selection-actions module that DataTable now uses too, unchanged.
Removes the unshipped LineItems.SelectionBar and BulkActions parts and
exposes removeLines for bulk handlers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Also fixes the docs, which listed maxBodyHeight as maxHeight with a
stale 480 default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sticky header and totals row cast a soft shadow over the lines while
more are hidden beyond them, and sit flush at the start and end of the
list. The totals row also gets a top rule; the last line drops its bottom
border so the two never double up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Controlled lines: an echo of the hook's own onLinesChange no longer
  re-seeds and wipes the baseline, so adds/removes stay in the change-set.
- Tab steps across editable cells only and releases focus at the grid's
  edges instead of trapping it; focus retries for rows just scrolled in.
- Cells mirror the store until the user types, so a paste into the
  focused cell isn't reverted on blur, and Enter/Tab commit once.
- Typing in flight is committed when its row unmounts or turns read-only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- No injected <style> or inline style objects: scrollbars and fullscreen
  use utility classes, the DirtyBar shake uses the Web Animations API.
  lineItemsFloatingBarStyles is removed from the public API.
- English and Japanese labels via defineI18nLabels for both components.
- Every cell editor is named by column and line; custom editors get
  ariaLabel. Row counts/indexes include header and totals rows.
- Fullscreen is a labelled modal dialog with a focus wrap, scroll lock and
  scoped Escape, and no longer remounts the document when toggled.
- DirtyBar: count announced, blocked navigation announced and focused,
  warnOnNav scoped to its own document/card and ignores modified clicks.
- Keyboard fill-down (Cmd/Ctrl+D), keyboard reorder announcements,
  reduced-motion support, O(1) selection checks.
- Exports aligned between folder and package root; types referenced by
  public types are exported. Shared selection bar moved to internals/.
- BulkItemPicker: className, ReactNode ctaLabel, cleaner row names.
- Docs synced with the types (amend mode, emptyMessage, Table props);
  dead boolean trueLabel/falseLabel options removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- useLineItems: `data` is now `defaultLines` (defaultX / x / onXChange).
- LineItems.Table: `rowActions` is now `renderRowActions`, so it isn't
  confused with DataTable's RowAction[]; the `totalsRow` prop is removed
  in favour of <LineItems.TotalsRow>.
- Field `hoverExpandWidth` is removed.
- Docs and demo present in-card SaveActions as the default save surface;
  DirtyBar is the floating alternative for long pages.
- Fill drag is dropped on pointercancel; the virtualizer measures before
  paint so the first frame isn't empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
itsprade and others added 11 commits October 12, 2026 17:29
- A paste, fill or revert now wins over typing already in the cell, and
  the cell shows the stored value after a commit that normalizes back.
- Escape in a closed select cell cancels instead of clearing the value.
- Tab reaches custom editors (focus falls back to their own input).
- Number cells read pasted "1,234", "1,5", currency and full-width
  digits; unreadable input keeps the current value. Date cells accept
  YYYY/MM/DD and reject invalid or out-of-range values.
- A blocked link focuses Save (or the bar), never Discard.
- FloatingDock and fullscreen use the z-index token scale, so dialogs and
  popups are never covered.
- Keyboard scroll-into-view clears the sticky header and totals rows.
- Cells get one stable handlers object, so memoized cells skip re-renders
  on unrelated keypresses.
- Last English sr-only labels localized; tests assert data attributes,
  not class names; new tests for each fix and for fullscreen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It is a generic tree-select dialog, not specific to line items.
Component, props/node types, labels, data-slots, docs and demo renamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pinned cells stay opaque so scrolled content can't show through, which also
hid the row's hover background. They now switch to the same tint (muted at
50% over the card), with the row's colour transition.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nested leaves no longer reserve the caret's space; the indent step is one
caret wide instead, so a child's checkbox sits under its parent's caret and
labels line up. A top-level leaf keeps the space only when siblings expand.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The row is the hit target, with a pointer cursor and hover tint. Clicks on
the checkbox, caret, or anything interactive the consumer renders keep their
own behaviour, so nothing toggles twice. Space still toggles from the keyboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…apse

A small hook watches the rows' size and animates the list's height with the
native Web Animations API (no animation library), so the dialog grows and
shrinks smoothly instead of jumping. Revealed child rows fade in. Both are
skipped under prefers-reduced-motion. Open/close keep Dialog's shared
fade + zoom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Group rows expand/collapse on click; their checkbox still selects every
child. Item rows keep click-to-tick. Documented under Mouse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`function TreePicker` + a trailing `export { TreePicker }` made the
declaration bundler emit the displayName namespace twice (exported and
local): TS2395 for consumers with skipLibCheck: false. This is what failed
check-dts in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- useLineItems: `selection: true | { actions }` replaces the separate
  `selectionActions` option; `lines` now requires `onLinesChange` at the
  type level (and can't be combined with `defaultLines`).
- DirtyBar warnOnNav uses React Router's useBlocker (links, navigate(),
  back/forward; same-route search-param changes pass) plus
  useBeforeUnload, replacing document click interception. Mounted only
  while dirty, so tables without it need no router.
- Custom table scrollbars dropped in favour of the browser's.
- TreePicker callbacks take one object: renderRow({ node, depth }),
  renderMetric({ node, depth }), matchesSearch({ node, query }).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TreePicker now lands in its own PR; this branch builds on it. The LineItems
pairing section and link return to the TreePicker docs here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@itsprade
itsprade changed the base branch from main to feat/tree-picker October 12, 2026 12:34
@itsprade

Copy link
Copy Markdown
Contributor Author

@IzumiSy, a quick check on a DataTable refactor before this goes further in review.

LineItems uses the same bulk-action footer bar as DataTable ("3 of 5 selected · actions · Clear"). Rather than copy it, I moved the bar's view and the pending-action runner into a shared internal module, components/internals/selection-actions/, and pointed DataTable at it.

  • No behaviour change: all 344 DataTable tests pass unchanged, snapshots included.
  • Nothing new is exported.
  • DataTable files touched: data-table.tsx, selection-bar.tsx, use-data-table.ts.

Is this refactor something we can do? If you'd rather see it as its own small PR first, I'll split it out.

For context, TreePicker is now its own PR (#597), and this PR is stacked on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant