TableColumns
- Status
- Implemented
- Family
- 5 of 13
- Registry
- @lopifi/table-columns
npx shadcn@latest add @lopifi/theme @lopifi/table-columnsThe component, rendering
Real data, the shipped component. Switch the theme to check the claims the spec makes below.
The four-layer spec
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.
<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 thedata-rawthatMoneyalready stamps, then the text.($1,240.56)sorted as text sorts by its opening parenthesis. nullis not0. 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
cancelledaboveoverdue.
What it refuses to sort
- A hierarchy.
data-levelrows (a chart of accounts) ordata-row-variantrows (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
separatorwitharia-valuenow. Arrows move it 16px,Alt+arrows 1px,Homerestores 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.
Lifted verbatim from Ledger & tables. The doc and this page are the same paragraphs, so they cannot disagree.
What lands in your repository
One component is never one file. Everything below is copied into your project, where you own it.
- Files
components/ledger/table-columns.tsx
- Lopifi dependencies · 2
- cntable-sort
- npm packages
- clsxtailwind-merge
- Registry entry
- /r/table-columns.json
The whole component
No compiled bundle, no black box. This is the file the registry copies, and the file you will edit.
"use client";
import * as React from "react";
import { cn } from "@/lib/ledger/cn";
import {
ariaSort,
compareValues,
firstDirection,
nextSortState,
readCellSortValue,
type SortState,
} from "@/lib/ledger/table-sort";
export interface TableColumnsProps extends React.ComponentPropsWithoutRef<"div"> {
/**
* Row detail. Double-click a row, or press Enter on it — 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.
*/
onOpenRow?: (index: number, row: HTMLTableRowElement) => void;
/** Reported after a sort, so the host can mirror it into the URL. */
onSortChange?: (sort: SortState) => void;
resizable?: boolean;
/** Off for a table whose order is itself the information — a journal entry. */
sortable?: boolean;
/**
* Persists the widths per user per table, under `ledger:cols:{storageKey}`.
* Omit it and the widths last as long as the mount does.
*/
storageKey?: string;
}
const MIN_WIDTH = 64;
const MAX_WIDTH = 720;
const KEY_STEP = 16;
const FINE_STEP = 1;
const storageKeyFor = (key: string) => `ledger:cols:${key}`;
/** The column index a cell starts at, accounting for the spans before it. */
function columnStarts(row: HTMLTableRowElement): number[] {
const starts: number[] = [];
let index = 0;
for (const cell of Array.from(row.cells)) {
starts.push(index);
index += cell.colSpan || 1;
}
return starts;
}
/** The cell in `row` covering column `target`, or null if a span swallowed it. */
function cellAt(row: HTMLTableRowElement, target: number): HTMLTableCellElement | null {
let index = 0;
for (const cell of Array.from(row.cells)) {
const span = cell.colSpan || 1;
if (target >= index && target < index + span) return span === 1 ? cell : null;
index += span;
}
return null;
}
/**
* Sortable, resizable columns for a table that renders on the server.
*
* The sibling of `GridKeyboard`, and the same argument: most ledger tables in
* this system are deliberately server components, because a 400-row statement
* should not ship 400 rows of JavaScript to be rendered again on the client.
* But a bookkeeper arrives from Excel, and in Excel every column sorts and
* every column drags. Losing that is a real cost, and paying for it by
* rewriting nineteen tables into client components is a worse one.
*
* So this is a wrapper, not a rewrite. It reads the table the server produced:
* header cells become sort controls and gain a resize handle, `<col>` elements
* carry the widths, and the rows are re-ordered in place. Any table in the
* catalog gets both behaviours without knowing this component exists.
*
* What it deliberately does **not** do:
*
* - **It never sorts a hierarchy.** A table with `data-level` rows (a chart of
* accounts) or `data-row-variant` rows (subtotals, section heads) has an
* order that carries meaning — a subtotal that floats away from the rows it
* totals is worse than an unsorted table. Those tables still resize.
* - **It never sorts a `tfoot`.** Totals are not rows of the population.
* - **It sorts the value, not the presentation** — `data-sort`, then the
* `data-raw` that `Money` already stamps, then the text. A column of
* `($1,240.56)` sorted as text is sorted by its parenthesis.
* - **It does not paginate.** If the table shows a page of a larger set, sort
* it at the source; sorting the page answers the question with the wrong
* population. Pass `sortable={false}` and drive it from the host.
*/
export const TableColumns = React.forwardRef<HTMLDivElement, TableColumnsProps>(
(
{ className, children, storageKey, sortable = true, resizable = true, onSortChange, onOpenRow, ...props },
forwardedRef,
) => {
const hostRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(forwardedRef, () => hostRef.current as HTMLDivElement);
/* Refs rather than state throughout, for the same reason `GridKeyboard`
uses them: this component paints attributes and widths onto a DOM
somebody else rendered. It has nothing of its own to re-render, and
holding the sort in state would re-render the host's subtree on every
header click for no visual gain. */
const sortRef = React.useRef<SortState>(null);
const widthsRef = React.useRef<Record<string, number>>({});
const defaultsRef = React.useRef<Record<string, number>>({});
const onSortChangeRef = React.useRef(onSortChange);
onSortChangeRef.current = onSortChange;
const onOpenRowRef = React.useRef(onOpenRow);
onOpenRowRef.current = onOpenRow;
/* ------------------------------------------------------------ reading */
const table = React.useCallback(() => hostRef.current?.querySelector("table") ?? null, []);
const headerRow = React.useCallback((): HTMLTableRowElement | null => {
const head = table()?.tHead;
/* The last row of the head is the one with the real columns — a banded
header puts group labels above it. */
return head?.rows.length ? head.rows[head.rows.length - 1] : null;
}, [table]);
/** The body rows that participate: no subtotals, no section heads. */
const sortableRows = React.useCallback((): HTMLTableRowElement[] => {
const body = table()?.tBodies?.[0];
if (!body) return [];
return Array.from(body.rows);
}, [table]);
const hierarchical = React.useCallback(() => {
const body = table()?.tBodies?.[0];
if (!body) return true;
return Array.from(body.rows).some(
(row) =>
row.hasAttribute("data-row-variant") ||
row.querySelector("[data-level], [aria-level]") !== null ||
Array.from(row.cells).some((c) => c.hasAttribute("data-level") || c.hasAttribute("aria-level")),
);
}, [table]);
/** The key a column's width is stored under — stable across re-renders. */
const keyFor = React.useCallback((cell: HTMLTableCellElement, index: number) => {
if (cell.dataset.column) return cell.dataset.column;
const label = (cell.textContent ?? "").trim().replace(/\s+/g, "-").toLowerCase();
return label || `col-${index}`;
}, []);
/* ------------------------------------------------------------- widths */
const persist = React.useCallback(() => {
if (!storageKey) return;
try {
localStorage.setItem(storageKeyFor(storageKey), JSON.stringify(widthsRef.current));
} catch {
/* Not persisting is survivable. Not rendering is not. */
}
}, [storageKey]);
/**
* Writes the widths onto the `<col>` elements.
*
* The slack matters more than it sounds. Under `table-layout: fixed` a
* table narrower than its container leaves a gap at the right edge, and a
* ruled card with a gap in it reads as a rendering bug. So the spare space
* goes to one column — the widest one at first paint, which is reliably
* the label column — exactly as it does in every tool that has this
* feature. The numeric columns keep the width they were given.
*/
const layout = React.useCallback(() => {
const t = table();
const head = headerRow();
if (!t || !head) return;
const group = t.querySelector<HTMLTableColElement>("colgroup[data-ledger-cols]");
if (!group) return;
const cols = Array.from(group.children) as HTMLTableColElement[];
const cells = Array.from(head.cells);
const starts = columnStarts(head);
let total = 0;
const owned = new Map<number, HTMLTableCellElement>();
for (const [i, cell] of cells.entries()) {
if ((cell.colSpan || 1) !== 1) continue;
owned.set(starts[i], cell);
}
for (const [index, _col] of cols.entries()) {
const cell = owned.get(index);
const key = cell ? keyFor(cell, index) : `col-${index}`;
total += widthsRef.current[key] ?? defaultsRef.current[key] ?? 0;
}
const available = t.parentElement?.clientWidth ?? 0;
const flexIndex = Number(t.dataset.ledgerFlexCol ?? "-1");
const slack = Math.max(0, available - total);
for (const [index, col] of cols.entries()) {
const cell = owned.get(index);
const key = cell ? keyFor(cell, index) : `col-${index}`;
const width = widthsRef.current[key] ?? defaultsRef.current[key] ?? 0;
col.style.width = `${index === flexIndex ? width + slack : width}px`;
}
t.style.tableLayout = "fixed";
t.style.width = `${total + slack}px`;
/* Only now does the table become a fixed-layout one, and only now do its
body cells start clipping — see the note in tokens.css. */
t.dataset.ledgerSized = "";
}, [headerRow, keyFor, table]);
/* ------------------------------------------------------------ sorting */
const paintSort = React.useCallback(() => {
const head = headerRow();
if (!head) return;
const starts = columnStarts(head);
for (const [i, cell] of Array.from(head.cells).entries()) {
if (!cell.hasAttribute("data-ledger-sortable")) continue;
const key = keyFor(cell, starts[i]);
const state = sortRef.current;
cell.setAttribute("aria-sort", ariaSort(state, key));
if (state?.column === key) cell.setAttribute("data-sorted", state.direction);
else cell.removeAttribute("data-sorted");
}
}, [headerRow, keyFor]);
const applySort = React.useCallback(
(columnIndex: number) => {
const t = table();
const body = t?.tBodies?.[0];
if (!t || !body) return;
const state = sortRef.current;
const rows = sortableRows();
const ordered = rows
.map((row, index) => ({
row,
/* The order the ledger gave us, so the third click can restore it. */
source: Number(row.dataset.ledgerOrder ?? index),
value: state ? readCellSortValue(cellAt(row, columnIndex) ?? row) : null,
}))
.sort((a, b) =>
state
? compareValues(a.value, b.value, state.direction) || a.source - b.source
: a.source - b.source,
);
/* Moving the nodes React rendered is safe here: React holds its own
references to them and re-inserts them in its order on the next
render, so a re-render corrects the DOM rather than corrupting it. */
const fragment = document.createDocumentFragment();
for (const entry of ordered) fragment.appendChild(entry.row);
body.appendChild(fragment);
},
[sortableRows, table],
);
const toggleSort = React.useCallback(
(cell: HTMLTableCellElement, columnIndex: number) => {
const key = keyFor(cell, columnIndex);
const rows = sortableRows();
const sample = rows.length ? readCellSortValue(cellAt(rows[0], columnIndex) ?? rows[0]) : undefined;
sortRef.current = nextSortState(sortRef.current, key, firstDirection(sample));
applySort(columnIndex);
paintSort();
onSortChangeRef.current?.(sortRef.current);
},
[applySort, keyFor, paintSort, sortableRows],
);
/* ------------------------------------------------------------ enhance */
React.useEffect(() => {
const t = table();
const head = headerRow();
const host = hostRef.current;
if (!t || !head || !host) return;
const cleanups: (() => void)[] = [];
const cells = Array.from(head.cells);
const starts = columnStarts(head);
const columnCount = starts.length
? starts[starts.length - 1] + (cells[cells.length - 1].colSpan || 1)
: 0;
/* Three ways a table opts out, and the middle one matters most: the
component itself declares it. A register's running balance is only
a running balance in date order, and that is knowledge the register
has and its caller does not. */
const canSort = sortable && t.dataset.sortable !== "false" && !hierarchical();
/* Source order, recorded once, so "no sort" is a state we can return to. */
for (const [index, row] of sortableRows().entries()) {
if (row.dataset.ledgerOrder === undefined) row.dataset.ledgerOrder = String(index);
}
/* -- widths -------------------------------------------------------- */
if (resizable && columnCount > 0) {
/* Adopt a `<colgroup>` the table already has rather than adding a
second one: colgroups concatenate, so a new one inserted in front of
an existing four-col group would define columns 1–4 twice and shift
every width by four columns. */
let group = t.querySelector<HTMLTableColElement>("colgroup");
if (group && group.children.length !== columnCount) group = null;
if (group) {
group.setAttribute("data-ledger-cols", "");
const adopted = group;
const original = Array.from(adopted.children).map((col) => (col as HTMLElement).style.width);
cleanups.push(() => {
adopted.removeAttribute("data-ledger-cols");
for (const [i, col] of Array.from(adopted.children).entries()) {
(col as HTMLElement).style.width = original[i] ?? "";
}
});
} else {
group = document.createElement("colgroup");
group.setAttribute("data-ledger-cols", "");
for (let i = 0; i < columnCount; i += 1) group.appendChild(document.createElement("col"));
t.insertBefore(group, t.firstChild);
const created = group;
cleanups.push(() => created.remove());
}
/* Measure before anything is written, or the first column measured
changes the width of the ones after it. */
let widest = { index: -1, width: 0 };
const measured: Record<string, number> = {};
for (const [i, cell] of cells.entries()) {
if ((cell.colSpan || 1) !== 1) continue;
const index = starts[i];
const width = Math.round(cell.getBoundingClientRect().width);
measured[keyFor(cell, index)] = width;
if (cell.dataset.flexColumn !== undefined) widest = { index, width: Number.POSITIVE_INFINITY };
else if (width > widest.width) widest = { index, width };
}
defaultsRef.current = measured;
widthsRef.current = { ...measured, ...widthsRef.current };
t.dataset.ledgerFlexCol = String(widest.index);
if (storageKey) {
try {
const raw = localStorage.getItem(storageKeyFor(storageKey));
if (raw) {
const stored = JSON.parse(raw) as Record<string, number>;
for (const [key, value] of Object.entries(stored)) {
/* A stored width for a column that no longer exists is dropped,
and one outside today's bounds is clamped rather than
trusted — the table's shape outlives the preference. */
if (key in measured && typeof value === "number" && Number.isFinite(value)) {
widthsRef.current[key] = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value));
}
}
}
} catch {
/* The measured widths are a correct table. Nothing to recover. */
}
}
for (const [i, cell] of cells.entries()) {
if ((cell.colSpan || 1) !== 1) continue;
if (cell.dataset.noResize !== undefined) continue;
const index = starts[i];
const key = keyFor(cell, index);
const label = (cell.textContent ?? "").trim() || `column ${index + 1}`;
if (getComputedStyle(cell).position === "static") cell.style.position = "relative";
const handle = document.createElement("div");
handle.className = "ledger-col-resizer";
handle.setAttribute("role", "separator");
handle.setAttribute("aria-orientation", "vertical");
handle.setAttribute("aria-label", `Resize ${label} column`);
handle.tabIndex = 0;
const setWidth = (value: number) => {
widthsRef.current[key] = Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value)));
handle.setAttribute("aria-valuenow", String(widthsRef.current[key]));
layout();
};
setWidth(widthsRef.current[key] ?? measured[key]);
const onPointerDown = (e: PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startWidth = widthsRef.current[key];
let frame = 0;
let latest = startWidth;
handle.toggleAttribute("data-resizing", true);
const previousCursor = document.body.style.cursor;
const previousSelect = document.body.style.userSelect;
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
const onMove = (move: PointerEvent) => {
/* Measured from the start of the drag, never accumulated — an
accumulated delta drifts away from the cursor. */
latest = startWidth + (move.clientX - startX);
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
setWidth(latest);
});
};
const onUp = () => {
if (frame) cancelAnimationFrame(frame);
setWidth(latest);
/* One write at the end of the drag, not three hundred during it. */
persist();
handle.removeAttribute("data-resizing");
document.body.style.cursor = previousCursor;
document.body.style.userSelect = previousSelect;
window.removeEventListener("pointermove", onMove);
window.removeEventListener("pointerup", onUp);
window.removeEventListener("pointercancel", onUp);
};
window.addEventListener("pointermove", onMove);
window.addEventListener("pointerup", onUp);
window.addEventListener("pointercancel", onUp);
};
const onKeyDown = (e: KeyboardEvent) => {
const step = e.altKey ? FINE_STEP : KEY_STEP;
if (e.key === "ArrowLeft") {
e.preventDefault();
setWidth(widthsRef.current[key] - step);
persist();
} else if (e.key === "ArrowRight") {
e.preventDefault();
setWidth(widthsRef.current[key] + step);
persist();
} else if (e.key === "Home" || e.key === "Enter") {
e.preventDefault();
setWidth(measured[key]);
persist();
}
};
/* The gesture every spreadsheet has trained for the seam. */
const onDoubleClick = (e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setWidth(measured[key]);
persist();
};
const swallowClick = (e: MouseEvent) => e.stopPropagation();
handle.addEventListener("pointerdown", onPointerDown);
handle.addEventListener("keydown", onKeyDown);
handle.addEventListener("dblclick", onDoubleClick);
handle.addEventListener("click", swallowClick);
cell.appendChild(handle);
cleanups.push(() => handle.remove());
}
layout();
const observer = new ResizeObserver(() => layout());
if (t.parentElement) observer.observe(t.parentElement);
cleanups.push(() => observer.disconnect());
}
/* -- sorting ------------------------------------------------------- */
if (canSort) {
for (const [i, cell] of cells.entries()) {
if ((cell.colSpan || 1) !== 1) continue;
if (cell.dataset.noSort !== undefined) continue;
const index = starts[i];
/* The `<th>` itself takes the interaction rather than a button
injected into it: a column header is already a `columnheader`, and
giving it a tab stop is the grid pattern. Injecting a button would
also put the sort control inside whatever layout the table author
chose for the label, and break it. */
cell.setAttribute("data-ledger-sortable", "");
cell.tabIndex = 0;
cell.setAttribute("aria-sort", "none");
/* Read, not assumed: the indicator hangs on the outside of a text
column and on the inside of a figure column. */
cell.dataset.align = getComputedStyle(cell).textAlign === "right" ? "right" : "left";
const onClick = () => toggleSort(cell, index);
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
toggleSort(cell, index);
};
cell.addEventListener("click", onClick);
cell.addEventListener("keydown", onKeyDown);
cleanups.push(() => {
cell.removeEventListener("click", onClick);
cell.removeEventListener("keydown", onKeyDown);
cell.removeAttribute("data-ledger-sortable");
delete cell.dataset.align;
cell.removeAttribute("aria-sort");
cell.removeAttribute("data-sorted");
cell.removeAttribute("tabindex");
});
}
/* A sort survives the table re-rendering underneath us. */
if (sortRef.current) {
const target = cells.findIndex((cell, i) => keyFor(cell, starts[i]) === sortRef.current?.column);
if (target >= 0) applySort(starts[target]);
paintSort();
}
}
/* -- row detail ---------------------------------------------------- */
if (onOpenRowRef.current) {
const body = t.tBodies[0];
const open = (row: HTMLTableRowElement) => {
const index = Number(row.dataset.ledgerOrder ?? -1);
onOpenRowRef.current?.(index, row);
};
const onDblClick = (e: MouseEvent) => {
const row = (e.target as HTMLElement | null)?.closest?.("tr");
if (row && body?.contains(row)) open(row as HTMLTableRowElement);
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "Enter") return;
const row = (e.target as HTMLElement | null)?.closest?.("tr");
if (row && body?.contains(row)) {
e.preventDefault();
open(row as HTMLTableRowElement);
}
};
host.addEventListener("dblclick", onDblClick);
host.addEventListener("keydown", onKeyDown);
if (body) body.dataset.ledgerOpenable = "";
cleanups.push(() => {
host.removeEventListener("dblclick", onDblClick);
host.removeEventListener("keydown", onKeyDown);
if (body) delete body.dataset.ledgerOpenable;
});
}
return () => {
for (const fn of cleanups) fn();
t.style.tableLayout = "";
t.style.width = "";
delete t.dataset.ledgerSized;
};
/* `children` is the dependency that matters: the table underneath can be
replaced by a filter or a period change, and the new one needs the
same treatment. */
}, [
applySort,
children,
headerRow,
hierarchical,
keyFor,
layout,
paintSort,
persist,
resizable,
sortable,
sortableRows,
storageKey,
table,
toggleSort,
]);
return (
<div className={cn("ledger-table-columns", className)} ref={hostRef} {...props}>
{children}
</div>
);
},
);
TableColumns.displayName = "TableColumns";
The accounting it encodes
Concepts this component renders. Each one is defined once, in the lexicon, and used identically in the code and the copy.
Also in Ledger & tables
- TransactionTableThe workhorse. Virtualized, sticky, priority-collapsing.
- TransactionRowDesktop row.Spec
- TransactionCardThe mobile re-rank.
- TransactionDetailFull detail sheet with attachments and audit trail.
- LedgerTableDouble-entry with debit/credit columns and a balance check.
- AgingTableAR/AP buckets, 0-30-60-90+.
- ReconciliationViewTwo-pane matcher. The match animation.
- CategoryPickerHierarchical with rule creation.
- FilterBarURL-param-backed filters with resolved-value chips.
- BulkActionBarSelection count + actions, safe-area aware.
- ColumnManagerShow/hide/reorder with drag.
- GroupPickerOne cut through a table, with subtotals per group.
- ExportMenuCSV/XLSX/PDF with a stated scope.
- ColumnHeaderThe sortable, draggable header cell.
- PeriodGridThe ruled grid. Frozen panes, hidden columns, hover change, ⌘C.
See also
A component is specified in one place, defined in another, and running in a third. All three, from here.
- Ledger & tables — the family specThe four-layer specification this component was built against, before it was written.Doc
- Ledger tables and transaction registersThe family running, with real figures in it, in both themes and at every density.Live
- CatalogAll 179 components, specified before they are written.Doc
- Chart of accountsThe account structureTerm
Elsewhere in the system
Where TableColumns sits in the rest of the system — the live section it renders in, the calculators that compute what it displays, and the ledger payloads it is fed from.
- Ledger tables and transaction registers, runningThe Ledger & tables family, live. TableColumns has no demo of its own there yet.Live
- Ledger & tablesThe 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.Doc
Components are copied into your repository and meant to be edited — rounding policy, fiscal calendar and compliance copy differ per company.
Told when Ledger & tables changes
Components are copied into your repo, so a fix here does not reach you on its own. One email when a component in this family changes in a way that needs a migration — and nothing when it does not.