1055 lines
34 KiB
TypeScript
1055 lines
34 KiB
TypeScript
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>
|
||
);
|
||
}
|