Files
app/src/admin/ui/DataTable.tsx

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>
);
}