169 lines
5.6 KiB
TypeScript
169 lines
5.6 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;
|
|
/**
|
|
* Fixed column width (number = px, string = any CSS width incl. "%"). When
|
|
* ANY column sets a width the table switches to `table-layout: fixed`, so
|
|
* columns keep a stable size regardless of cell content (e.g. while
|
|
* filtering). Overflowing text is clipped with an ellipsis.
|
|
*/
|
|
width?: number | string;
|
|
render: (row: T) => ReactNode;
|
|
}
|
|
|
|
export interface DataTableProps<T> {
|
|
columns: DataColumn<T>[];
|
|
rows: T[];
|
|
rowKey: (row: T) => string | number;
|
|
rowInactive?: (row: T) => boolean;
|
|
/** When set, each row is clickable and shows a pointer cursor. */
|
|
onRowClick?: (row: T) => void;
|
|
/** When it returns true, the row is tinted with the error palette (danger highlight). */
|
|
rowDanger?: (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,
|
|
onRowClick,
|
|
rowDanger,
|
|
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}</>;
|
|
// When any column declares a width, lock the table to fixed layout so column
|
|
// widths come from the colgroup (stable) instead of from cell content (which
|
|
// jumps as rows are filtered). Tables that set no widths keep auto layout.
|
|
const hasWidths = columns.some((c) => c.width != null);
|
|
const colWidth = (w: number | string | undefined) =>
|
|
w == null ? undefined : typeof w === "number" ? `${w}px` : w;
|
|
return (
|
|
<TableContainer sx={{ borderRadius: 2, overflowX: "auto" }}>
|
|
<Table
|
|
size="small"
|
|
sx={{
|
|
...(hasWidths ? { tableLayout: "fixed", minWidth: 720 } : {}),
|
|
"& td, & th": { borderColor: "divider" },
|
|
}}
|
|
>
|
|
{hasWidths && (
|
|
<colgroup>
|
|
{columns.map((c) => (
|
|
<col key={c.key} style={{ width: colWidth(c.width) }} />
|
|
))}
|
|
</colgroup>
|
|
)}
|
|
<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
|
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
sx={[
|
|
onRowClick ? { cursor: "pointer" } : {},
|
|
rowDanger?.(row)
|
|
? {
|
|
bgcolor: "error.light",
|
|
opacity: 0.88,
|
|
"&:hover": {
|
|
bgcolor: "error.light",
|
|
filter: "brightness(0.97)",
|
|
},
|
|
}
|
|
: {},
|
|
rowInactive?.(row) ? { opacity: 0.55 } : {},
|
|
]}
|
|
>
|
|
{columns.map((c) => (
|
|
<TableCell
|
|
key={c.key}
|
|
align={c.align}
|
|
sx={{
|
|
fontSize: ".8rem",
|
|
...(c.bold ? { fontWeight: 500 } : {}),
|
|
...(c.mono
|
|
? { fontFamily: "'DM Mono', Menlo, monospace" }
|
|
: {}),
|
|
...(hasWidths
|
|
? {
|
|
whiteSpace: "nowrap",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
}
|
|
: {}),
|
|
}}
|
|
>
|
|
{c.render(row)}
|
|
</TableCell>
|
|
))}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
);
|
|
}
|