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>
|
|||
|
|
);
|
|||
|
|
}
|