{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "ledger-tables",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "Sort and resize for a table that renders on the server.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport {\n  ariaSort,\n  compareValues,\n  firstDirection,\n  nextSortState,\n  readCellSortValue,\n  type SortState,\n} from \"@/lib/ledger/table-sort\";\n\nexport interface TableColumnsProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * Row detail. Double-click a row, or press Enter on it — both, because\n   * double-click is the gesture people bring from every ledger they have used\n   * and Enter is the one that works without a mouse.\n   */\n  onOpenRow?: (index: number, row: HTMLTableRowElement) => void;\n  /** Reported after a sort, so the host can mirror it into the URL. */\n  onSortChange?: (sort: SortState) => void;\n  resizable?: boolean;\n  /** Off for a table whose order is itself the information — a journal entry. */\n  sortable?: boolean;\n  /**\n   * Persists the widths per user per table, under `ledger:cols:{storageKey}`.\n   * Omit it and the widths last as long as the mount does.\n   */\n  storageKey?: string;\n}\n\nconst MIN_WIDTH = 64;\nconst MAX_WIDTH = 720;\nconst KEY_STEP = 16;\nconst FINE_STEP = 1;\n\nconst storageKeyFor = (key: string) => `ledger:cols:${key}`;\n\n/** The column index a cell starts at, accounting for the spans before it. */\nfunction columnStarts(row: HTMLTableRowElement): number[] {\n  const starts: number[] = [];\n  let index = 0;\n  for (const cell of Array.from(row.cells)) {\n    starts.push(index);\n    index += cell.colSpan || 1;\n  }\n  return starts;\n}\n\n/** The cell in `row` covering column `target`, or null if a span swallowed it. */\nfunction cellAt(row: HTMLTableRowElement, target: number): HTMLTableCellElement | null {\n  let index = 0;\n  for (const cell of Array.from(row.cells)) {\n    const span = cell.colSpan || 1;\n    if (target >= index && target < index + span) return span === 1 ? cell : null;\n    index += span;\n  }\n  return null;\n}\n\n/**\n * Sortable, resizable columns for a table that renders on the server.\n *\n * The sibling of `GridKeyboard`, and the same argument: most ledger tables in\n * this system are deliberately server components, because a 400-row statement\n * should not ship 400 rows of JavaScript to be rendered again on the client.\n * But a bookkeeper arrives from Excel, and in Excel every column sorts and\n * every column drags. Losing that is a real cost, and paying for it by\n * rewriting nineteen tables into client components is a worse one.\n *\n * So this is a wrapper, not a rewrite. It reads the table the server produced:\n * header cells become sort controls and gain a resize handle, `<col>` elements\n * carry the widths, and the rows are re-ordered in place. Any table in the\n * catalog gets both behaviours without knowing this component exists.\n *\n * What it deliberately does **not** do:\n *\n * - **It never sorts a hierarchy.** A table with `data-level` rows (a chart of\n *   accounts) or `data-row-variant` rows (subtotals, section heads) has an\n *   order that carries meaning — a subtotal that floats away from the rows it\n *   totals is worse than an unsorted table. Those tables still resize.\n * - **It never sorts a `tfoot`.** Totals are not rows of the population.\n * - **It sorts the value, not the presentation** — `data-sort`, then the\n *   `data-raw` that `Money` already stamps, then the text. A column of\n *   `($1,240.56)` sorted as text is sorted by its parenthesis.\n * - **It does not paginate.** If the table shows a page of a larger set, sort\n *   it at the source; sorting the page answers the question with the wrong\n *   population. Pass `sortable={false}` and drive it from the host.\n */\nexport const TableColumns = React.forwardRef<HTMLDivElement, TableColumnsProps>(\n  (\n    { className, children, storageKey, sortable = true, resizable = true, onSortChange, onOpenRow, ...props },\n    forwardedRef,\n  ) => {\n    const hostRef = React.useRef<HTMLDivElement>(null);\n    React.useImperativeHandle(forwardedRef, () => hostRef.current as HTMLDivElement);\n\n    /* Refs rather than state throughout, for the same reason `GridKeyboard`\n       uses them: this component paints attributes and widths onto a DOM\n       somebody else rendered. It has nothing of its own to re-render, and\n       holding the sort in state would re-render the host's subtree on every\n       header click for no visual gain. */\n    const sortRef = React.useRef<SortState>(null);\n    const widthsRef = React.useRef<Record<string, number>>({});\n    const defaultsRef = React.useRef<Record<string, number>>({});\n    const onSortChangeRef = React.useRef(onSortChange);\n    onSortChangeRef.current = onSortChange;\n    const onOpenRowRef = React.useRef(onOpenRow);\n    onOpenRowRef.current = onOpenRow;\n\n    /* ------------------------------------------------------------ reading */\n\n    const table = React.useCallback(() => hostRef.current?.querySelector(\"table\") ?? null, []);\n\n    const headerRow = React.useCallback((): HTMLTableRowElement | null => {\n      const head = table()?.tHead;\n      /* The last row of the head is the one with the real columns — a banded\n         header puts group labels above it. */\n      return head?.rows.length ? head.rows[head.rows.length - 1] : null;\n    }, [table]);\n\n    /** The body rows that participate: no subtotals, no section heads. */\n    const sortableRows = React.useCallback((): HTMLTableRowElement[] => {\n      const body = table()?.tBodies?.[0];\n      if (!body) return [];\n      return Array.from(body.rows);\n    }, [table]);\n\n    const hierarchical = React.useCallback(() => {\n      const body = table()?.tBodies?.[0];\n      if (!body) return true;\n      return Array.from(body.rows).some(\n        (row) =>\n          row.hasAttribute(\"data-row-variant\") ||\n          row.querySelector(\"[data-level], [aria-level]\") !== null ||\n          Array.from(row.cells).some((c) => c.hasAttribute(\"data-level\") || c.hasAttribute(\"aria-level\")),\n      );\n    }, [table]);\n\n    /** The key a column's width is stored under — stable across re-renders. */\n    const keyFor = React.useCallback((cell: HTMLTableCellElement, index: number) => {\n      if (cell.dataset.column) return cell.dataset.column;\n      const label = (cell.textContent ?? \"\").trim().replace(/\\s+/g, \"-\").toLowerCase();\n      return label || `col-${index}`;\n    }, []);\n\n    /* ------------------------------------------------------------- widths */\n\n    const persist = React.useCallback(() => {\n      if (!storageKey) return;\n      try {\n        localStorage.setItem(storageKeyFor(storageKey), JSON.stringify(widthsRef.current));\n      } catch {\n        /* Not persisting is survivable. Not rendering is not. */\n      }\n    }, [storageKey]);\n\n    /**\n     * Writes the widths onto the `<col>` elements.\n     *\n     * The slack matters more than it sounds. Under `table-layout: fixed` a\n     * table narrower than its container leaves a gap at the right edge, and a\n     * ruled card with a gap in it reads as a rendering bug. So the spare space\n     * goes to one column — the widest one at first paint, which is reliably\n     * the label column — exactly as it does in every tool that has this\n     * feature. The numeric columns keep the width they were given.\n     */\n    const layout = React.useCallback(() => {\n      const t = table();\n      const head = headerRow();\n      if (!t || !head) return;\n\n      const group = t.querySelector<HTMLTableColElement>(\"colgroup[data-ledger-cols]\");\n      if (!group) return;\n      const cols = Array.from(group.children) as HTMLTableColElement[];\n      const cells = Array.from(head.cells);\n      const starts = columnStarts(head);\n\n      let total = 0;\n      const owned = new Map<number, HTMLTableCellElement>();\n      for (const [i, cell] of cells.entries()) {\n        if ((cell.colSpan || 1) !== 1) continue;\n        owned.set(starts[i], cell);\n      }\n      for (const [index, _col] of cols.entries()) {\n        const cell = owned.get(index);\n        const key = cell ? keyFor(cell, index) : `col-${index}`;\n        total += widthsRef.current[key] ?? defaultsRef.current[key] ?? 0;\n      }\n\n      const available = t.parentElement?.clientWidth ?? 0;\n      const flexIndex = Number(t.dataset.ledgerFlexCol ?? \"-1\");\n      const slack = Math.max(0, available - total);\n\n      for (const [index, col] of cols.entries()) {\n        const cell = owned.get(index);\n        const key = cell ? keyFor(cell, index) : `col-${index}`;\n        const width = widthsRef.current[key] ?? defaultsRef.current[key] ?? 0;\n        col.style.width = `${index === flexIndex ? width + slack : width}px`;\n      }\n      t.style.tableLayout = \"fixed\";\n      t.style.width = `${total + slack}px`;\n      /* Only now does the table become a fixed-layout one, and only now do its\n         body cells start clipping — see the note in tokens.css. */\n      t.dataset.ledgerSized = \"\";\n    }, [headerRow, keyFor, table]);\n\n    /* ------------------------------------------------------------ sorting */\n\n    const paintSort = React.useCallback(() => {\n      const head = headerRow();\n      if (!head) return;\n      const starts = columnStarts(head);\n      for (const [i, cell] of Array.from(head.cells).entries()) {\n        if (!cell.hasAttribute(\"data-ledger-sortable\")) continue;\n        const key = keyFor(cell, starts[i]);\n        const state = sortRef.current;\n        cell.setAttribute(\"aria-sort\", ariaSort(state, key));\n        if (state?.column === key) cell.setAttribute(\"data-sorted\", state.direction);\n        else cell.removeAttribute(\"data-sorted\");\n      }\n    }, [headerRow, keyFor]);\n\n    const applySort = React.useCallback(\n      (columnIndex: number) => {\n        const t = table();\n        const body = t?.tBodies?.[0];\n        if (!t || !body) return;\n        const state = sortRef.current;\n        const rows = sortableRows();\n\n        const ordered = rows\n          .map((row, index) => ({\n            row,\n            /* The order the ledger gave us, so the third click can restore it. */\n            source: Number(row.dataset.ledgerOrder ?? index),\n            value: state ? readCellSortValue(cellAt(row, columnIndex) ?? row) : null,\n          }))\n          .sort((a, b) =>\n            state\n              ? compareValues(a.value, b.value, state.direction) || a.source - b.source\n              : a.source - b.source,\n          );\n\n        /* Moving the nodes React rendered is safe here: React holds its own\n           references to them and re-inserts them in its order on the next\n           render, so a re-render corrects the DOM rather than corrupting it. */\n        const fragment = document.createDocumentFragment();\n        for (const entry of ordered) fragment.appendChild(entry.row);\n        body.appendChild(fragment);\n      },\n      [sortableRows, table],\n    );\n\n    const toggleSort = React.useCallback(\n      (cell: HTMLTableCellElement, columnIndex: number) => {\n        const key = keyFor(cell, columnIndex);\n        const rows = sortableRows();\n        const sample = rows.length ? readCellSortValue(cellAt(rows[0], columnIndex) ?? rows[0]) : undefined;\n        sortRef.current = nextSortState(sortRef.current, key, firstDirection(sample));\n        applySort(columnIndex);\n        paintSort();\n        onSortChangeRef.current?.(sortRef.current);\n      },\n      [applySort, keyFor, paintSort, sortableRows],\n    );\n\n    /* ------------------------------------------------------------ enhance */\n\n    React.useEffect(() => {\n      const t = table();\n      const head = headerRow();\n      const host = hostRef.current;\n      if (!t || !head || !host) return;\n\n      const cleanups: (() => void)[] = [];\n      const cells = Array.from(head.cells);\n      const starts = columnStarts(head);\n      const columnCount = starts.length\n        ? starts[starts.length - 1] + (cells[cells.length - 1].colSpan || 1)\n        : 0;\n      /* Three ways a table opts out, and the middle one matters most: the\n         component itself declares it. A register's running balance is only\n         a running balance in date order, and that is knowledge the register\n         has and its caller does not. */\n      const canSort = sortable && t.dataset.sortable !== \"false\" && !hierarchical();\n\n      /* Source order, recorded once, so \"no sort\" is a state we can return to. */\n      for (const [index, row] of sortableRows().entries()) {\n        if (row.dataset.ledgerOrder === undefined) row.dataset.ledgerOrder = String(index);\n      }\n\n      /* -- widths -------------------------------------------------------- */\n      if (resizable && columnCount > 0) {\n        /* Adopt a `<colgroup>` the table already has rather than adding a\n           second one: colgroups concatenate, so a new one inserted in front of\n           an existing four-col group would define columns 1–4 twice and shift\n           every width by four columns. */\n        let group = t.querySelector<HTMLTableColElement>(\"colgroup\");\n        if (group && group.children.length !== columnCount) group = null;\n        if (group) {\n          group.setAttribute(\"data-ledger-cols\", \"\");\n          const adopted = group;\n          const original = Array.from(adopted.children).map((col) => (col as HTMLElement).style.width);\n          cleanups.push(() => {\n            adopted.removeAttribute(\"data-ledger-cols\");\n            for (const [i, col] of Array.from(adopted.children).entries()) {\n              (col as HTMLElement).style.width = original[i] ?? \"\";\n            }\n          });\n        } else {\n          group = document.createElement(\"colgroup\");\n          group.setAttribute(\"data-ledger-cols\", \"\");\n          for (let i = 0; i < columnCount; i += 1) group.appendChild(document.createElement(\"col\"));\n          t.insertBefore(group, t.firstChild);\n          const created = group;\n          cleanups.push(() => created.remove());\n        }\n\n        /* Measure before anything is written, or the first column measured\n           changes the width of the ones after it. */\n        let widest = { index: -1, width: 0 };\n        const measured: Record<string, number> = {};\n        for (const [i, cell] of cells.entries()) {\n          if ((cell.colSpan || 1) !== 1) continue;\n          const index = starts[i];\n          const width = Math.round(cell.getBoundingClientRect().width);\n          measured[keyFor(cell, index)] = width;\n          if (cell.dataset.flexColumn !== undefined) widest = { index, width: Number.POSITIVE_INFINITY };\n          else if (width > widest.width) widest = { index, width };\n        }\n        defaultsRef.current = measured;\n        widthsRef.current = { ...measured, ...widthsRef.current };\n        t.dataset.ledgerFlexCol = String(widest.index);\n\n        if (storageKey) {\n          try {\n            const raw = localStorage.getItem(storageKeyFor(storageKey));\n            if (raw) {\n              const stored = JSON.parse(raw) as Record<string, number>;\n              for (const [key, value] of Object.entries(stored)) {\n                /* A stored width for a column that no longer exists is dropped,\n                   and one outside today's bounds is clamped rather than\n                   trusted — the table's shape outlives the preference. */\n                if (key in measured && typeof value === \"number\" && Number.isFinite(value)) {\n                  widthsRef.current[key] = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value));\n                }\n              }\n            }\n          } catch {\n            /* The measured widths are a correct table. Nothing to recover. */\n          }\n        }\n\n        for (const [i, cell] of cells.entries()) {\n          if ((cell.colSpan || 1) !== 1) continue;\n          if (cell.dataset.noResize !== undefined) continue;\n          const index = starts[i];\n          const key = keyFor(cell, index);\n          const label = (cell.textContent ?? \"\").trim() || `column ${index + 1}`;\n\n          if (getComputedStyle(cell).position === \"static\") cell.style.position = \"relative\";\n\n          const handle = document.createElement(\"div\");\n          handle.className = \"ledger-col-resizer\";\n          handle.setAttribute(\"role\", \"separator\");\n          handle.setAttribute(\"aria-orientation\", \"vertical\");\n          handle.setAttribute(\"aria-label\", `Resize ${label} column`);\n          handle.tabIndex = 0;\n\n          const setWidth = (value: number) => {\n            widthsRef.current[key] = Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, value)));\n            handle.setAttribute(\"aria-valuenow\", String(widthsRef.current[key]));\n            layout();\n          };\n          setWidth(widthsRef.current[key] ?? measured[key]);\n\n          const onPointerDown = (e: PointerEvent) => {\n            if (e.button !== 0) return;\n            e.preventDefault();\n            e.stopPropagation();\n            const startX = e.clientX;\n            const startWidth = widthsRef.current[key];\n            let frame = 0;\n            let latest = startWidth;\n            handle.toggleAttribute(\"data-resizing\", true);\n            const previousCursor = document.body.style.cursor;\n            const previousSelect = document.body.style.userSelect;\n            document.body.style.cursor = \"col-resize\";\n            document.body.style.userSelect = \"none\";\n\n            const onMove = (move: PointerEvent) => {\n              /* Measured from the start of the drag, never accumulated — an\n                 accumulated delta drifts away from the cursor. */\n              latest = startWidth + (move.clientX - startX);\n              if (frame) return;\n              frame = requestAnimationFrame(() => {\n                frame = 0;\n                setWidth(latest);\n              });\n            };\n            const onUp = () => {\n              if (frame) cancelAnimationFrame(frame);\n              setWidth(latest);\n              /* One write at the end of the drag, not three hundred during it. */\n              persist();\n              handle.removeAttribute(\"data-resizing\");\n              document.body.style.cursor = previousCursor;\n              document.body.style.userSelect = previousSelect;\n              window.removeEventListener(\"pointermove\", onMove);\n              window.removeEventListener(\"pointerup\", onUp);\n              window.removeEventListener(\"pointercancel\", onUp);\n            };\n            window.addEventListener(\"pointermove\", onMove);\n            window.addEventListener(\"pointerup\", onUp);\n            window.addEventListener(\"pointercancel\", onUp);\n          };\n\n          const onKeyDown = (e: KeyboardEvent) => {\n            const step = e.altKey ? FINE_STEP : KEY_STEP;\n            if (e.key === \"ArrowLeft\") {\n              e.preventDefault();\n              setWidth(widthsRef.current[key] - step);\n              persist();\n            } else if (e.key === \"ArrowRight\") {\n              e.preventDefault();\n              setWidth(widthsRef.current[key] + step);\n              persist();\n            } else if (e.key === \"Home\" || e.key === \"Enter\") {\n              e.preventDefault();\n              setWidth(measured[key]);\n              persist();\n            }\n          };\n\n          /* The gesture every spreadsheet has trained for the seam. */\n          const onDoubleClick = (e: MouseEvent) => {\n            e.preventDefault();\n            e.stopPropagation();\n            setWidth(measured[key]);\n            persist();\n          };\n          const swallowClick = (e: MouseEvent) => e.stopPropagation();\n\n          handle.addEventListener(\"pointerdown\", onPointerDown);\n          handle.addEventListener(\"keydown\", onKeyDown);\n          handle.addEventListener(\"dblclick\", onDoubleClick);\n          handle.addEventListener(\"click\", swallowClick);\n          cell.appendChild(handle);\n          cleanups.push(() => handle.remove());\n        }\n\n        layout();\n        const observer = new ResizeObserver(() => layout());\n        if (t.parentElement) observer.observe(t.parentElement);\n        cleanups.push(() => observer.disconnect());\n      }\n\n      /* -- sorting ------------------------------------------------------- */\n      if (canSort) {\n        for (const [i, cell] of cells.entries()) {\n          if ((cell.colSpan || 1) !== 1) continue;\n          if (cell.dataset.noSort !== undefined) continue;\n          const index = starts[i];\n\n          /* The `<th>` itself takes the interaction rather than a button\n             injected into it: a column header is already a `columnheader`, and\n             giving it a tab stop is the grid pattern. Injecting a button would\n             also put the sort control inside whatever layout the table author\n             chose for the label, and break it. */\n          cell.setAttribute(\"data-ledger-sortable\", \"\");\n          cell.tabIndex = 0;\n          cell.setAttribute(\"aria-sort\", \"none\");\n          /* Read, not assumed: the indicator hangs on the outside of a text\n             column and on the inside of a figure column. */\n          cell.dataset.align = getComputedStyle(cell).textAlign === \"right\" ? \"right\" : \"left\";\n\n          const onClick = () => toggleSort(cell, index);\n          const onKeyDown = (e: KeyboardEvent) => {\n            if (e.key !== \"Enter\" && e.key !== \" \") return;\n            e.preventDefault();\n            toggleSort(cell, index);\n          };\n          cell.addEventListener(\"click\", onClick);\n          cell.addEventListener(\"keydown\", onKeyDown);\n          cleanups.push(() => {\n            cell.removeEventListener(\"click\", onClick);\n            cell.removeEventListener(\"keydown\", onKeyDown);\n            cell.removeAttribute(\"data-ledger-sortable\");\n            delete cell.dataset.align;\n            cell.removeAttribute(\"aria-sort\");\n            cell.removeAttribute(\"data-sorted\");\n            cell.removeAttribute(\"tabindex\");\n          });\n        }\n        /* A sort survives the table re-rendering underneath us. */\n        if (sortRef.current) {\n          const target = cells.findIndex((cell, i) => keyFor(cell, starts[i]) === sortRef.current?.column);\n          if (target >= 0) applySort(starts[target]);\n          paintSort();\n        }\n      }\n\n      /* -- row detail ---------------------------------------------------- */\n      if (onOpenRowRef.current) {\n        const body = t.tBodies[0];\n        const open = (row: HTMLTableRowElement) => {\n          const index = Number(row.dataset.ledgerOrder ?? -1);\n          onOpenRowRef.current?.(index, row);\n        };\n        const onDblClick = (e: MouseEvent) => {\n          const row = (e.target as HTMLElement | null)?.closest?.(\"tr\");\n          if (row && body?.contains(row)) open(row as HTMLTableRowElement);\n        };\n        const onKeyDown = (e: KeyboardEvent) => {\n          if (e.key !== \"Enter\") return;\n          const row = (e.target as HTMLElement | null)?.closest?.(\"tr\");\n          if (row && body?.contains(row)) {\n            e.preventDefault();\n            open(row as HTMLTableRowElement);\n          }\n        };\n        host.addEventListener(\"dblclick\", onDblClick);\n        host.addEventListener(\"keydown\", onKeyDown);\n        if (body) body.dataset.ledgerOpenable = \"\";\n        cleanups.push(() => {\n          host.removeEventListener(\"dblclick\", onDblClick);\n          host.removeEventListener(\"keydown\", onKeyDown);\n          if (body) delete body.dataset.ledgerOpenable;\n        });\n      }\n\n      return () => {\n        for (const fn of cleanups) fn();\n        t.style.tableLayout = \"\";\n        t.style.width = \"\";\n        delete t.dataset.ledgerSized;\n      };\n      /* `children` is the dependency that matters: the table underneath can be\n         replaced by a filter or a period change, and the new one needs the\n         same treatment. */\n    }, [\n      applySort,\n      children,\n      headerRow,\n      hierarchical,\n      keyFor,\n      layout,\n      paintSort,\n      persist,\n      resizable,\n      sortable,\n      sortableRows,\n      storageKey,\n      table,\n      toggleSort,\n    ]);\n\n    return (\n      <div className={cn(\"ledger-table-columns\", className)} ref={hostRef} {...props}>\n        {children}\n      </div>\n    );\n  },\n);\nTableColumns.displayName = \"TableColumns\";\n",
      "path": "components/ledger/table-columns.tsx",
      "target": "components/ledger/table-columns.tsx",
      "type": "registry:component"
    }
  ],
  "name": "table-columns",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/cn",
    "@lopifi/table-sort"
  ],
  "title": "TableColumns",
  "type": "registry:component"
}
