Select value/option restricted to string (MUI sets the literal child value; string|number + onChange:string was a type lie). DataTable sortDirection uses ?? 'asc' so aria-sort is never undefined on the active column. StatusChip sx-merge drops undefined; Pagination clamps page into range; DataTable dev-warns on sortKey without onSort; Alert onClose JSDoc. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
116 lines
3.5 KiB
TypeScript
116 lines
3.5 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import Table from "@mui/material/Table";
|
|
import TableBody from "@mui/material/TableBody";
|
|
import TableCell from "@mui/material/TableCell";
|
|
import TableContainer from "@mui/material/TableContainer";
|
|
import TableHead from "@mui/material/TableHead";
|
|
import TableRow from "@mui/material/TableRow";
|
|
import TableSortLabel from "@mui/material/TableSortLabel";
|
|
|
|
export interface DataColumn<T> {
|
|
key: string;
|
|
header: string;
|
|
align?: "left" | "right";
|
|
/** Render the cell with the DM Mono numeric face. */
|
|
mono?: boolean;
|
|
/** Emphasize the cell (font-weight 500). */
|
|
bold?: boolean;
|
|
/** If set, the header becomes a clickable sort control (requires onSort). */
|
|
sortKey?: string;
|
|
render: (row: T) => ReactNode;
|
|
}
|
|
|
|
export interface DataTableProps<T> {
|
|
columns: DataColumn<T>[];
|
|
rows: T[];
|
|
rowKey: (row: T) => string | number;
|
|
rowInactive?: (row: T) => boolean;
|
|
empty?: ReactNode;
|
|
/** Server-sort wiring: active key (matches a column's sortKey), direction, handler. */
|
|
sortBy?: string;
|
|
sortDir?: "asc" | "desc";
|
|
onSort?: (key: string) => void;
|
|
}
|
|
|
|
/** Dense, Soft-SaaS-styled data table. Styling only — never re-formats values. */
|
|
export default function DataTable<T>({
|
|
columns,
|
|
rows,
|
|
rowKey,
|
|
rowInactive,
|
|
empty,
|
|
sortBy,
|
|
sortDir,
|
|
onSort,
|
|
}: DataTableProps<T>) {
|
|
if (import.meta.env.DEV && columns.some((c) => c.sortKey) && !onSort) {
|
|
console.warn("DataTable: columns with sortKey require an onSort prop.");
|
|
}
|
|
if (rows.length === 0 && empty) return <>{empty}</>;
|
|
return (
|
|
<TableContainer sx={{ borderRadius: 2 }}>
|
|
<Table size="small" sx={{ "& td, & th": { borderColor: "divider" } }}>
|
|
<TableHead>
|
|
<TableRow>
|
|
{columns.map((c) => (
|
|
<TableCell
|
|
key={c.key}
|
|
align={c.align}
|
|
sortDirection={
|
|
c.sortKey && sortBy === c.sortKey ? (sortDir ?? "asc") : false
|
|
}
|
|
sx={{
|
|
textTransform: "uppercase",
|
|
letterSpacing: ".05em",
|
|
fontSize: ".64rem",
|
|
fontWeight: 700,
|
|
color: "text.secondary",
|
|
}}
|
|
>
|
|
{c.sortKey && onSort ? (
|
|
<TableSortLabel
|
|
active={sortBy === c.sortKey}
|
|
direction={
|
|
sortBy === c.sortKey ? (sortDir ?? "asc") : "asc"
|
|
}
|
|
onClick={() => onSort(c.sortKey!)}
|
|
>
|
|
{c.header}
|
|
</TableSortLabel>
|
|
) : (
|
|
c.header
|
|
)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{rows.map((row) => (
|
|
<TableRow
|
|
key={rowKey(row)}
|
|
hover
|
|
sx={rowInactive?.(row) ? { opacity: 0.55 } : undefined}
|
|
>
|
|
{columns.map((c) => (
|
|
<TableCell
|
|
key={c.key}
|
|
align={c.align}
|
|
sx={{
|
|
fontSize: ".8rem",
|
|
...(c.bold ? { fontWeight: 500 } : {}),
|
|
...(c.mono
|
|
? { fontFamily: "'DM Mono', Menlo, monospace" }
|
|
: {}),
|
|
}}
|
|
>
|
|
{c.render(row)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
);
|
|
}
|