Files
AI/ontology_platform/web/frontend/src/components/ui/data-table.tsx

1055 lines
34 KiB
TypeScript
Raw Normal View History

2026-05-20 18:43:08 +09:00
import * as React from "react";
import {
ColumnDef,
ColumnFiltersState,
ColumnOrderState,
ColumnPinningState,
ColumnSizingState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
PaginationState,
Row,
SortingState,
Table as ReactTable,
useReactTable,
VisibilityState,
} from "@tanstack/react-table";
import { useVirtualizer } from "@tanstack/react-virtual";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import {
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronsUpDown,
ChevronUp,
Columns,
Inbox,
Pin,
PinOff,
Save,
Trash2,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "./button";
import { EmptyState } from "./empty-state";
import { LoadingState } from "./loading-state";
/* ============================================================================
* DataTable v2 TanStack Table + react-virtual + Radix dropdowns
*
* Features:
* - Virtualized rows (auto-enables above `virtualizeAbove` row threshold)
* - Sortable columns (asc desc unsorted)
* - Column resize (drag handle)
* - Column visibility toggle (DropdownMenu)
* - Column pinning (left/right/unpin) via per-column dropdown
* - Pagination footer (optional)
* - Saved views (localStorage-backed) keyed by `tableId`
*
* Re-export ColumnDef from @tanstack/react-table so consumers can type their
* columns with full IntelliSense.
* ========================================================================== */
export type { ColumnDef } from "@tanstack/react-table";
export type Density = "compact" | "cozy" | "comfortable";
const ROW_HEIGHT: Record<Density, number> = {
compact: 32,
cozy: 40,
comfortable: 48,
};
const CELL_PAD: Record<Density, string> = {
compact: "px-3 py-1.5",
cozy: "px-3 py-2",
comfortable: "px-4 py-3",
};
/* ============================================================
* Saved views localStorage persistence
* ========================================================== */
interface PersistedState {
columnSizing: ColumnSizingState;
columnVisibility: VisibilityState;
columnPinning: ColumnPinningState;
columnOrder: ColumnOrderState;
sorting: SortingState;
}
interface SavedView {
id: string;
name: string;
state: PersistedState;
}
interface SavedViewsConfig {
views: SavedView[];
activeViewId: string | null;
}
const viewsStorageKey = (tableId: string) => `data-table:${tableId}:views`;
const stateStorageKey = (tableId: string) => `data-table:${tableId}:state`;
function readJSON<T>(key: string): T | null {
if (typeof window === "undefined") return null;
try {
const v = window.localStorage.getItem(key);
return v ? (JSON.parse(v) as T) : null;
} catch {
return null;
}
}
function writeJSON(key: string, value: unknown) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
/* ignore quota */
}
}
/* ============================================================
* Main component
* ========================================================== */
export interface DataTableProps<T> {
/** Unique id used to scope localStorage (saved views, column widths). */
tableId: string;
data: T[] | undefined;
columns: ColumnDef<T, any>[];
loading?: boolean;
loadingRows?: number;
/** Loading variant — defaults to "table" */
loadingVariant?: "table" | "spinner";
/** Row stable id getter (default uses row index) */
getRowId?: (row: T, index: number) => string;
density?: Density;
onDensityChange?: (density: Density) => void;
onRowClick?: (row: T, index: number) => void;
/** Custom empty state */
empty?: React.ReactNode;
/** Error state node (overrides empty when present) */
error?: React.ReactNode;
/** Estimated container height — needed for virtualization. */
height?: number | string;
/** Row count over which virtualization auto-enables. Default 50. */
virtualizeAbove?: number;
/** Force virtualization on/off (overrides threshold) */
virtualize?: boolean;
/** Enable pagination footer */
enablePagination?: boolean;
pageSize?: number;
pageSizeOptions?: number[];
/**
* Server-side pagination when true, the table will NOT slice rows itself.
* Parent owns `pagination` state and supplies a fresh `data` page on change.
* Must also pass `pageCount` (total pages from API) or `rowCount` (total rows).
*/
manualPagination?: boolean;
pageCount?: number;
rowCount?: number;
/** Controlled pagination state — required when manualPagination is true */
paginationState?: PaginationState;
onPaginationStateChange?: (state: PaginationState) => void;
/** Column features (default all true) */
enableColumnResize?: boolean;
enableColumnVisibility?: boolean;
enableColumnPinning?: boolean;
/** Enable saved views UI */
enableSavedViews?: boolean;
/** Show density toggle in toolbar */
enableDensityToggle?: boolean;
/** Custom toolbar nodes rendered on the left */
toolbarStart?: React.ReactNode;
/** Custom toolbar nodes rendered on the right (before built-ins) */
toolbarEnd?: React.ReactNode;
/** Row tone — applies a soft background tint */
rowToneAccessor?: (
row: T,
) => "success" | "warning" | "danger" | "info" | undefined;
className?: string;
}
const TONE_BG: Record<"success" | "warning" | "danger" | "info", string> = {
success: "bg-success-subtle/40",
warning: "bg-warning-subtle/40",
danger: "bg-danger-subtle/40",
info: "bg-info-subtle/40",
};
export function DataTable<T>({
tableId,
data,
columns,
loading,
loadingRows = 8,
loadingVariant = "table",
getRowId,
density: densityProp,
onDensityChange,
onRowClick,
empty,
error,
height = 520,
virtualizeAbove = 50,
virtualize,
enablePagination = false,
pageSize: pageSizeProp = 50,
pageSizeOptions = [25, 50, 100, 200],
manualPagination = false,
pageCount,
rowCount,
paginationState,
onPaginationStateChange,
enableColumnResize = true,
enableColumnVisibility = true,
enableColumnPinning = true,
enableSavedViews = true,
enableDensityToggle = true,
toolbarStart,
toolbarEnd,
rowToneAccessor,
className,
}: DataTableProps<T>) {
/* ----------------------- Persisted state ----------------------- */
const persisted = React.useMemo<Partial<PersistedState>>(
() => readJSON<PersistedState>(stateStorageKey(tableId)) ?? {},
[tableId],
);
const [sorting, setSorting] = React.useState<SortingState>(
persisted.sorting ?? [],
);
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(
persisted.columnVisibility ?? {},
);
const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>(
persisted.columnPinning ?? { left: [], right: [] },
);
const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>(
persisted.columnSizing ?? {},
);
const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
persisted.columnOrder ?? [],
);
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[],
);
const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: pageSizeProp,
});
// Controlled when caller passes paginationState (typical for server-side)
const pagination = paginationState ?? internalPagination;
const setPagination = (
updater: PaginationState | ((prev: PaginationState) => PaginationState),
) => {
const next =
typeof updater === "function" ? updater(pagination) : updater;
if (onPaginationStateChange) onPaginationStateChange(next);
else setInternalPagination(next);
};
// Uncontrolled internal density (when no parent control)
const [internalDensity, setInternalDensity] = React.useState<Density>(
densityProp ?? "cozy",
);
const density = densityProp ?? internalDensity;
const setDensity = (d: Density) => {
if (onDensityChange) onDensityChange(d);
else setInternalDensity(d);
};
/* ----------------------- Persist on change ----------------------- */
React.useEffect(() => {
writeJSON(stateStorageKey(tableId), {
sorting,
columnVisibility,
columnPinning,
columnSizing,
columnOrder,
});
}, [
tableId,
sorting,
columnVisibility,
columnPinning,
columnSizing,
columnOrder,
]);
/* ----------------------- Table instance ----------------------- */
const table = useReactTable({
data: data ?? [],
columns,
state: {
sorting,
columnVisibility,
columnPinning,
columnSizing,
columnOrder,
columnFilters,
pagination: enablePagination ? pagination : undefined,
},
getRowId,
onSortingChange: setSorting,
onColumnVisibilityChange: setColumnVisibility,
onColumnPinningChange: setColumnPinning,
onColumnSizingChange: setColumnSizing,
onColumnOrderChange: setColumnOrder,
onColumnFiltersChange: setColumnFilters,
onPaginationChange: setPagination,
columnResizeMode: "onChange",
enableColumnResizing: enableColumnResize,
enableHiding: enableColumnVisibility,
enablePinning: enableColumnPinning,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel:
enablePagination && !manualPagination
? getPaginationRowModel()
: undefined,
manualPagination,
pageCount:
manualPagination && typeof pageCount === "number" ? pageCount : undefined,
rowCount:
manualPagination && typeof rowCount === "number" ? rowCount : undefined,
});
const allRows = table.getRowModel().rows;
const usingPaginationView = enablePagination && !manualPagination;
const shouldVirtualize =
virtualize ?? (!usingPaginationView && allRows.length > virtualizeAbove);
/* ----------------------- Layout helpers ----------------------- */
const cellPadClass = CELL_PAD[density];
const rowH = ROW_HEIGHT[density];
/* ----------------------- Render ----------------------- */
return (
<div className={cn("flex flex-col gap-2", className)}>
<Toolbar
table={table}
tableId={tableId}
density={density}
setDensity={setDensity}
enableSavedViews={enableSavedViews}
enableColumnVisibility={enableColumnVisibility}
enableDensityToggle={enableDensityToggle}
toolbarStart={toolbarStart}
toolbarEnd={toolbarEnd}
sorting={sorting}
setSorting={setSorting}
columnVisibility={columnVisibility}
setColumnVisibility={setColumnVisibility}
columnPinning={columnPinning}
setColumnPinning={setColumnPinning}
columnSizing={columnSizing}
setColumnSizing={setColumnSizing}
columnOrder={columnOrder}
setColumnOrder={setColumnOrder}
/>
<div
className={cn(
"relative overflow-hidden rounded-lg border border-border bg-surface",
)}
>
{error ? (
<div className="px-4">{error}</div>
) : loading ? (
<div className="p-3">
<LoadingState
variant={loadingVariant}
rows={loadingRows}
/>
</div>
) : allRows.length === 0 ? (
empty ?? (
<EmptyState
compact
icon={Inbox}
title="No records"
description="Nothing matches the current filters."
/>
)
) : (
<TableShell
table={table}
allRows={allRows}
cellPadClass={cellPadClass}
rowHeight={rowH}
height={height}
shouldVirtualize={shouldVirtualize}
onRowClick={onRowClick}
rowToneAccessor={rowToneAccessor}
enableColumnResize={enableColumnResize}
/>
)}
</div>
{enablePagination && !loading && allRows.length > 0 && (
<Pagination
table={table}
pageSizeOptions={pageSizeOptions}
/>
)}
</div>
);
}
/* ============================================================
* Toolbar saved views + column visibility + density
* ========================================================== */
function Toolbar<T>({
table,
tableId,
density,
setDensity,
enableSavedViews,
enableColumnVisibility,
enableDensityToggle,
toolbarStart,
toolbarEnd,
sorting,
setSorting,
columnVisibility,
setColumnVisibility,
columnPinning,
setColumnPinning,
columnSizing,
setColumnSizing,
columnOrder,
setColumnOrder,
}: {
table: ReactTable<T>;
tableId: string;
density: Density;
setDensity: (d: Density) => void;
enableSavedViews: boolean;
enableColumnVisibility: boolean;
enableDensityToggle: boolean;
toolbarStart?: React.ReactNode;
toolbarEnd?: React.ReactNode;
sorting: SortingState;
setSorting: React.Dispatch<React.SetStateAction<SortingState>>;
columnVisibility: VisibilityState;
setColumnVisibility: React.Dispatch<React.SetStateAction<VisibilityState>>;
columnPinning: ColumnPinningState;
setColumnPinning: React.Dispatch<React.SetStateAction<ColumnPinningState>>;
columnSizing: ColumnSizingState;
setColumnSizing: React.Dispatch<React.SetStateAction<ColumnSizingState>>;
columnOrder: ColumnOrderState;
setColumnOrder: React.Dispatch<React.SetStateAction<ColumnOrderState>>;
}) {
const currentState: PersistedState = {
sorting,
columnVisibility,
columnPinning,
columnSizing,
columnOrder,
};
return (
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-1 items-center gap-2">{toolbarStart}</div>
<div className="flex items-center gap-1.5">
{toolbarEnd}
{enableSavedViews && (
<ViewsMenu
tableId={tableId}
currentState={currentState}
applyState={(s) => {
setSorting(s.sorting);
setColumnVisibility(s.columnVisibility);
setColumnPinning(s.columnPinning);
setColumnSizing(s.columnSizing);
setColumnOrder(s.columnOrder);
}}
/>
)}
{enableColumnVisibility && <ColumnsMenu table={table} />}
{enableDensityToggle && (
<DensityMenu density={density} setDensity={setDensity} />
)}
</div>
</div>
);
}
/* ============================================================
* ColumnsMenu toggle visibility + pin
* ========================================================== */
function ColumnsMenu<T>({ table }: { table: ReactTable<T> }) {
const cols = table
.getAllLeafColumns()
.filter((c) => c.getCanHide() || c.getCanPin());
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="outline" size="sm" className="h-9">
<Columns className="h-4 w-4" />
Columns
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={4}
className="z-40 w-64 rounded-md border border-border bg-popover p-1 shadow-md animate-fade-in-up"
>
<div className="px-2 py-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Toggle columns
</div>
{cols.map((col) => {
const isPinned = col.getIsPinned();
const headerLabel =
typeof col.columnDef.header === "string"
? col.columnDef.header
: col.id;
return (
<div
key={col.id}
className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent/60"
>
<button
type="button"
onClick={() => col.toggleVisibility()}
className="flex flex-1 items-center gap-2 text-left"
>
<span
className={cn(
"flex h-4 w-4 items-center justify-center rounded border",
col.getIsVisible()
? "border-brand-500 bg-brand-500 text-white"
: "border-border",
)}
>
{col.getIsVisible() && <Check className="h-3 w-3" />}
</span>
<span className="truncate">{headerLabel}</span>
</button>
{col.getCanPin() && (
<button
type="button"
onClick={() => {
col.pin(isPinned === "left" ? false : "left");
}}
className={cn(
"rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground",
isPinned === "left" && "text-brand-600 dark:text-brand-300",
)}
title={isPinned === "left" ? "Unpin" : "Pin to left"}
>
{isPinned === "left" ? (
<PinOff className="h-3.5 w-3.5" />
) : (
<Pin className="h-3.5 w-3.5" />
)}
</button>
)}
</div>
);
})}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
/* ============================================================
* DensityMenu
* ========================================================== */
function DensityMenu({
density,
setDensity,
}: {
density: Density;
setDensity: (d: Density) => void;
}) {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="outline" size="sm" className="h-9">
Density: <span className="font-medium capitalize">{density}</span>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={4}
className="z-40 w-44 rounded-md border border-border bg-popover p-1 shadow-md animate-fade-in-up"
>
{(["compact", "cozy", "comfortable"] as const).map((d) => (
<DropdownMenu.Item
key={d}
onSelect={() => setDensity(d)}
className="flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent/60 data-[highlighted]:bg-accent/60"
>
<span className="capitalize">{d}</span>
{density === d && <Check className="h-3.5 w-3.5" />}
</DropdownMenu.Item>
))}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
/* ============================================================
* ViewsMenu saved views
* ========================================================== */
function ViewsMenu({
tableId,
currentState,
applyState,
}: {
tableId: string;
currentState: PersistedState;
applyState: (s: PersistedState) => void;
}) {
const [config, setConfig] = React.useState<SavedViewsConfig>(() =>
readJSON<SavedViewsConfig>(viewsStorageKey(tableId)) ?? {
views: [],
activeViewId: null,
},
);
const persist = (next: SavedViewsConfig) => {
setConfig(next);
writeJSON(viewsStorageKey(tableId), next);
};
const activeView =
config.views.find((v) => v.id === config.activeViewId) ?? null;
const saveCurrent = () => {
const name = window.prompt("Name for this view");
if (!name?.trim()) return;
const id = `${Date.now().toString(36)}`;
const next: SavedViewsConfig = {
views: [...config.views, { id, name: name.trim(), state: currentState }],
activeViewId: id,
};
persist(next);
};
const apply = (view: SavedView) => {
applyState(view.state);
persist({ ...config, activeViewId: view.id });
};
const remove = (id: string) => {
if (!confirm("Delete this view?")) return;
persist({
views: config.views.filter((v) => v.id !== id),
activeViewId: config.activeViewId === id ? null : config.activeViewId,
});
};
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<Button variant="outline" size="sm" className="h-9 max-w-[180px]">
<Save className="h-3.5 w-3.5" />
<span className="truncate">{activeView?.name ?? "Default view"}</span>
<ChevronDown className="h-3.5 w-3.5" />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
align="end"
sideOffset={4}
className="z-40 w-64 rounded-md border border-border bg-popover p-1 shadow-md animate-fade-in-up"
>
<div className="flex items-center justify-between px-2 py-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Saved views
</div>
{config.views.length === 0 && (
<div className="px-2 py-3 text-center text-xs text-muted-foreground">
No saved views yet
</div>
)}
{config.views.map((view) => (
<div
key={view.id}
className="flex items-center gap-1 rounded-sm px-1 py-0.5 hover:bg-accent/60"
>
<button
type="button"
onClick={() => apply(view)}
className="flex flex-1 items-center gap-2 px-2 py-1 text-left text-sm"
>
{config.activeViewId === view.id ? (
<Check className="h-3.5 w-3.5 text-brand-600 dark:text-brand-300" />
) : (
<span className="h-3.5 w-3.5" />
)}
<span className="truncate">{view.name}</span>
</button>
<button
type="button"
onClick={() => remove(view.id)}
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-danger"
aria-label={`Delete view ${view.name}`}
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
<DropdownMenu.Separator className="my-1 h-px bg-border" />
<DropdownMenu.Item
onSelect={saveCurrent}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent/60 data-[highlighted]:bg-accent/60"
>
<Save className="h-3.5 w-3.5" />
Save current as new view
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
/* ============================================================
* TableShell header + body with optional virtualization
* ========================================================== */
function TableShell<T>({
table,
allRows,
cellPadClass,
rowHeight,
height,
shouldVirtualize,
onRowClick,
rowToneAccessor,
enableColumnResize,
}: {
table: ReactTable<T>;
allRows: Row<T>[];
cellPadClass: string;
rowHeight: number;
height: number | string;
shouldVirtualize: boolean;
onRowClick?: (row: T, index: number) => void;
rowToneAccessor?: (row: T) => "success" | "warning" | "danger" | "info" | undefined;
enableColumnResize: boolean;
}) {
const parentRef = React.useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: allRows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => rowHeight,
overscan: 12,
enabled: shouldVirtualize,
});
const totalSize = shouldVirtualize ? virtualizer.getTotalSize() : "auto";
const items = shouldVirtualize ? virtualizer.getVirtualItems() : null;
return (
<div
ref={parentRef}
className="relative overflow-auto"
style={{ height }}
>
<table
className="w-full border-collapse text-sm tabular-nums"
style={{
tableLayout: "fixed",
width: table.getCenterTotalSize() + getPinnedWidth(table, "left") + getPinnedWidth(table, "right"),
}}
>
<colgroup>
{table.getVisibleLeafColumns().map((col) => (
<col key={col.id} style={{ width: col.getSize() }} />
))}
</colgroup>
<thead className="sticky top-0 z-[2] bg-background-subtle text-xs uppercase tracking-wide text-muted-foreground">
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const col = header.column;
const isSorted = col.getIsSorted();
const canSort = col.getCanSort();
const align =
(col.columnDef.meta as { align?: "left" | "right" | "center" } | undefined)
?.align ?? "left";
const alignClass =
align === "right"
? "text-right"
: align === "center"
? "text-center"
: "text-left";
const pinned = col.getIsPinned();
return (
<th
key={header.id}
scope="col"
style={{
width: header.getSize(),
...getPinStyle(col, "header"),
}}
aria-sort={
isSorted === "asc"
? "ascending"
: isSorted === "desc"
? "descending"
: canSort
? "none"
: undefined
}
className={cn(
"relative border-b border-border font-medium",
cellPadClass,
alignClass,
canSort && "cursor-pointer select-none hover:text-foreground",
pinned && "bg-background-subtle",
)}
onClick={canSort ? col.getToggleSortingHandler() : undefined}
>
{header.isPlaceholder ? null : (
<span
className={cn(
"inline-flex items-center gap-1",
align === "right" && "flex-row-reverse",
)}
>
{flexRender(col.columnDef.header, header.getContext())}
{canSort && (
isSorted === "asc" ? (
<ChevronUp className="h-3.5 w-3.5" />
) : isSorted === "desc" ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronsUpDown className="h-3.5 w-3.5 opacity-40" />
)
)}
</span>
)}
{enableColumnResize && col.getCanResize() && (
<span
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
onClick={(e) => e.stopPropagation()}
className={cn(
"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none",
"bg-transparent hover:bg-brand-500/40",
header.column.getIsResizing() && "bg-brand-500",
)}
role="separator"
aria-orientation="vertical"
/>
)}
</th>
);
})}
</tr>
))}
</thead>
<tbody
style={
shouldVirtualize
? { height: totalSize, position: "relative", display: "block" }
: undefined
}
>
{(shouldVirtualize ? items! : allRows.map((_, i) => ({ index: i, start: 0, size: 0, key: i }))).map(
(virtualRow) => {
const row = allRows[virtualRow.index];
if (!row) return null;
const tone = rowToneAccessor?.(row.original);
return (
<tr
key={row.id}
data-index={virtualRow.index}
style={
shouldVirtualize
? {
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: rowHeight,
transform: `translateY(${virtualRow.start}px)`,
display: "table",
tableLayout: "fixed",
}
: { height: rowHeight }
}
className={cn(
"border-b border-border transition-colors last:border-b-0",
tone ? TONE_BG[tone] : "hover:bg-background-subtle",
onRowClick && "cursor-pointer",
)}
onClick={onRowClick ? () => onRowClick(row.original, virtualRow.index) : undefined}
>
{row.getVisibleCells().map((cell) => {
const col = cell.column;
const align =
(col.columnDef.meta as { align?: "left" | "right" | "center" } | undefined)
?.align ?? "left";
const alignClass =
align === "right"
? "text-right"
: align === "center"
? "text-center"
: "text-left";
const pinned = col.getIsPinned();
return (
<td
key={cell.id}
style={{
width: col.getSize(),
...getPinStyle(col, "cell"),
}}
className={cn(
cellPadClass,
alignClass,
"text-foreground",
pinned && "bg-surface",
)}
>
{flexRender(col.columnDef.cell, cell.getContext())}
</td>
);
})}
</tr>
);
},
)}
</tbody>
</table>
</div>
);
}
/* ============================================================
* Pinning helpers sticky positioning for pinned columns
* ========================================================== */
function getPinStyle(
column: ReactTable<any>["getColumn"] extends (...a: any) => infer R ? R : never,
kind: "header" | "cell",
): React.CSSProperties {
if (!column) return {};
const pinned = column.getIsPinned();
if (!pinned) return {};
const offset = pinned === "left" ? column.getStart("left") : column.getAfter("right");
return {
position: "sticky",
[pinned === "left" ? "left" : "right"]: `${offset}px`,
zIndex: kind === "header" ? 3 : 1,
};
}
function getPinnedWidth<T>(table: ReactTable<T>, side: "left" | "right"): number {
return table
.getAllLeafColumns()
.filter((c) => c.getIsPinned() === side && c.getIsVisible())
.reduce((sum, c) => sum + c.getSize(), 0);
}
/* ============================================================
* Pagination
* ========================================================== */
function Pagination<T>({
table,
pageSizeOptions,
}: {
table: ReactTable<T>;
pageSizeOptions: number[];
}) {
const { pageIndex, pageSize } = table.getState().pagination;
const pageCount = table.getPageCount();
const totalRows = table.getFilteredRowModel().rows.length;
const start = pageIndex * pageSize + 1;
const end = Math.min(start + pageSize - 1, totalRows);
return (
<div className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm">
<div className="flex items-center gap-3 text-muted-foreground">
<span className="tabular-nums">
{totalRows === 0 ? 0 : `${start}${end}`} of {totalRows}
</span>
<label className="flex items-center gap-2">
<span>Rows per page</span>
<select
className="h-8 rounded-md border border-border bg-surface px-2 text-sm"
value={pageSize}
onChange={(e) => table.setPageSize(Number(e.target.value))}
>
{pageSizeOptions.map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()}
aria-label="First page"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="px-2 text-sm tabular-nums text-muted-foreground">
{pageIndex + 1} / {Math.max(pageCount, 1)}
</span>
<Button
variant="ghost"
size="icon"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => table.setPageIndex(pageCount - 1)}
disabled={!table.getCanNextPage()}
aria-label="Last page"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
);
}