105 lines
2.6 KiB
TypeScript
105 lines
2.6 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { EmptyBlock, ErrorBlock } from "./StateBlock";
|
|
|
|
export interface Column<T> {
|
|
key: string;
|
|
header: ReactNode;
|
|
render: (row: T) => ReactNode;
|
|
/** Right-align (numeric) cells. */
|
|
numeric?: boolean;
|
|
/** Extra class on the td/th. */
|
|
className?: string;
|
|
}
|
|
|
|
interface DataTableProps<T> {
|
|
columns: Column<T>[];
|
|
rows: T[];
|
|
rowKey: (row: T) => string;
|
|
loading?: boolean;
|
|
error?: unknown;
|
|
onRetry?: () => void;
|
|
emptyTitle?: string;
|
|
emptyHint?: string;
|
|
caption?: string;
|
|
/** Row click handler — renders rows as clickable. */
|
|
onRowClick?: (row: T) => void;
|
|
/** Number of skeleton rows while loading. */
|
|
skeletonRows?: number;
|
|
}
|
|
|
|
/**
|
|
* Data-dense table with unified loading / error / empty states.
|
|
* Renders inside a horizontal-scroll wrapper so narrow viewports never
|
|
* overflow the document.
|
|
*/
|
|
export function DataTable<T>({
|
|
columns,
|
|
rows,
|
|
rowKey,
|
|
loading = false,
|
|
error,
|
|
onRetry,
|
|
emptyTitle = "No rows",
|
|
emptyHint,
|
|
caption,
|
|
onRowClick,
|
|
skeletonRows = 6,
|
|
}: DataTableProps<T>) {
|
|
if (error !== undefined && error !== null) {
|
|
return <ErrorBlock error={error} onRetry={onRetry} />;
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div role="status" aria-label="Loading table rows">
|
|
{Array.from({ length: skeletonRows }, (_, i) => (
|
|
<div className="skeleton-row" key={i} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (rows.length === 0) {
|
|
return <EmptyBlock title={emptyTitle} hint={emptyHint} />;
|
|
}
|
|
|
|
return (
|
|
<div className="table-wrap">
|
|
<table className="data-table">
|
|
{caption ? <caption className="sr-only">{caption}</caption> : null}
|
|
<thead>
|
|
<tr>
|
|
{columns.map((col) => (
|
|
<th
|
|
key={col.key}
|
|
scope="col"
|
|
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
|
|
>
|
|
{col.header}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map((row) => (
|
|
<tr
|
|
key={rowKey(row)}
|
|
className={onRowClick ? "clickable" : undefined}
|
|
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
|
>
|
|
{columns.map((col) => (
|
|
<td
|
|
key={col.key}
|
|
className={`${col.numeric ? "num" : ""} ${col.className ?? ""}`.trim()}
|
|
>
|
|
{col.render(row)}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|