Files
AI/ontology_platform/web/frontend/src/components/ui/data-table.tsx
LASTA_DEV01\lasta 651c47d698 1
2026-05-20 18:43:08 +09:00

1055 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}