web/src/components/data-table.tsx
18 KB · sha256:13425bb08e4deb666b71171e1b4640f033a1ac1bd0583f8320109d5651405e25
import {
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronUp,
Search,
} from "lucide-react";
import {
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { cn } from "../lib/cn";
import { Button, EmptyState, Input } from "./ui";
type Primitive = string | number | boolean | null | undefined;
export interface Column<T> {
id: string;
header: ReactNode;
cell: (row: T) => ReactNode;
/** Value the sort/filter logic sees. Omit to disable both. */
accessor?: (row: T) => Primitive;
sortable?: boolean;
/** Default `true` when `accessor` is set. */
filterable?: boolean;
width?: string;
align?: "left" | "right" | "center";
className?: string;
headerClassName?: string;
}
export interface BulkAction<T> {
id: string;
label: string;
icon?: React.ComponentType<{ size?: number; className?: string }>;
variant?: "primary" | "secondary" | "ghost" | "danger" | "subtle";
run: (rows: T[]) => void | Promise<void>;
/** Show a `confirm()` first with this prompt. `{n}` is replaced with row count. */
confirm?: string;
}
export interface DataTableProps<T> {
data: T[];
columns: Column<T>[];
rowKey: (row: T) => string;
searchPlaceholder?: string;
bulkActions?: BulkAction<T>[];
pageSize?: number;
pageSizes?: number[];
emptyState?: {
title: string;
description?: string;
action?: ReactNode;
};
/** Click anywhere on the row (outside the checkbox/links) -- usually navigation. */
onRowClick?: (row: T) => void;
loading?: boolean;
/** Right side of the toolbar (shown when no bulk selection is active). */
toolbarActions?: ReactNode;
className?: string;
}
interface SortState {
id: string;
dir: "asc" | "desc";
}
export function DataTable<T>({
data,
columns,
rowKey,
searchPlaceholder = "Search…",
bulkActions = [],
pageSize = 25,
pageSizes = [10, 25, 50, 100],
emptyState,
onRowClick,
loading,
toolbarActions,
className,
}: DataTableProps<T>) {
const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortState | null>(null);
const [page, setPage] = useState(0);
const [size, setSize] = useState(pageSize);
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => setPage(0), [search, sort, size, data.length]);
const filtered = useMemo(() => {
const q = search.trim().toLowerCase();
if (!q) return data;
const cols = columns.filter(
(c) => c.accessor && c.filterable !== false,
);
return data.filter((row) =>
cols.some((c) => {
const v = c.accessor!(row);
return v != null && String(v).toLowerCase().includes(q);
}),
);
}, [data, columns, search]);
const sorted = useMemo(() => {
if (!sort) return filtered;
const col = columns.find((c) => c.id === sort.id);
if (!col?.accessor) return filtered;
const acc = col.accessor;
const out = [...filtered];
out.sort((a, b) => {
const av = acc(a);
const bv = acc(b);
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
let cmp: number;
if (typeof av === "number" && typeof bv === "number") cmp = av - bv;
else if (typeof av === "boolean" && typeof bv === "boolean")
cmp = Number(av) - Number(bv);
else cmp = String(av).localeCompare(String(bv));
return sort.dir === "asc" ? cmp : -cmp;
});
return out;
}, [filtered, columns, sort]);
const total = sorted.length;
const pageCount = Math.max(1, Math.ceil(total / size));
const safePage = Math.min(page, pageCount - 1);
const pageStart = safePage * size;
const paged = sorted.slice(pageStart, pageStart + size);
const pagedKeys = paged.map(rowKey);
const allOnPageSelected =
paged.length > 0 && pagedKeys.every((k) => selected.has(k));
const someOnPageSelected =
!allOnPageSelected && pagedKeys.some((k) => selected.has(k));
const togglePage = () => {
setSelected((prev) => {
const next = new Set(prev);
if (allOnPageSelected) {
for (const k of pagedKeys) next.delete(k);
} else {
for (const k of pagedKeys) next.add(k);
}
return next;
});
};
const toggleRow = (key: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const onSort = (id: string) => {
setSort((prev) => {
if (prev?.id !== id) return { id, dir: "asc" };
if (prev.dir === "asc") return { id, dir: "desc" };
return null;
});
};
const selectedRows = useMemo(
() => data.filter((r) => selected.has(rowKey(r))),
[data, selected, rowKey],
);
const isEmpty = !loading && data.length === 0;
const hasBulk = bulkActions.length > 0;
return (
<div
className={cn(
"bg-surface border-border overflow-hidden rounded-2xl border shadow-sm",
className,
)}
>
<Toolbar
searchPlaceholder={searchPlaceholder}
search={search}
onSearch={setSearch}
selected={selectedRows}
clearSelection={() => setSelected(new Set())}
bulkActions={bulkActions}
toolbarActions={toolbarActions}
/>
{isEmpty && emptyState ? (
<EmptyState
title={emptyState.title}
description={emptyState.description}
action={emptyState.action}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-left text-sm">
<thead>
<tr className="bg-surface-2/60">
{hasBulk && (
<th className="border-border w-10 border-b px-4 py-2.5">
<Checkbox
checked={allOnPageSelected}
indeterminate={someOnPageSelected}
onChange={togglePage}
aria-label="Select all on page"
/>
</th>
)}
{columns.map((col) => {
const isSorted = sort?.id === col.id;
const canSort = col.sortable !== false && !!col.accessor;
return (
<th
key={col.id}
className={cn(
"border-border text-fg-subtle border-b px-4 py-2.5 text-xs font-semibold tracking-wide uppercase",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
col.headerClassName,
)}
style={col.width ? { width: col.width } : undefined}
>
{canSort ? (
<button
type="button"
onClick={() => onSort(col.id)}
className="hover:text-fg group inline-flex items-center gap-1.5 transition"
>
{col.header}
<span
className={cn(
"flex flex-col",
isSorted ? "opacity-100" : "opacity-30 group-hover:opacity-60",
)}
>
<ChevronUp
size={10}
className={cn(
"-mb-0.5",
isSorted && sort?.dir === "asc"
? "text-brand-600 dark:text-brand-300"
: "",
)}
/>
<ChevronDown
size={10}
className={cn(
isSorted && sort?.dir === "desc"
? "text-brand-600 dark:text-brand-300"
: "",
)}
/>
</span>
</button>
) : (
col.header
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{paged.length === 0 ? (
<tr>
<td
colSpan={columns.length + (hasBulk ? 1 : 0)}
className="text-fg-subtle px-4 py-12 text-center text-sm"
>
{search.trim()
? `No matches for "${search.trim()}".`
: "No rows."}
</td>
</tr>
) : (
paged.map((row) => {
const key = rowKey(row);
const isSelected = selected.has(key);
return (
<tr
key={key}
onClick={() => onRowClick?.(row)}
className={cn(
"transition",
isSelected
? "bg-brand-50/60 dark:bg-brand-900/15"
: "hover:bg-surface-3/60",
onRowClick && "cursor-pointer",
)}
>
{hasBulk && (
<td
className="border-border border-b px-4 py-3"
onClick={(e) => {
// Don't let a select-cell click bubble to the row
// (which would also navigate via onRowClick).
e.stopPropagation();
// If the user clicked the checkbox directly, its
// native onChange already toggled -- toggling
// again here would cancel it out. Only toggle
// when the click hit the cell padding instead.
const target = e.target as HTMLElement;
if (target.tagName === "INPUT") return;
toggleRow(key);
}}
>
<Checkbox
checked={isSelected}
onChange={() => toggleRow(key)}
onClick={(e) => e.stopPropagation()}
aria-label="Select row"
/>
</td>
)}
{columns.map((col) => (
<td
key={col.id}
className={cn(
"border-border text-fg border-b px-4 py-3 align-middle",
col.align === "right" && "text-right",
col.align === "center" && "text-center",
col.className,
)}
>
{col.cell(row)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
)}
<Pagination
page={safePage}
pageCount={pageCount}
pageSize={size}
pageSizes={pageSizes}
total={total}
from={total === 0 ? 0 : pageStart + 1}
to={Math.min(pageStart + size, total)}
onPageChange={setPage}
onPageSizeChange={setSize}
/>
</div>
);
}
function Toolbar<T>({
searchPlaceholder,
search,
onSearch,
selected,
clearSelection,
bulkActions,
toolbarActions,
}: {
searchPlaceholder: string;
search: string;
onSearch: (v: string) => void;
selected: T[];
clearSelection: () => void;
bulkActions: BulkAction<T>[];
toolbarActions?: ReactNode;
}) {
const hasSelection = selected.length > 0;
return (
<div className="border-border flex flex-wrap items-center justify-between gap-3 border-b p-4">
<div className="relative max-w-md flex-1">
<Search
size={16}
className="text-fg-subtle pointer-events-none absolute top-1/2 left-3 -translate-y-1/2"
/>
<Input
value={search}
onChange={(e) => onSearch(e.target.value)}
placeholder={searchPlaceholder}
className="pl-9"
/>
</div>
{hasSelection ? (
<BulkActionBar
selected={selected}
clearSelection={clearSelection}
actions={bulkActions}
/>
) : (
toolbarActions && <div className="flex items-center gap-2">{toolbarActions}</div>
)}
</div>
);
}
function BulkActionBar<T>({
selected,
clearSelection,
actions,
}: {
selected: T[];
clearSelection: () => void;
actions: BulkAction<T>[];
}) {
// Per-action "running" flag so each button can show its own spinner +
// disable while in flight. Prevents double-submits from button mashing.
const [running, setRunning] = useState<string | null>(null);
return (
<div className="flex items-center gap-2">
<span className="bg-brand-50 text-brand-700 dark:bg-brand-900/30 dark:text-brand-200 inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-medium">
{selected.length} selected
<button
type="button"
onClick={clearSelection}
disabled={!!running}
className="hover:underline disabled:opacity-50"
>
clear
</button>
</span>
{actions.map((a) => {
const Icon = a.icon;
const isRunning = running === a.id;
return (
<Button
key={a.id}
variant={a.variant || "secondary"}
size="sm"
loading={isRunning}
disabled={!!running && !isRunning}
onClick={async () => {
if (a.confirm) {
const msg = a.confirm.replace(/\{n\}/g, String(selected.length));
if (!confirm(msg)) return;
}
setRunning(a.id);
try {
await a.run(selected);
clearSelection();
} finally {
setRunning(null);
}
}}
>
{Icon && !isRunning && <Icon size={14} />}
{a.label}
</Button>
);
})}
</div>
);
}
function Pagination({
page,
pageCount,
pageSize,
pageSizes,
total,
from,
to,
onPageChange,
onPageSizeChange,
}: {
page: number;
pageCount: number;
pageSize: number;
pageSizes: number[];
total: number;
from: number;
to: number;
onPageChange: (p: number) => void;
onPageSizeChange: (s: number) => void;
}) {
return (
<div className="border-border bg-surface-2/40 text-fg-muted flex flex-wrap items-center justify-between gap-3 border-t px-4 py-2.5 text-sm">
<div className="tabular-nums">
{total === 0 ? "No rows" : (
<>
<span className="text-fg font-medium">
{from.toLocaleString()}–{to.toLocaleString()}
</span>{" "}
of <span className="text-fg font-medium">{total.toLocaleString()}</span>
</>
)}
</div>
<div className="flex items-center gap-3">
<label className="text-fg-subtle inline-flex items-center gap-2 text-xs">
Rows
<select
value={pageSize}
onChange={(e) => onPageSizeChange(parseInt(e.target.value, 10))}
className="bg-surface text-fg border-border focus:border-brand-500 h-8 rounded-md border px-2 text-xs outline-none"
>
{pageSizes.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<div className="flex items-center gap-0.5">
<PageNavButton
disabled={page === 0}
onClick={() => onPageChange(0)}
label="First page"
>
<ChevronsLeft size={16} />
</PageNavButton>
<PageNavButton
disabled={page === 0}
onClick={() => onPageChange(page - 1)}
label="Previous page"
>
<ChevronLeft size={16} />
</PageNavButton>
<span className="text-fg-muted px-3 tabular-nums">
<span className="text-fg font-medium">{page + 1}</span> / {pageCount}
</span>
<PageNavButton
disabled={page >= pageCount - 1}
onClick={() => onPageChange(page + 1)}
label="Next page"
>
<ChevronRight size={16} />
</PageNavButton>
<PageNavButton
disabled={page >= pageCount - 1}
onClick={() => onPageChange(pageCount - 1)}
label="Last page"
>
<ChevronsRight size={16} />
</PageNavButton>
</div>
</div>
</div>
);
}
function PageNavButton({
disabled,
onClick,
label,
children,
}: {
disabled?: boolean;
onClick: () => void;
label: string;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
className="text-fg-muted hover:text-fg hover:bg-surface-3 inline-flex h-8 w-8 items-center justify-center rounded-md transition disabled:opacity-30 disabled:hover:bg-transparent"
>
{children}
</button>
);
}
function Checkbox({
checked,
indeterminate,
onChange,
...rest
}: {
checked: boolean;
indeterminate?: boolean;
onChange: () => void;
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, "checked" | "onChange" | "type">) {
return (
<input
type="checkbox"
checked={checked}
onChange={onChange}
ref={(el) => {
if (el) el.indeterminate = !!indeterminate && !checked;
}}
className="accent-brand-600 border-border h-4 w-4 cursor-pointer rounded"
{...rest}
/>
);
}