dodil/erp-ui

public/r/data-table.json

{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "data-table",
  "title": "DataTable",
  "description": "Lists by column kind: exact money sorting, pagination, loading/empty/error states, keyboard row opening.",
  "dependencies": [
    "@radix-ui/themes",
    "@tanstack/react-table@^9.3.1"
  ],
  "registryDependencies": [
    "@erp-ui/format",
    "@erp-ui/status-badge"
  ],
  "files": [
    {
      "path": "src/components/data-table/DataTable.tsx",
      "content": "/**\n * DataTable — the list every ERP screen starts from.\n *\n * Columns are described by KIND (text, number, money, date, status) so formatting and alignment are the\n * same in every module: money right-aligned in tabular figures and sorted EXACTLY (as decimals, never as\n * floats), dates formatted one way, statuses as badges. `cell` is the escape hatch for anything else.\n *\n * Built on TanStack Table 9 (sorting, pagination) and Radix Themes' Table. Loading, error and empty are\n * states of the table itself, so no screen forgets one.\n */\nimport {\n  createColumnHelper,\n  createPaginatedRowModel,\n  createSortedRowModel,\n  rowPaginationFeature,\n  rowSortingFeature,\n  sortFns,\n  tableFeatures,\n  useTable,\n  type ColumnDef,\n  type RowData,\n  type SortingState,\n} from \"@tanstack/react-table\";\nimport { Button, Flex, Spinner, Table, Text } from \"@radix-ui/themes\";\nimport { useMemo, useState, type ReactNode } from \"react\";\n\nimport \"./data-table.css\";\n\nimport { compareDecimal, formatAmount, formatDate } from \"../../lib/format\";\nimport { StatusBadge, type Tone } from \"../status-badge/StatusBadge\";\n\nexport type ColumnKind = \"text\" | \"number\" | \"money\" | \"date\" | \"status\";\n\nexport interface Column<T extends RowData> {\n  key: keyof T & string;\n  header: string;\n  kind?: ColumnKind;\n  /** money: the field holding the currency, shown beside the amount when the table mixes currencies. */\n  currencyKey?: keyof T & string;\n  sortable?: boolean;\n  /** status: colours for this brick's own states. */\n  tones?: Record<string, Tone>;\n  /** Anything the kinds do not cover. Receives the row; returns what to show. */\n  cell?: (row: T) => ReactNode;\n}\n\nexport interface DataTableProps<T extends RowData> {\n  rows: T[] | undefined;\n  columns: Column<T>[];\n  /** A stable id per row — the record's natural key. */\n  rowKey: (row: T) => string;\n  onRowClick?: (row: T) => void;\n  loading?: boolean;\n  /** A message to show instead of the rows — the API's own reason. */\n  error?: string | null;\n  empty?: ReactNode;\n  pageSize?: number;\n  /** Accessible name for the table, e.g. \"Partners\". */\n  label: string;\n}\n\nconst features = tableFeatures({\n  rowSortingFeature,\n  rowPaginationFeature,\n  sortedRowModel: createSortedRowModel(),\n  paginatedRowModel: createPaginatedRowModel(),\n  sortFns,\n});\n\nconst ALIGN: Record<ColumnKind, \"left\" | \"right\"> = {\n  text: \"left\", status: \"left\", date: \"left\", number: \"right\", money: \"right\",\n};\n\nexport function DataTable<T extends RowData>({\n  rows, columns, rowKey, onRowClick, loading, error, empty, pageSize = 25, label,\n}: DataTableProps<T>) {\n  const [sorting, setSorting] = useState<SortingState>([]);\n  const helper = useMemo(() => createColumnHelper<typeof features, T>(), []);\n\n  const defs = useMemo(\n    () =>\n      columns.map((c) => {\n        const kind = c.kind ?? \"text\";\n        return helper.accessor((row: T) => row[c.key] as unknown, {\n          id: c.key,\n          header: c.header,\n          enableSorting: c.sortable ?? true,\n          sortFn: (a, b) => {\n            const x = a.original[c.key] as unknown, y = b.original[c.key] as unknown;\n            if (kind === \"money\" || kind === \"number\") return compareDecimal(x as string, y as string);\n            return String(x ?? \"\").localeCompare(String(y ?? \"\"), undefined, { numeric: true });\n          },\n        });\n      }) as ColumnDef<typeof features, T, unknown>[],\n    [columns, helper],\n  );\n\n  const table = useTable({\n    features,\n    columns: defs,\n    data: rows ?? [],\n    state: { sorting },\n    onSortingChange: setSorting,\n    initialState: { pagination: { pageIndex: 0, pageSize } },\n    getRowId: (row: T) => rowKey(row),\n  });\n\n  const byId = new Map(columns.map((c) => [c.key, c] as const));\n  const show = (row: T, c: Column<T>): ReactNode => {\n    if (c.cell) return c.cell(row);\n    const v = row[c.key] as unknown;\n    switch (c.kind ?? \"text\") {\n      case \"money\": {\n        const cur = c.currencyKey ? (row[c.currencyKey] as unknown as string) : \"\";\n        return `${formatAmount(v as string)}${cur ? ` ${cur}` : \"\"}`;\n      }\n      case \"number\":\n        return formatAmount(v as string, 0);\n      case \"date\":\n        return formatDate(v as string);\n      case \"status\":\n        return v ? <StatusBadge status={String(v)} tones={c.tones} /> : \"—\";\n      default:\n        return v === null || v === undefined || v === \"\" ? \"—\" : String(v);\n    }\n  };\n\n  if (error) {\n    return <Text as=\"p\" color=\"red\" role=\"alert\">{error}</Text>;\n  }\n\n  const pageRows = table.getRowModel().rows;\n  const pages = table.getPageCount();\n  return (\n    <Flex direction=\"column\" gap=\"3\">\n      <Table.Root variant=\"surface\" aria-label={label} aria-busy={loading || undefined}>\n        <Table.Header>\n          {table.getHeaderGroups().map((g) => (\n            <Table.Row key={g.id}>\n              {g.headers.map((h) => {\n                const c = byId.get(h.column.id as keyof T & string)!;\n                const sorted = h.column.getIsSorted();\n                const sortable = h.column.getCanSort();\n                return (\n                  <Table.ColumnHeaderCell\n                    key={h.id}\n                    align={ALIGN[c.kind ?? \"text\"]}\n                    aria-sort={sorted === \"asc\" ? \"ascending\" : sorted === \"desc\" ? \"descending\" : undefined}\n                  >\n                    {sortable ? (\n                      <button type=\"button\" className=\"erp-sort\" onClick={h.column.getToggleSortingHandler()}>\n                        {c.header}\n                        <span aria-hidden=\"true\">{sorted === \"asc\" ? \" ↑\" : sorted === \"desc\" ? \" ↓\" : \"\"}</span>\n                      </button>\n                    ) : (\n                      c.header\n                    )}\n                  </Table.ColumnHeaderCell>\n                );\n              })}\n            </Table.Row>\n          ))}\n        </Table.Header>\n        <Table.Body>\n          {loading && !rows?.length ? (\n            <Table.Row>\n              <Table.Cell colSpan={columns.length}>\n                <Flex align=\"center\" gap=\"2\"><Spinner /> <Text color=\"gray\">Loading…</Text></Flex>\n              </Table.Cell>\n            </Table.Row>\n          ) : pageRows.length === 0 ? (\n            <Table.Row>\n              <Table.Cell colSpan={columns.length}>\n                <Text color=\"gray\">{empty ?? \"Nothing here yet.\"}</Text>\n              </Table.Cell>\n            </Table.Row>\n          ) : (\n            pageRows.map((r) => (\n              <Table.Row\n                key={r.id}\n                onClick={onRowClick ? () => onRowClick(r.original) : undefined}\n                onKeyDown={onRowClick ? (e) => { if (e.key === \"Enter\") onRowClick(r.original); } : undefined}\n                tabIndex={onRowClick ? 0 : undefined}\n                style={onRowClick ? { cursor: \"pointer\" } : undefined}\n                data-row-id={r.id}\n              >\n                {columns.map((c) => (\n                  <Table.Cell\n                    key={c.key}\n                    align={ALIGN[c.kind ?? \"text\"]}\n                    style={c.kind === \"money\" || c.kind === \"number\" ? { fontVariantNumeric: \"tabular-nums\" } : undefined}\n                  >\n                    {show(r.original, c)}\n                  </Table.Cell>\n                ))}\n              </Table.Row>\n            ))\n          )}\n        </Table.Body>\n      </Table.Root>\n      {pages > 1 && (\n        <Flex align=\"center\" justify=\"end\" gap=\"2\">\n          <Text size=\"2\" color=\"gray\">\n            Page {table.state.pagination.pageIndex + 1} of {pages}\n          </Text>\n          <Button size=\"1\" variant=\"soft\" onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>\n            Previous\n          </Button>\n          <Button size=\"1\" variant=\"soft\" onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>\n            Next\n          </Button>\n        </Flex>\n      )}\n    </Flex>\n  );\n}\n",
      "type": "registry:component",
      "target": "erp-ui/components/data-table/DataTable.tsx"
    },
    {
      "path": "src/components/data-table/data-table.css",
      "content": "/* A sortable column header reads as its label, not as a browser button, and still shows keyboard focus. */\n.erp-sort {\n  all: unset;\n  cursor: pointer;\n  font: inherit;\n  color: inherit;\n  border-radius: var(--radius-1);\n}\n.erp-sort:focus-visible {\n  outline: 2px solid var(--focus-8);\n  outline-offset: 2px;\n}\n",
      "type": "registry:file",
      "target": "erp-ui/components/data-table/data-table.css"
    }
  ],
  "type": "registry:component"
}