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"
}