{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [
    "@base-ui-components/react",
    "@tanstack/react-virtual"
  ],
  "description": "The workhorse. Virtualized, sticky, priority-collapsing.",
  "files": [
    {
      "content": "\"use client\";\n\nimport { Checkbox } from \"@base-ui-components/react/checkbox\";\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { useColumnPriority, useScrolledSentinel } from \"@/hooks/ledger/use-column-priority\";\nimport { type ColumnSpec, useColumnWidths } from \"@/hooks/ledger/use-column-widths\";\nimport { useDensity } from \"@/hooks/ledger/use-density\";\nimport { useGridNav } from \"@/hooks/ledger/use-grid-nav\";\nimport { useTableSort } from \"@/hooks/ledger/use-table-sort\";\nimport type { ClipboardCell } from \"@/lib/ledger/clipboard\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDateShort } from \"@/lib/ledger/fiscal-period\";\nimport { rawMoney } from \"@/lib/ledger/format-money\";\nimport {\n  byMonthDescending,\n  bySubtotal,\n  flattenGroups,\n  type GroupedItem,\n  groupRows,\n  monthBucket,\n  visibleRows,\n} from \"@/lib/ledger/group-rows\";\nimport type { MoneyValue, PaymentStatus } from \"@/lib/ledger/money.types\";\nimport type { SortState } from \"@/lib/ledger/table-sort\";\nimport { ColumnHeader } from \"@/components/ledger/column-header\";\nimport { EntityMark } from \"@/components/ledger/entity-mark\";\nimport { CheckIcon, ChevronDownIcon, MinusIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\nimport { Reference } from \"@/components/ledger/reference\";\nimport { StatusPill } from \"@/components/ledger/status-pill\";\nimport { TransactionCard } from \"@/components/ledger/transaction-card\";\n\nexport type Transaction = {\n  id: string;\n  date: Date;\n  description: string;\n  merchant?: string;\n  category: string;\n  account: string;\n  accountLast4: string;\n  status: PaymentStatus;\n  amount: MoneyValue;\n  reference: string;\n  memo?: string;\n  createdBy?: string;\n  pending?: boolean;\n};\n\nexport interface TransactionTableProps {\n  className?: string;\n  /**\n   * Persists column widths per user, under `ledger:cols:{key}`. Omit it and\n   * the widths last as long as the mount does.\n   */\n  columnStorageKey?: string;\n  defaultGroupBy?: TransactionGroupKey;\n  defaultSort?: SortState;\n  /**\n   * The unfiltered row count, when a filter is active.\n   *\n   * A footer total that silently describes a subset is a data error with the\n   * same shape as exporting the page when the user expected the whole set —\n   * which is why `ExportMenu` already states its scope. Pass this and the\n   * total says what it covers; leave it out and nothing is claimed.\n   */\n  filteredFrom?: number;\n  /**\n   * Cut the rows into groups, each with its own header and subtotal.\n   *\n   * `undefined` is a flat table, which is what a transaction list should open\n   * as: grouping is an answer to a question, and the reader has not asked one\n   * yet. Controlled when `onGroupByChange` is given.\n   *\n   * The subtotals are computed from the rows in each group, which is the only\n   * honest thing they can be — but note that this makes them a statement about\n   *these* rows and not about the filtered set behind them. Pass\n   * `filteredFrom` when the two differ and the header says so.\n   */\n  groupBy?: TransactionGroupKey;\n  /** Told after a ⌘C, so the host can raise a toast. */\n  onCopyRange?: (rows: number, cols: number, ok: boolean) => void;\n  onGroupByChange?: (key: TransactionGroupKey | undefined) => void;\n  onOpen?: (t: Transaction) => void;\n  onSelectionChange?: (ids: string[]) => void;\n  onSortChange?: (sort: SortState) => void;\n  selection?: string[];\n  /**\n   * Sort order.\n   *\n   * Left alone, the table sorts its own rows. Pass `sort` **and**\n   * `onSortChange` and the host owns it — which is the correct arrangement the\n   * moment these rows are a page of a larger set, because sorting the page\n   * answers \"largest amount\" with the largest of ninety-four rows out of\n   * twelve thousand, and looks exactly like the truth.\n   */\n  sort?: SortState;\n  /** Passed in, already computed — a component that sums its own rows will\n      eventually disagree with the backend, and the backend is right. */\n  total: MoneyValue;\n  transactions: Transaction[];\n}\n\nconst ROW_HEIGHTS = { comfortable: 48, compact: 32, default: 40 } as const;\n\n/**\n * What a transaction list can be cut by.\n *\n * These five and no more, because each one is a question somebody actually\n * arrives with: where did it go (category), which account (account), what\n * shape is the month (month), what still needs a human (status), and who did\n * we pay (merchant). A grouping by reference or by id would be a grouping by\n * a field no two rows share.\n *\n * Category and merchant come out biggest-first, because the question behind\n * them is \"where did it all go\" and the answer is the top of that list. Month\n * comes out newest-first, which is the order a ledger is read in. Status and\n * account keep the table's own row order, so the sort the reader chose\n * survives the grouping.\n */\nexport type TransactionGroupKey = \"category\" | \"account\" | \"month\" | \"status\" | \"merchant\";\n\nexport const TRANSACTION_GROUPS: { key: TransactionGroupKey; label: string }[] = [\n  { key: \"category\", label: \"Category\" },\n  { key: \"account\", label: \"Account\" },\n  { key: \"month\", label: \"Month\" },\n  { key: \"status\", label: \"Status\" },\n  { key: \"merchant\", label: \"Payee\" },\n];\n\nconst GROUP_SPECS: Record<\n  TransactionGroupKey,\n  {\n    keyOf: (t: Transaction) => { key: string; label: string } | null;\n    compare?: Parameters<typeof groupRows<Transaction>>[1][\"compare\"];\n  }\n> = {\n  account: {\n    keyOf: (t) =>\n      t.account ? { key: `${t.account}-${t.accountLast4}`, label: `${t.account} ••${t.accountLast4}` } : null,\n  },\n  category: {\n    compare: bySubtotal,\n    keyOf: (t) => (t.category ? { key: t.category, label: t.category } : null),\n  },\n  merchant: {\n    compare: bySubtotal,\n    keyOf: (t) => {\n      const label = t.merchant ?? t.description;\n      return label ? { key: label, label } : null;\n    },\n  },\n  month: { compare: byMonthDescending, keyOf: (t) => monthBucket(t.date) },\n  status: { keyOf: (t) => (t.status ? { key: t.status, label: t.status } : null) },\n};\n\n/**\n * The shipped widths, in px.\n *\n * Resizable because account names, memos and vendor names have no natural\n * length: the width that fits one client's chart of accounts truncates\n * another's on every row, and two truncated account names look identical.\n * `description` is the flexible one — it takes the slack, and every figure\n * column keeps the width it was given.\n */\nconst COLUMN_SPECS: ColumnSpec[] = [\n  { id: \"date\", max: 160, min: 64, width: 80 },\n  { id: \"description\", max: 640, min: 160, width: 280 },\n  { id: \"category\", min: 80, width: 128 },\n  { id: \"account\", min: 96, width: 144 },\n  { id: \"status\", min: 96, width: 128 },\n  { id: \"reference\", min: 96, width: 160 },\n  { id: \"amount\", max: 240, min: 96, width: 128 },\n];\n\n/**\n * Workflow order, not alphabetical.\n *\n * Sorting a status column A–Z puts `cancelled` above `overdue`, which is the\n * opposite of the order the work needs doing in. This is an urgency scale —\n * high is \"needs a human\", low is \"closed\" — so the first click, which opens\n * a numeric column at its big end, puts the exceptions on top.\n */\nconst STATUS_RANK: Record<string, number> = {\n  cancelled: 0,\n  draft: 9,\n  failed: 13,\n  \"in-transit\": 5,\n  matched: 3,\n  \"needs-review\": 10,\n  overdue: 11,\n  pending: 8,\n  processing: 7,\n  reconciled: 1,\n  returned: 12,\n  scheduled: 6,\n  sent: 4,\n  settled: 2,\n};\n\n/** Every column sorts on its value, not on the string the reader sees. */\nconst SORT_ACCESSORS = {\n  account: (t: Transaction) => `${t.account} ${t.accountLast4}`,\n  amount: (t: Transaction) => Number(t.amount.amount),\n  category: (t: Transaction) => t.category,\n  date: (t: Transaction) => t.date,\n  description: (t: Transaction) => t.merchant ?? t.description,\n  reference: (t: Transaction) => t.reference,\n  status: (t: Transaction) => STATUS_RANK[t.status] ?? -1,\n};\n\n/**\n * The workhorse. Virtualized past 100 rows with correct aria-rowcount, four\n * responsive presentations, a full grid keyboard model with exactly one tab\n * stop, and no zebra striping — row dividers instead, because striping\n * doubles the background changes on a scrolling surface and fights with\n * hover and selection.\n *\n * Tier 0 motion almost entirely: row hover is background-color at 100ms.\n * Sort, filter and paginate do not animate.\n */\nexport function TransactionTable({\n  transactions,\n  total,\n  onOpen,\n  selection = [],\n  onSelectionChange,\n  onCopyRange,\n  filteredFrom,\n  sort,\n  defaultSort = null,\n  onSortChange,\n  columnStorageKey,\n  groupBy,\n  defaultGroupBy,\n  onGroupByChange,\n  className,\n}: TransactionTableProps) {\n  const { ref: containerRef, presentation } = useColumnPriority();\n  const { resolved: density } = useDensity();\n  const { sentinelRef, scrolled } = useScrolledSentinel();\n  const scrollRef = React.useRef<HTMLDivElement>(null);\n  const [expanded, setExpanded] = React.useState<string | null>(null);\n  const rowHeight = ROW_HEIGHTS[density];\n\n  /* Amount sorts on the minor units, never on the rendered \"($1,240.56)\" —\n     that string sorts by its opening parenthesis. Status sorts on urgency\n     rather than the alphabet. Both are the same rule: sort the value. */\n  const sorting = useTableSort<Transaction>({\n    accessors: SORT_ACCESSORS,\n    defaultSort,\n    onSortChange,\n    rows: transactions,\n    sort,\n  });\n  const sorted = sorting.sorted;\n\n  /* Uncontrolled unless the host asks for control — the same arrangement the\n     sort uses, and for the same reason: the moment these rows are a page of a\n     larger set, grouping them is the host's business. */\n  const [ownGroupBy, setOwnGroupBy] = React.useState(defaultGroupBy);\n  const activeGroupBy = onGroupByChange ? groupBy : (groupBy ?? ownGroupBy);\n  const _setGroupBy = (key: TransactionGroupKey | undefined) => {\n    if (!onGroupByChange) setOwnGroupBy(key);\n    onGroupByChange?.(key);\n  };\n\n  const [collapsed, setCollapsed] = React.useState<ReadonlySet<string>>(new Set());\n  /* A key from the last grouping is meaningless under the next one, and a\n     stale collapse is a group the reader cannot find. */\n  React.useEffect(() => setCollapsed(new Set()), [activeGroupBy]);\n\n  const groups = React.useMemo(() => {\n    if (!activeGroupBy) return null;\n    const spec = GROUP_SPECS[activeGroupBy];\n    return groupRows(sorted, {\n      amountOf: (t) => Number(t.amount.amount),\n      compare: spec.compare,\n      keyOf: spec.keyOf,\n    });\n  }, [sorted, activeGroupBy]);\n\n  /* One flat list either way, so the virtualiser measures one thing and the\n     keyboard model counts one thing. Ungrouped, every item is a row. */\n  const items = React.useMemo<GroupedItem<Transaction>[]>(\n    () =>\n      groups\n        ? flattenGroups(groups, collapsed)\n        : sorted.map((row) => ({\n            group: { count: sorted.length, key: \"\", label: \"\", rows: sorted, subtotal: null },\n            indexInGroup: 0,\n            kind: \"row\" as const,\n            row,\n          })),\n    [groups, collapsed, sorted],\n  );\n\n  /* The rows the reader can actually reach right now. Arrow keys and ⌘C walk\n     this, not the full set — navigating into a collapsed group would move the\n     focus ring somewhere nobody can see it. */\n  const rows = React.useMemo(() => visibleRows(items), [items]);\n\n  const toggleGroup = (key: string) =>\n    setCollapsed((prev) => {\n      const next = new Set(prev);\n      if (next.has(key)) next.delete(key);\n      else next.add(key);\n      return next;\n    });\n\n  const widths = useColumnWidths({ columns: COLUMN_SPECS, storageKey: columnStorageKey });\n\n  const columns = React.useMemo(() => {\n    const base = [\"date\", \"description\", \"category\", \"account\", \"status\", \"amount\"];\n    if (presentation === \"full\") return [...base.slice(0, 5), \"reference\", \"amount\"];\n    if (presentation === \"expandable\") return base;\n    return [\"description\", \"date\", \"status\", \"account\", \"amount\"];\n  }, [presentation]);\n\n  /* Which position in `rows` each item holds, so a virtual item can address\n     the keyboard grid without counting group headers every render. */\n  const rowIndexOf = React.useMemo(() => {\n    const map: number[] = [];\n    let next = 0;\n    for (const item of items) map.push(item.kind === \"row\" ? next++ : -1);\n    return map;\n  }, [items]);\n\n  const virtualizer = useVirtualizer({\n    count: items.length,\n    /* A group header is one row tall plus its own breathing room; guessing one\n       height for both makes the scrollbar lie by a header per group. */\n    estimateSize: (i) => (items[i]?.kind === \"group\" ? rowHeight + 8 : rowHeight),\n    getScrollElement: () => scrollRef.current,\n    overscan: 12,\n  });\n\n  const allSelected = selection.length === transactions.length && transactions.length > 0;\n  const someSelected = selection.length > 0 && !allSelected;\n\n  const toggleAll = () => onSelectionChange?.(allSelected ? [] : transactions.map((t) => t.id));\n\n  const toggleOne = (id: string) =>\n    onSelectionChange?.(selection.includes(id) ? selection.filter((s) => s !== id) : [...selection, id]);\n\n  /* What a cell puts on the clipboard. Money goes across as its raw value —\n     \"-1240.56\", never \"($1,240.56)\" — because a column of accounting-formatted\n     text does not sum in the spreadsheet it just landed in. */\n  const getCell = React.useCallback(\n    (row: number, col: number): ClipboardCell => {\n      const t = rows[row];\n      const column = columns[col];\n      if (!t || !column) return { text: \"\" };\n      switch (column) {\n        case \"date\":\n          return { text: t.date.toISOString().slice(0, 10) };\n        case \"description\":\n          return { text: t.merchant ?? t.description };\n        case \"category\":\n          return { text: t.category };\n        case \"account\":\n          return { text: `${t.account} ••${t.accountLast4}` };\n        case \"status\":\n          return { text: t.status };\n        case \"reference\":\n          return { text: t.reference };\n        case \"amount\":\n          return { raw: rawMoney(t.amount), text: rawMoney(t.amount) };\n        default:\n          return { text: \"\" };\n      }\n    },\n    [columns, rows],\n  );\n\n  const grid = useGridNav({\n    colCount: columns.length,\n    getCell,\n    onActivate: (row) => {\n      const t = rows[row];\n      if (t) onOpen?.(t);\n    },\n    onCopyRange,\n    onEscape: () => onSelectionChange?.([]),\n    onToggleSelect: (row) => {\n      const t = rows[row];\n      if (t) toggleOne(t.id);\n    },\n    rowCount: rows.length,\n    scrollToRow: (row) => virtualizer.scrollToIndex(row, { align: \"auto\" }),\n  });\n\n  if (presentation === \"cards\") {\n    return (\n      <div className={className} ref={containerRef}>\n        <ul className=\"overflow-hidden rounded-xl border border-border bg-surface\">\n          {rows.map((t) => (\n            <TransactionCard\n              key={t.id}\n              onOpen={() => onOpen?.(t)}\n              onToggle={() => toggleOne(t.id)}\n              selected={selection.includes(t.id)}\n              transaction={t}\n            />\n          ))}\n        </ul>\n        <div className=\"mt-2 flex items-center justify-between rounded-md border border-border bg-surface px-4 py-3\">\n          <span className=\"text-label font-medium\">Total</span>\n          <Money accounting size=\"md\" value={total} />\n        </div>\n      </div>\n    );\n  }\n\n  const stickyEdges = presentation === \"sticky-edges\";\n  const filtered = filteredFrom != null && filteredFrom !== transactions.length;\n\n  return (\n    <div className={cn(\"w-full\", className)} ref={containerRef}>\n      <div\n        className=\"relative max-h-[520px] overflow-auto rounded-xl border border-border bg-surface scrollbar-thin\"\n        ref={scrollRef}\n        style={{ overscrollBehavior: \"contain\" }}\n      >\n        <div className=\"h-px\" ref={sentinelRef} />\n        <table\n          aria-label=\"Transactions\"\n          aria-rowcount={transactions.length}\n          className=\"w-full border-collapse\"\n          onKeyDown={grid.onKeyDown}\n          role=\"grid\"\n          tabIndex={0}\n        >\n          <thead\n            className={cn(\n              \"sticky top-0 z-sticky bg-surface transition-shadow duration-2 ease-standard\",\n              scrolled && \"shadow-elevation-1\",\n            )}\n          >\n            <tr className=\"flex w-full items-center\">\n              <th className=\"w-10 shrink-0 px-3 py-2 text-left\" scope=\"col\">\n                <Checkbox.Root\n                  aria-label=\"Select all transactions\"\n                  checked={allSelected}\n                  className=\"grid size-4 place-items-center rounded-xs border border-border-strong bg-surface data-[checked]:border-accent data-[checked]:bg-accent data-[indeterminate]:border-accent data-[indeterminate]:bg-accent\"\n                  indeterminate={someSelected && !allSelected}\n                  onCheckedChange={toggleAll}\n                >\n                  <Checkbox.Indicator className=\"text-accent-fg\">\n                    {someSelected ? <MinusIcon className=\"size-3\" /> : <CheckIcon className=\"size-3\" />}\n                  </Checkbox.Indicator>\n                </Checkbox.Root>\n              </th>\n              {columns.map((col) => (\n                <ColumnHeader\n                  align={col === \"amount\" ? \"right\" : \"left\"}\n                  className={cn(\n                    col === \"amount\" && \"ml-auto\",\n                    stickyEdges &&\n                      col === \"description\" &&\n                      \"sticky left-10 z-sticky border-r border-border-subtle bg-surface\",\n                    stickyEdges &&\n                      col === \"amount\" &&\n                      \"sticky right-0 z-sticky border-l border-border-subtle bg-surface\",\n                  )}\n                  column={col}\n                  direction={sorting.direction(col)}\n                  key={col}\n                  onSort={sorting.toggle}\n                  resizer={\n                    widths.isResizable(col)\n                      ? {\n                          active: widths.resizing === col,\n                          max: COLUMN_SPECS.find((c) => c.id === col)?.max,\n                          min: COLUMN_SPECS.find((c) => c.id === col)?.min,\n                          onKeyDown: (e) => widths.onResizeKeyDown(col, e),\n                          onPointerDown: (e) => widths.startResize(col, e),\n                          onReset: () => widths.reset(col),\n                          width: widths.widths[col],\n                        }\n                      : undefined\n                  }\n                  sortable={sorting.isSortable(col)}\n                  style={cellStyle(col, widths.widths[col])}\n                >\n                  {HEADERS[col] ?? col}\n                </ColumnHeader>\n              ))}\n              {presentation === \"expandable\" ? <th className=\"w-10 shrink-0\" scope=\"col\" /> : null}\n            </tr>\n          </thead>\n          <tbody style={{ height: virtualizer.getTotalSize(), position: \"relative\" }}>\n            {virtualizer.getVirtualItems().map((virtualRow) => {\n              const item = items[virtualRow.index];\n              if (!item) return null;\n\n              const position = {\n                height: virtualRow.size,\n                transform: `translateY(${virtualRow.start}px)`,\n              } as const;\n\n              /* The group header. It carries the count and the subtotal, so a\n                 collapsed group is still an answer rather than a closed door —\n                 and the count is printed rather than implied, because the\n                 subtotal is a statement about exactly those rows. */\n              if (item.kind === \"group\") {\n                const { group } = item;\n                return (\n                  <tr\n                    className=\"absolute left-0 flex w-full items-center border-b border-border bg-surface-sunken\"\n                    key={`group-${group.key}`}\n                    style={position}\n                  >\n                    <th\n                      className=\"flex w-full items-center gap-2 px-3 text-left font-normal\"\n                      colSpan={columns.length + 1}\n                      scope=\"colgroup\"\n                    >\n                      <button\n                        aria-expanded={!item.collapsed}\n                        className=\"flex flex-1 items-center gap-1.5 text-left\"\n                        onClick={() => toggleGroup(group.key)}\n                        type=\"button\"\n                      >\n                        <ChevronDownIcon\n                          aria-hidden=\"true\"\n                          className={cn(\n                            \"size-4 shrink-0 text-text-tertiary transition-transform duration-2 ease-standard\",\n                            item.collapsed && \"-rotate-90\",\n                          )}\n                        />\n                        <span className=\"text-label font-medium\">{group.label}</span>\n                        <span className=\"numeric text-caption text-text-tertiary\">\n                          {group.count.toLocaleString()}\n                        </span>\n                      </button>\n                      {group.subtotal === null ? null : (\n                        <Money\n                          accounting\n                          size=\"sm\"\n                          value={{ amount: group.subtotal, currency: total.currency }}\n                        />\n                      )}\n                    </th>\n                  </tr>\n                );\n              }\n\n              const t = item.row;\n              const rowIndex = rowIndexOf[virtualRow.index] ?? 0;\n              const selected = selection.includes(t.id);\n              return (\n                <tr\n                  aria-rowindex={rowIndex + 1}\n                  aria-selected={selected}\n                  className={cn(\n                    \"absolute left-0 flex w-full items-center border-b border-border-subtle\",\n                    // Row hover is background-color only. No transform, no lift.\n                    \"transition-colors duration-1 ease-standard hover:bg-surface-hover\",\n                    selected && \"bg-surface-selected\",\n                    // Pending is the warning rule and the status, never a dimmed\n                    // row: at 60% its secondary text fails contrast.\n                    t.pending && \"border-l-2 border-l-[var(--warning)]\",\n                  )}\n                  data-pending={t.pending || undefined}\n                  key={t.id}\n                  style={position}\n                >\n                  <td className=\"w-10 shrink-0 px-3\">\n                    <Checkbox.Root\n                      aria-label={`Select ${t.description}`}\n                      checked={selected}\n                      className=\"grid size-4 place-items-center rounded-xs border border-border-strong bg-surface data-[checked]:border-accent data-[checked]:bg-accent\"\n                      onCheckedChange={() => toggleOne(t.id)}\n                    >\n                      <Checkbox.Indicator className=\"text-accent-fg\">\n                        <CheckIcon className=\"size-3\" />\n                      </Checkbox.Indicator>\n                    </Checkbox.Root>\n                  </td>\n                  {columns.map((col, ci) => (\n                    <Cell\n                      column={col}\n                      focused={grid.cell.row === rowIndex && grid.cell.col === ci}\n                      key={col}\n                      onOpen={() => onOpen?.(t)}\n                      ranged={grid.inRange(rowIndex, ci)}\n                      sticky={\n                        stickyEdges\n                          ? col === \"description\"\n                            ? \"start\"\n                            : col === \"amount\"\n                              ? \"end\"\n                              : undefined\n                          : undefined\n                      }\n                      transaction={t}\n                      width={widths.widths[col]}\n                    />\n                  ))}\n                  {presentation === \"expandable\" ? (\n                    <td className=\"w-10 shrink-0 px-3\">\n                      <button\n                        aria-expanded={expanded === t.id}\n                        aria-label={`Show details for ${t.description}`}\n                        className=\"press grid size-6 place-items-center rounded-xs text-text-tertiary hover:bg-surface-hover\"\n                        onClick={() => setExpanded(expanded === t.id ? null : t.id)}\n                        type=\"button\"\n                      >\n                        <ChevronDownIcon\n                          className={cn(\n                            \"size-4 transition-transform duration-2 ease-standard\",\n                            expanded === t.id && \"rotate-180\",\n                          )}\n                        />\n                      </button>\n                    </td>\n                  ) : null}\n                </tr>\n              );\n            })}\n          </tbody>\n        </table>\n      </div>\n\n      {/* Sticky total footer — the number people are actually looking for,\n          and the scope it actually covers. */}\n      <div className=\"mt-2 flex flex-wrap items-center justify-between gap-2 rounded-md border border-border bg-surface px-4 py-3\">\n        <span className=\"text-label font-medium\">{transactions.length.toLocaleString()} transactions</span>\n        <span className=\"flex items-baseline gap-3\">\n          <span className=\"text-label text-text-secondary\">\n            {filtered ? \"Total of filtered set\" : \"Total\"}\n          </span>\n          <Money accounting size=\"md\" value={total} />\n        </span>\n        {filtered ? (\n          <span className=\"w-full text-caption text-text-secondary\">\n            Filtered from <span className=\"numeric\">{filteredFrom?.toLocaleString()}</span> transactions. This\n            total covers the {transactions.length.toLocaleString()} shown, not all of them.\n          </span>\n        ) : null}\n      </div>\n    </div>\n  );\n}\n\n/**\n * Rows are flex (the virtualizer positions them absolutely), so the header row\n * must be flex too and share these exact widths. A table-layout header over\n * flex rows drifts by a column the moment any cell wraps — which is also why\n * the widths are inline numbers rather than utility classes now that they are\n * draggable: one source, written into both.\n *\n * `description` is the only column that grows. The slack in a wide viewport\n * has to land somewhere, and it lands on the free-text column rather than\n * being spread across the figures, which have no use for it.\n */\nfunction cellStyle(column: string, width?: number): React.CSSProperties {\n  const spec = COLUMN_SPECS.find((c) => c.id === column);\n  const resolved = width ?? spec?.width ?? 128;\n  if (column === \"description\") {\n    return { flex: `1 1 ${resolved}px`, minWidth: spec?.min ?? 160 };\n  }\n  return { flex: `0 0 ${resolved}px`, width: resolved };\n}\n\nconst HEADERS: Record<string, string> = {\n  account: \"Account\",\n  amount: \"Amount\",\n  category: \"Category\",\n  date: \"Date\",\n  description: \"Description\",\n  reference: \"Reference\",\n  status: \"Status\",\n};\n\nfunction Cell({\n  column,\n  transaction: t,\n  focused,\n  ranged,\n  sticky,\n  width,\n  onOpen,\n}: {\n  column: string;\n  transaction: Transaction;\n  focused: boolean;\n  ranged: boolean;\n  sticky?: \"start\" | \"end\";\n  width?: number;\n  onOpen: () => void;\n}) {\n  const style = cellStyle(column, width);\n  const base = cn(\n    \"min-w-0 px-3 py-0\",\n    column === \"amount\" ? \"ml-auto text-right\" : \"text-left\",\n    (column === \"category\" || column === \"account\") && \"truncate\",\n    // The range tint sits under the focus ring, so the anchor cell reads as\n    // both \"selected\" and \"where the keyboard is\".\n    ranged && \"bg-accent-subtle\",\n    // outline, never box-shadow: a sticky cell must not clip its focus ring.\n    focused && \"outline outline-2 outline-offset-[-2px] outline-[var(--accent)]\",\n    sticky === \"start\" && \"sticky left-10 z-sticky border-r border-border-subtle bg-[inherit]\",\n    sticky === \"end\" && \"sticky right-0 z-sticky border-l border-border-subtle bg-[inherit]\",\n  );\n\n  switch (column) {\n    case \"date\":\n      return (\n        <td className={base} style={style}>\n          <span className=\"numeric text-figure-sm text-text-secondary\">{formatDateShort(t.date)}</span>\n        </td>\n      );\n    case \"description\":\n      return (\n        <td className={base} style={style}>\n          <button\n            className=\"flex min-w-0 items-center gap-2 text-left\"\n            onClick={onOpen}\n            tabIndex={-1}\n            type=\"button\"\n          >\n            <EntityMark name={t.merchant ?? t.description} size={20} />\n            <span className=\"min-w-0\">\n              {/* The label truncates. The number does not. */}\n              <span className=\"block truncate text-body\" title={t.description}>\n                {t.description}\n              </span>\n              {t.merchant && t.merchant !== t.description ? (\n                <span className=\"block truncate text-caption text-text-secondary\">{t.merchant}</span>\n              ) : null}\n            </span>\n          </button>\n        </td>\n      );\n    case \"category\":\n      return (\n        <td className={base} style={style}>\n          <span className=\"text-body text-text-secondary\">{t.category}</span>\n        </td>\n      );\n    case \"account\":\n      return (\n        <td className={base} style={style}>\n          <span className=\"text-caption text-text-secondary\">\n            {t.account} <span className=\"reference-numeric\">••{t.accountLast4}</span>\n          </span>\n        </td>\n      );\n    case \"status\":\n      return (\n        <td className={base} style={style}>\n          <StatusPill entity=\"Transaction\" status={t.status} />\n        </td>\n      );\n    case \"reference\":\n      return (\n        <td className={base} style={style}>\n          <Reference value={t.reference} />\n        </td>\n      );\n    case \"amount\":\n      return (\n        <td className={base} style={style}>\n          <Money accounting colorBySign value={t.amount} />\n        </td>\n      );\n    default:\n      return <td className={base} style={style} />;\n  }\n}\n",
      "path": "components/ledger/transaction-table.tsx",
      "target": "components/ledger/transaction-table.tsx",
      "type": "registry:component"
    }
  ],
  "name": "transaction-table",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/clipboard",
    "@lopifi/cn",
    "@lopifi/column-header",
    "@lopifi/entity-mark",
    "@lopifi/fiscal-period",
    "@lopifi/format-money",
    "@lopifi/group-rows",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/reference",
    "@lopifi/status-pill",
    "@lopifi/table-sort",
    "@lopifi/transaction-card",
    "@lopifi/use-column-priority",
    "@lopifi/use-column-widths",
    "@lopifi/use-density",
    "@lopifi/use-grid-nav",
    "@lopifi/use-table-sort"
  ],
  "title": "TransactionTable",
  "type": "registry:component"
}
