# TableColumns

> Sort and resize for a table that renders on the server.

Source: https://lopifi.com/components/table-columns
Part of: Lopifi — US GAAP books on autopilot, reviewed by a CPA (https://lopifi.com)

---

- **Family:** Ledger & tables — 
- **Status:** Implemented
- **Spec:** https://lopifi.com/docs/catalog/05-ledger-tables

## Install

```bash
npx shadcn@latest add @lopifi/theme
npx shadcn@latest add @lopifi/table-columns
```

## Specification

> 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`.

## Rules this component obeys

Money is minor units, never floats. `null` is not `0` and neither is redacted. Negatives render in accounting parentheses with the parenthesis column reserved on both sides. Debit and credit are two columns. Per-currency decimal places. Polarity is explicit — up is not always good. Every figure states its period, entity, currency, basis and as-of.
