blob: 65be155fcbbe4e0f52c7e35b46349200cff9c3ff [file]
// packages/ui/src/primitives/data-table.tsx
//
// The ONE hairline data-table recipe. Extracted from usage-settings'
// inline `UsageStatsTable` (#1252) once it grew a second and third
// prospective consumer (health signals, permission capabilities) — a
// real HTML `<table>` with:
// - a muted + weight-medium scoped header row (`<th scope="col">`);
// - the first data cell of every row promoted to a scoped row header
// (`<th scope="row">`, weight-medium ink) so screen readers announce
// the row by its name column;
// - hairline row separators (`border-b`) and a hairline-boxed surface;
// - right-aligned tabular-nums numeric columns so figures never jitter;
// - exactly one `grow` column that absorbs the row's slack while every
// other column sizes to its content on one line (no floating gaps,
// no per-character header wrapping).
//
// Empty handling is deliberately NOT here: a table primitive renders a
// table. Call sites branch to the shared `EmptyState` before rendering it
// (that decision — icon + copy vs. a bare header row — is a page concern
// and already converged in `EmptyState`).
//
// Styled with Tailwind utilities so the primitive is portable; a call
// site's pin class passes through via `className` for CSS placement / test
// + CDP selectors.
import type { ReactNode } from 'react';
import { cn } from '../utils.js';
export interface DataTableColumn {
header: ReactNode;
/** Numeric columns right-align and force tabular-nums. */
numeric?: boolean;
/** The single column that absorbs slack so the others size to content. */
grow?: boolean;
}
export interface DataTableProps {
/** Table-specific accessible name (each surface names its own table). */
ariaLabel: string;
columns: DataTableColumn[];
/** Row-major cells; cell 0 of each row becomes the scoped row header. */
rows: Array<Array<ReactNode>>;
/** Pin class for page-level CSS placement / stable selectors. */
className?: string;
}
const CELL_BASE =
'border-b border-border px-[var(--space-2)] py-[var(--space-1-5)] align-middle';
// Only the grow column wraps; every other column stays on one line and sizes
// to its content (no per-character header wrapping, no floating giant gaps).
function shape(column: DataTableColumn): string {
return [
column.numeric ? 'text-right [font-variant-numeric:tabular-nums]' : 'text-left',
column.grow ? 'w-full' : 'whitespace-nowrap',
].join(' ');
}
export function DataTable({ ariaLabel, columns, rows, className }: DataTableProps) {
const cellClass = (column: DataTableColumn) =>
`${CELL_BASE} text-foreground-secondary ${shape(column)}`;
const headClass = (column: DataTableColumn) =>
`${CELL_BASE} font-medium text-muted-foreground ${shape(column)}`;
// #1364 → routed through #1360: the recipe deliberately keeps every
// non-grow column on one line (`whitespace-nowrap`), so the table's
// min-content width is the sum of its widest cells — a dated preview model
// id plus a namespaced MCP tool name already exceeded the settings column at
// full window width, and a bare <table> propagates that to every ancestor.
// Wide content scrolls inside its own container (#1303 acceptance rule), so
// the primitive owns that container: the hairline box + radius move onto the
// scroller (a border on the <table> itself would scroll along with it), and
// the caller's pin class stays on the outermost element it always addressed.
return (
<div
data-slot="data-table-scroller"
className={cn(
'overflow-x-auto rounded-[var(--radius-surface)] border border-border',
className,
)}
>
<table
aria-label={ariaLabel}
data-slot="data-table"
// Last row drops its hairline: the box border lives on the scroller
// now, so border-collapse no longer merges the two into one line.
className="w-full border-collapse text-[length:var(--font-size-caption)] [&>tbody>tr:last-child>*]:border-b-0"
>
<thead>
<tr>
{columns.map((column, columnIndex) => (
<th key={columnIndex} scope="col" className={headClass(column)}>
{column.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIndex) => (
<tr key={rowIndex}>
{row.map((cell, cellIndex) =>
cellIndex === 0 ? (
<th
key={cellIndex}
scope="row"
className={`${cellClass(columns[cellIndex])} font-medium text-foreground`}
>
{cell}
</th>
) : (
<td key={cellIndex} className={cellClass(columns[cellIndex])}>
{cell}
</td>
),
)}
</tr>
))}
</tbody>
</table>
</div>
);
}