{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "data-table", "type": "registry:component", "title": "Data table", "description": "The ledger register: token-driven row pitch, hairline-only header, right-aligned tabular money, em-dash empties, no zebra, no shadow.", "registryDependencies": [ "@venlyfinance/venly-tokens" ], "files": [ { "path": "registry/components/data-table.tsx", "type": "registry:component", "target": "~/components/venly/components/data-table.tsx", "content": "import { useState, type CSSProperties, type ReactElement, type ReactNode } from \"react\";\n\n/**\n * Data table – the ledger register.\n *\n * Design contract encoded by this component:\n * - Density is the diagnostic number: row pitch ÷ body size targets\n * 2.4–3.8×. Pitch comes from --row-pitch; never inflate with padding.\n * - Header: hairline-only, no fill, micro-size grey sentence case, ~32px.\n * - Money right-aligned tabular; two numeric columns share a right edge.\n * - Hover = full-row ~3% neutral tint, never a border or shadow.\n * - Empty cells render an em-dash – blank reads as a load failure.\n * - Cells truncate with ellipsis; wrapping rows destroy scanability.\n * - No zebra striping: hairline separators only.\n * - Grouped mode: section bands at ~60% of a data row ([chevron] name ·\n * count). Empty groups are still DRAWN, collapsed to the single band –\n * \"Pending: 0\" is a state, not a missing feature. Needs-attention groups\n * carry a small dot; the count is the signal, absence of a count means\n * nothing to do.\n */\nexport interface DataTableColumn {\n key: string;\n header: string;\n /** Money columns set this: right-aligns and applies tabular figures. */\n money?: boolean;\n align?: \"left\" | \"right\";\n width?: string;\n cell: (row: Row) => ReactNode;\n}\n\nexport interface DataTableGroup {\n key: string;\n label: string;\n rows: Row[];\n /** Renders the small dot beside the label when the group has rows. */\n attention?: boolean;\n}\n\nexport interface DataTableProps {\n columns: DataTableColumn[];\n rows: Row[];\n rowKey: (row: Row) => string;\n onRowClick?: (row: Row) => void;\n /** Row currently opened in a side panel; stays tinted. */\n selectedKey?: string;\n /**\n * Sectioned rendering: rows come from the groups, in order, each under\n * its own collapsible band. `rows` is ignored while groups are set.\n */\n groups?: DataTableGroup[];\n /**\n * Controlled collapse state per group key. Provide it (with\n * onGroupToggle) when anything OUTSIDE the table depends on which rows\n * are actually rendered – e.g. a keyboard row-stepper must never select\n * a row whose group is collapsed. Uncontrolled when omitted.\n */\n collapsedGroups?: Record;\n onGroupToggle?: (key: string, collapsed: boolean) => void;\n emptyMessage?: string;\n style?: CSSProperties;\n className?: string;\n}\n\nconst EMPTY = \"—\";\n\nfunction cellContent(value: ReactNode): ReactNode {\n return value === null || value === undefined || value === \"\" ? EMPTY : value;\n}\n\nexport function DataTable({\n columns,\n rows,\n rowKey,\n onRowClick,\n selectedKey,\n groups,\n collapsedGroups,\n onGroupToggle,\n emptyMessage = \"Nothing here yet\",\n style,\n className,\n}: DataTableProps): ReactElement {\n const [internalCollapsed, setInternalCollapsed] = useState>({});\n const collapsed = collapsedGroups ?? internalCollapsed;\n const toggleGroup = (key: string, next: boolean) => {\n if (collapsedGroups === undefined) setInternalCollapsed((c) => ({ ...c, [key]: next }));\n onGroupToggle?.(key, next);\n };\n\n const renderRow = (row: Row): ReactElement => {\n const key = rowKey(row);\n const selected = key === selectedKey;\n return (\n onRowClick(row) : undefined}\n style={{\n height: \"var(--row-pitch)\",\n borderBottom: \"var(--border-w-hairline) solid var(--border-hairline)\",\n background: selected ? \"var(--selected-tint)\" : undefined,\n cursor: onRowClick ? \"pointer\" : undefined,\n }}\n >\n {columns.map((col) => (\n \n {cellContent(col.cell(row))}\n \n ))}\n \n );\n };\n\n return (\n \n \n \n {columns.map((col) => (\n \n {col.header}\n \n ))}\n \n \n \n {groups ? (\n groups.flatMap((group) => {\n const isEmpty = group.rows.length === 0;\n const isCollapsed = isEmpty || (collapsed[group.key] ?? false);\n const band = (\n \n \n toggleGroup(group.key, !isCollapsed)}\n style={{\n display: \"flex\",\n alignItems: \"center\",\n gap: \"var(--space-xs)\",\n width: \"100%\",\n height: \"var(--group-header-pitch)\",\n border: \"none\",\n background: \"none\",\n cursor: isEmpty ? \"default\" : \"pointer\",\n padding: \"0 var(--cell-pad-x)\",\n fontFamily: \"var(--font-family)\",\n fontSize: \"var(--font-size-micro)\",\n fontWeight: 500,\n color: \"var(--text-secondary)\",\n textAlign: \"left\",\n }}\n >\n {isCollapsed ? \"▸\" : \"▾\"}\n {group.label}\n \n {group.rows.length}\n \n {group.attention && group.rows.length > 0 ? (\n \n ) : null}\n \n \n \n );\n return isCollapsed ? [band] : [band, ...group.rows.map(renderRow)];\n })\n ) : rows.length === 0 ? (\n \n \n {emptyMessage}\n \n \n ) : (\n rows.map(renderRow)\n )}\n \n \n );\n}\n\n/**\n * Row content helper: primary 14px medium + secondary micro-size one tonal\n * step down. Secondary is de-emphasised by BOTH size and colour; invert to a\n * state colour only for warnings (\"Overdue 17 hours\").\n */\nexport function RowText({\n primary,\n secondary,\n warn,\n}: {\n primary: ReactNode;\n secondary?: ReactNode;\n warn?: boolean;\n}): ReactElement {\n return (\n \n {primary}\n {secondary !== undefined ? (\n \n {secondary}\n \n ) : null}\n \n );\n}\n" } ] }