# Ledger & tables

> The table is the product. It gets the same design investment as the hero card. Specs for 15 React components: TransactionTable, TransactionRow, TransactionCard, TransactionDetail and 11 more.

Source: https://lopifi.com/docs/catalog/05-ledger-tables
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

# Ledger & tables

The table is the product. It gets the same design investment as the hero card.

---

## 47. `TransactionTable`

> The workhorse. Everything about it is a craft decision.

### Column priority
```
P1  date · description · amount        never dropped
P2  status · account
P3  category · merchant
P4  reference · memo · created-by · tags
```

### Four presentations
| Width | Presentation |
|---|---|
| ≥1280 | Full table, sticky header, sticky total footer |
| 1024–1280 | P4 dropped into a per-row expand |
| 768–1024 | **Sticky description (left) + sticky amount (right), metadata scrolls between** |
| <768 | `TransactionCard` list — a genuine re-rank |

The 768–1024 pattern is the one nobody implements and the one that makes a wide table usable at laptop width: **the label and the number stay pinned; the metadata scrolls between them**, so the two things being correlated are always both on screen. Details — sticky cell backgrounds, `IntersectionObserver`-driven edge fades, inner borders, focus rings that don't clip — in [responsive](../craft/04-responsive-and-input.md#table-collapse).

### Craft
- **Virtualized past 100 rows** (`@tanstack/react-virtual`), with correct `aria-rowcount` / `aria-rowindex` so a screen reader knows the real total.
- **Amounts right-aligned, `tnum`, paren column reserved, hugging the card inset.**
- **Row heights on the grid:** 32 / 40 / 48 by density.
- **Zebra striping: no.** `--border-subtle` row dividers instead. Striping doubles the background changes on a scrolling surface and fights with hover and selection.
- **Selection is a checkbox column**, always visible on touch, hover-or-focus-revealed on desktop. `Shift`-click range select, `Cmd`-click toggle.
- **Sort is instant.** No FLIP, no stagger. High frequency. Every column sorts on its
  value — the amount column on minor units, the status column on workflow urgency rather
  than the alphabet, so one click puts the exceptions on top. Uncontrolled by default;
  pass `sort` + `onSortChange` and the host owns it, which is the only correct
  arrangement once the rows are a page of a larger set.
- **Columns drag.** Widths are px, persisted per user under `columnStorageKey`, with
  `description` taking the slack. See [`ColumnHeader`](#172-columnheader).
- **Sticky header** gains its shadow only once content scrolls beneath (sentinel + `IntersectionObserver`).
- **Sticky total footer** — a single row height plus a 1px top rule. This is the number people are actually looking for.
- **Pending rows** carry a 2px `--warning` left border and their pending status, at full opacity: a row dimmed to 0.6 drops its secondary text under AA (3.2:1). Confirmation removes the border.

### Keyboard
Full grid model — arrows navigate cells, the grid is **one** tab stop. Table in [accessibility](../craft/02-accessibility.md#data-grid-keyboard-model).

### Motion
Tier 0 almost entirely. Row hover is `background-color` at 100ms — no transform, no shadow, no lift. Insert and delete animate; sort, filter, and paginate do not.

---

## 48. `TransactionRow`
`[checkbox] [date] [mark] [description / merchant] [category] [account] [status] [amount]`

Description truncates; **amount never does**. Merchant name on a second line at `caption`/`--text-secondary` when it differs from the description. Row actions revealed on hover/focus, right-aligned, always present on touch.

## 49. `TransactionCard`
The mobile re-rank. 72px, 16px inset, full-bleed divider, amount right-aligned and never truncated, 32px mark, swipe-left actions **with an overflow-menu equivalent** because swipe is undiscoverable. Sticky date section headers.

## 50. `TransactionDetail`
A sheet on phone, a side panel on desktop. Everything: full amounts, FX detail if converted, attachments, the categorization rule that fired, the full audit trail with actor and timestamp. Inline editing for category and memo, with optimistic update and rollback on failure.

## 51. `LedgerTable`
Double-entry. **Debit and credit as two separate columns**, never one signed column — collapsing them destroys the reader's ability to verify, which is the entire purpose of double-entry. Column totals with the balance check displayed; a failing check is a prominent error state, not a silent discrepancy.

### The shared grid model

Every table in this family — and in statements, schedules and the close — is
driven by the same two primitives, because an accountant does not distinguish
between them. They arrive from a spreadsheet and press the arrow keys.

- **`useGridNav`** for tables that are already client components (`TransactionTable`).
  **`GridKeyboard`** wraps the ones that are deliberately server-rendered, reading
  the DOM they produced rather than forcing a rewrite — a 400-row statement should
  not ship 400 rows of JavaScript to be rendered twice.
- **One tab stop per grid.** Tab *to* the table, arrow *within* it. Arrows move,
  `Cmd`+arrow jumps to the edge, `PageUp`/`PageDown` move a screen, `Home`/`End`
  the row, `Space` selects, `Enter` opens, `Escape` collapses.
- **`Shift`+arrows select a block, `Cmd`+C copies it** — as TSV *and* as HTML, so
  every paste target gets its best flavour.
- **The clipboard carries the value, not the presentation.** `Money` stamps every
  figure with `data-raw`, so a copied column lands in Excel as `-1240.56` and
  sums. `($1,240.56)` pastes as text, and a column of text does not add up.
- **The label column freezes.** Any table wider than its container pins its row
  header with `FROZEN_LABEL` — scrolling a general ledger sideways and losing the
  account name is the exact motion this family exists to support.
- **Every column sorts and drags.** `ColumnHeader` for the tables that render their own
  rows, `TableColumns` for the ones that render on the server — the same split, and the
  same reason, as `useGridNav` and `GridKeyboard`.

## 52. `AgingTable`
AR/AP by bucket: current / 1-30 / 31-60 / 61-90 / 90+. Row totals and column totals. **The intersection cell (grand total) is proven both ways** — rows and columns must agree, and the component asserts it.

Bucket severity uses a sequential lightness ramp, not a red gradient, so it survives greyscale and the signal palette.

## 53. `ReconciliationView`
Two panes: bank statement lines, book entries. Suggested matches with confidence, one-click accept, manual multi-select match (many-to-one and one-to-many both supported — real reconciliation is rarely 1:1).

**The match animation** is one of the four sanctioned moments: highlight → FLIP together → merge with a check. [Spec](../motion/03-catalog.md). Bulk match runs the sequence once for the group, staggered at 40ms with a 240ms cap.

Running totals for both sides, with the remaining difference always visible. That difference is the number the user is trying to get to zero, so it never scrolls away.

## 54. `CategoryPicker`
Hierarchical, searchable, keyboard-first. Recents pinned. Inline "create a rule from this" — categorizing one transaction and offering to categorize the other 40 like it is the highest-leverage interaction in bookkeeping software. Rule preview shows the match count **before** applying.

## 55. `FilterBar`
URL-param-backed, so a filtered view is shareable and survives a refresh. Chips show **resolved values** — "1–30 Sep 2026", not "Last 30 days". Clear-all always present. Result count updates live. Filter state changes are instant, no animation.

## 56. `BulkActionBar`
Appears when rows are selected. Bottom-anchored on phone with safe-area padding, inline on desktop. States the count (`42 selected`), offers actions, and **names the destruction**: "Delete 42 transactions", never "Delete". Enters with a 240ms translate + fade — occasional, so it's licensed.

## 57. `ColumnManager`
Show/hide/reorder with drag. Persisted per user per table. Drag uses FLIP + `spring.snap`; measure once at drag start, never during. Keyboard reorder via `Cmd`+arrows.

## 171. `TableColumns`

> Sortable, resizable columns for a table that renders on the server.

The sibling of `GridKeyboard`, and the same argument. Most ledger tables here are
deliberately server components, because a 400-row statement should not ship 400 rows of
JavaScript to be rendered twice. But a bookkeeper arrives from Excel, and in Excel every
column sorts and every column drags. Paying for that by rewriting nineteen tables into
client components is the worse trade.

So it is a wrapper, not a rewrite: it reads the table the server produced. Header cells
become sort controls and gain a resize handle, an injected (or adopted) `<colgroup>`
carries the widths, and the rows are re-ordered in place. Any table in the catalog gets
both behaviours without knowing this component exists.

```tsx
<TableColumns storageKey="gl-detail" onOpenRow={(i) => setDrilldown(i)}>
  <GridKeyboard>
    <LedgerTable … />
  </GridKeyboard>
</TableColumns>
```

### Sorting
- **Three states, not two** — ascending, descending, and back to the ledger's own order.
  The source order of a journal is itself information; a table you can sort but never
  un-sort has taken it away.
- **Numbers and dates open at the big end, text opens at A.** The question behind
  clicking an amount column is "what is the largest", never "what is the smallest", and a
  first click that yields the oldest row reads as a bug.
- **Sorts the value, not the presentation** — `data-sort`, then the `data-raw` that
  `Money` already stamps, then the text. `($1,240.56)` sorted as text sorts by its
  opening parenthesis.
- **`null` is not `0`.** Blanks sort last in *both* directions. Flipping them to the top
  on a descending sort is how a reviewer concludes twelve accounts have no balance when
  nobody has told us yet.
- **Stable.** Rows that tie keep the order the ledger gave them — date, then entry number.
- **Status columns sort by workflow, not alphabet.** A–Z puts `cancelled` above `overdue`.

### What it refuses to sort
- **A hierarchy.** `data-level` rows (a chart of accounts) or `data-row-variant` rows
  (subtotals, section heads) carry meaning in their order — a subtotal floating away from
  the rows it totals is worse than an unsorted table. Those tables still resize.
- **A table that opted out.** `data-sortable="false"` on the `<table>`, declared by the
  component rather than the caller: a register's running balance is only a running
  balance in date order, and the register is what knows that.
- **`tfoot`.** Totals are not rows of the population.
- **A page of a larger set.** Sorting the page answers "largest amount" with the largest
  of ninety-four rows out of twelve thousand, and looks exactly like the truth. Pass
  `sortable={false}` and sort at the source.

### Resizing
Account names, memos and vendor names have no natural length: the width that fits one
client's chart of accounts truncates another's on every row, and two truncated account
names look identical. That is a correctness problem wearing a layout problem's clothes.

- **A 9px hit area centred on the seam**, with a 1px line that paints only on hover,
  focus or during the drag. A permanently visible grabber on every boundary turns a
  ledger into a grid of handles.
- **The handle is a real `separator`** with `aria-valuenow`. Arrows move it 16px,
  `Alt`+arrows 1px, `Home` restores the shipped width — and so does a double-click.
- **Measured from the start of the drag**, never accumulated per move; an accumulated
  delta drifts away from the cursor.
- **Persisted per user per table** on pointer-up, never per move. Stored widths are
  clamped to today's bounds and dropped for columns that no longer exist — the table's
  shape outlives the preference.
- **The slack goes to one column**, the widest at first paint, which is reliably the
  label column. A fixed-layout table narrower than its card leaves a gap, and a ruled
  card with a gap in it reads as a rendering bug.
- **A body cell clips; a figure cell does not.** A truncated label is an inconvenience
  the reader can undo. A figure missing its leading digit is a number that is wrong.

### Row detail
`onOpenRow` fires on double-click and on `Enter` — both, because double-click is the
gesture people bring from every ledger they have used and `Enter` is the one that works
without a mouse. Pair it with `DrilldownSheet` or `TransactionDetail`.

---

## 172. `ColumnHeader`
The same two behaviours for a table that renders its own rows — `TransactionTable` uses
it. The sort control is a real `<button>` inside the cell rather than a handler on the
`<th>`, because the resize handle beside it needs its own focus stop; `aria-sort` stays
on the `<th>`, where a screen reader looks for it. The direction chevron always occupies
its space — a glyph that arrives on sort shifts the label sideways on every click of the
column the eye is trying to read.

---

## 58. `ExportMenu`
CSV / XLSX / PDF. **States the scope explicitly** — "Export 1,284 filtered transactions" — because exporting the current page when the user expected the full filtered set is a silent data error. Includes the resolved period and filters in the exported file's header.

---

## 173. `PeriodGrid`
The ruled grid: rows down, periods across, and the six spreadsheet behaviours an accountant
performs on a workbook within a minute of opening it. Every period-by-period surface in the
system — `ForecastTable`, `BudgetVsActual`, `RevenueTracker`, `ExpenseTracker`, `DriverModel`,
`ScenarioCompare`, `PlByDimension` — is a configuration of this one grid rather than another
table written from scratch.

### Ruled cells, and why the statements are not
A statement is read across one row at a time, and its horizontal rules carry meaning — single
above a subtotal, double under a total. A period grid is read *down* a column as often as
across a row, and a column with no left edge is one the eye loses the moment two neighbouring
figures happen to be the same width. So the grid boxes every cell and the statements keep their
accounting rules with a vertical seam added between period columns. Two different documents,
two different rulings, one reason each.

`border-collapse: separate` is required rather than preferred: a sticky cell in a collapsed
table loses its borders when it detaches, because the border belongs to the table and the cell
scrolls away from it.

### Freeze panes
Top row and left column, the two anyone freezes in every workbook they open. The freeze shadow
appears only once something is hidden behind it — a permanent shadow on a grid that fits its
container is a scroll cue for a scroll that cannot happen.

### Hidden columns
Nobody reviews fourteen months at once. Hiding is not deleting: rows index into the full column
list, so a hidden column never renumbers the data behind it, and a declared comparison
(`column.compare`) still measures against its base while that base is off screen. The count of
hidden columns is always shown with a one-click path back — a reader who hid six columns last
quarter needs to know this grid is not the whole story.

### The change, printed under the figure
The figure answers "how much"; the next question is always "versus what". That answer is printed
under every figure at once — small, toned, right-aligned — rather than revealed one cell at a
time on hover. The reason anyone opens this grid is to compare, and a comparison you can only
make one cell at a time is not a comparison. The pointer and the keyboard cursor still fill the
formula bar under the grid with the full sentence, including the amount and the column being
measured against.

The sub-line is a percentage, not an amount: the amounts are already on screen directly above
and to the left, and the percentage is the thing the eye cannot do for itself while scanning
seven columns. On a percentage row it is points instead — calling a move from 51.8% to 53.0%
"+2.3%" is the most common way a deck lies by accident.

**No invented percentages.** No ratio against a zero base, none against a negative base, and a
missing figure stays missing. The sub-line says which of the three it was instead of printing a
number that reads backwards, and the first column on screen shows an en dash rather than a zero.

### Annotations, not views
Two independent toggles — % change, and % of a named base row — each adding a line under the
figures. Neither replaces them. A segmented Value / Δ / Δ% switch forces the reader to hold one
of the two numbers they are comparing in their head while they look at the other, and a grid
whose numbers are swapped out for percentages under a tab press is a grid that has to be
re-read every time it is touched.

### Typing
Off by default: a grid over posted figures is a report, and a report that accepts typing is one
someone will believe they have changed. Where a caller marks cells editable — the projected
inputs of a driver model, never its actuals or its computed rows — a cell opens on double-click
or `Enter` and accepts what a spreadsheet accepts, including `1.2k`, `(1,240)` and `=1200+340`.
A value it cannot parse commits nothing rather than committing zero.

### Keyboard and clipboard
One tab stop, then arrow keys, `PageUp`/`PageDown`, `Home`/`End`, shift to extend a range and
⌘C to copy it. The clipboard carries `data-raw` values, so the paste sums in the spreadsheet it
is going back to.
