868 lines
28 KiB
TypeScript
868 lines
28 KiB
TypeScript
"use client";
|
||
|
||
import React, { useState, useCallback, useMemo, useRef, useEffect } from "react";
|
||
import {
|
||
useReactTable,
|
||
getCoreRowModel,
|
||
getSortedRowModel,
|
||
getFilteredRowModel,
|
||
getPaginationRowModel,
|
||
flexRender,
|
||
type ColumnDef,
|
||
type SortingState,
|
||
type ColumnFiltersState,
|
||
type VisibilityState,
|
||
type Row,
|
||
type OnChangeFn,
|
||
type PaginationState,
|
||
} from "@tanstack/react-table";
|
||
import {
|
||
DndContext,
|
||
closestCenter,
|
||
type DragEndEvent,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
} from "@dnd-kit/core";
|
||
import {
|
||
SortableContext,
|
||
horizontalListSortingStrategy,
|
||
useSortable,
|
||
arrayMove,
|
||
} from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import { rankItem } from "@tanstack/match-sorter-utils";
|
||
|
||
/* ─── Types ─── */
|
||
|
||
export type RowAction<TData> = {
|
||
label: string;
|
||
onClick?: (row: TData) => void;
|
||
icon?: React.ReactNode;
|
||
variant?: "default" | "destructive";
|
||
};
|
||
|
||
export type DataTableProps<TData, TValue> = {
|
||
columns: ColumnDef<TData, TValue>[];
|
||
data: TData[];
|
||
loading?: boolean;
|
||
// search
|
||
searchPlaceholder?: string;
|
||
enableGlobalFilter?: boolean;
|
||
// sorting
|
||
enableSorting?: boolean;
|
||
// row selection
|
||
enableRowSelection?: boolean;
|
||
rowSelection?: Record<string, boolean>;
|
||
onRowSelectionChange?: OnChangeFn<Record<string, boolean>>;
|
||
bulkActions?: React.ReactNode;
|
||
// column features
|
||
enableColumnReordering?: boolean;
|
||
onColumnReorder?: (newOrder: string[]) => void;
|
||
initialColumnVisibility?: VisibilityState;
|
||
// pagination
|
||
pageSize?: number;
|
||
// actions
|
||
onRefresh?: () => void;
|
||
onAdd?: () => void;
|
||
addButtonLabel?: string;
|
||
onRowClick?: (row: TData, index: number) => void;
|
||
rowActions?: (row: TData) => RowAction<TData>[];
|
||
// toolbar
|
||
toolbarExtra?: React.ReactNode;
|
||
title?: string;
|
||
titleIcon?: React.ReactNode;
|
||
// sticky
|
||
stickyFirstColumn?: boolean;
|
||
// server-side pagination
|
||
serverPagination?: {
|
||
totalCount: number;
|
||
page: number;
|
||
pageSize: number;
|
||
onPageChange: (page: number) => void;
|
||
onPageSizeChange: (size: number) => void;
|
||
};
|
||
// server-side search callback (replaces client-side fuzzy filter)
|
||
onServerSearch?: (query: string) => void;
|
||
};
|
||
|
||
/* ─── Fuzzy filter ─── */
|
||
|
||
function fuzzyFilter(
|
||
row: Row<unknown>,
|
||
columnId: string,
|
||
filterValue: string,
|
||
) {
|
||
const result = rankItem(row.getValue(columnId), filterValue);
|
||
return result.passed;
|
||
}
|
||
|
||
/* ─── Sortable header cell (DnD) ─── */
|
||
|
||
function SortableHeader({
|
||
id,
|
||
children,
|
||
style,
|
||
className,
|
||
}: {
|
||
id: string;
|
||
children: React.ReactNode;
|
||
style?: React.CSSProperties;
|
||
className?: string;
|
||
}) {
|
||
const {
|
||
attributes,
|
||
listeners,
|
||
setNodeRef,
|
||
transform,
|
||
transition,
|
||
isDragging,
|
||
} = useSortable({ id });
|
||
|
||
const dragStyle: React.CSSProperties = {
|
||
...style,
|
||
transform: CSS.Translate.toString(transform),
|
||
transition,
|
||
opacity: isDragging ? 0.5 : 1,
|
||
cursor: "grab",
|
||
};
|
||
|
||
return (
|
||
<th
|
||
ref={setNodeRef}
|
||
style={dragStyle}
|
||
className={className}
|
||
{...attributes}
|
||
{...listeners}
|
||
>
|
||
{children}
|
||
</th>
|
||
);
|
||
}
|
||
|
||
/* ─── Sort icon ─── */
|
||
|
||
function SortIcon({ direction }: { direction: "asc" | "desc" | false }) {
|
||
if (!direction) {
|
||
return (
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.25 }}>
|
||
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
|
||
</svg>
|
||
);
|
||
}
|
||
return (
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
{direction === "asc" ? <path d="m5 12 7-7 7 7" /> : <path d="m19 12-7 7-7-7" />}
|
||
</svg>
|
||
);
|
||
}
|
||
|
||
/* ─── Main component ─── */
|
||
|
||
export function DataTable<TData, TValue>({
|
||
columns,
|
||
data,
|
||
loading = false,
|
||
searchPlaceholder = "Search...",
|
||
enableGlobalFilter = true,
|
||
enableSorting = true,
|
||
enableRowSelection = false,
|
||
rowSelection: externalRowSelection,
|
||
onRowSelectionChange,
|
||
bulkActions,
|
||
enableColumnReordering = false,
|
||
onColumnReorder,
|
||
initialColumnVisibility,
|
||
pageSize: defaultPageSize = 100,
|
||
onRefresh,
|
||
onAdd,
|
||
addButtonLabel = "+ Add",
|
||
onRowClick,
|
||
rowActions,
|
||
toolbarExtra,
|
||
title,
|
||
titleIcon,
|
||
stickyFirstColumn: stickyFirstProp = true,
|
||
serverPagination,
|
||
onServerSearch,
|
||
}: DataTableProps<TData, TValue>) {
|
||
const [sorting, setSorting] = useState<SortingState>([]);
|
||
const [globalFilter, setGlobalFilter] = useState("");
|
||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(initialColumnVisibility ?? {});
|
||
// Sync column visibility when the prop changes (e.g. loading a saved view)
|
||
useEffect(() => {
|
||
if (initialColumnVisibility) {
|
||
setColumnVisibility(initialColumnVisibility);
|
||
}
|
||
}, [initialColumnVisibility]);
|
||
const [internalRowSelection, setInternalRowSelection] = useState<Record<string, boolean>>({});
|
||
const [showColumnsMenu, setShowColumnsMenu] = useState(false);
|
||
const [stickyFirstColumn, setStickyFirstColumn] = useState(stickyFirstProp);
|
||
const [isScrolled, setIsScrolled] = useState(false);
|
||
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: defaultPageSize });
|
||
const columnsMenuRef = useRef<HTMLDivElement>(null);
|
||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||
|
||
const rowSelectionState = externalRowSelection !== undefined ? externalRowSelection : internalRowSelection;
|
||
|
||
// Extract column ID from ColumnDef
|
||
const getColumnId = useCallback((c: ColumnDef<TData, TValue>): string => {
|
||
if ("id" in c && typeof c.id === "string") {return c.id;}
|
||
if ("accessorKey" in c && typeof c.accessorKey === "string") {return c.accessorKey;}
|
||
return "";
|
||
}, []);
|
||
|
||
// Column order for DnD — include "select" at start and "actions" at end
|
||
// so TanStack doesn't push them to the end of the table
|
||
const buildColumnOrder = useCallback(
|
||
(dataCols: ColumnDef<TData, TValue>[]) => {
|
||
const dataOrder = dataCols.map(getColumnId);
|
||
const order: string[] = [];
|
||
if (enableRowSelection) {order.push("select");}
|
||
order.push(...dataOrder);
|
||
if (rowActions) {order.push("actions");}
|
||
return order;
|
||
},
|
||
[getColumnId, enableRowSelection, rowActions],
|
||
);
|
||
|
||
const [columnOrder, setColumnOrder] = useState<string[]>(() =>
|
||
buildColumnOrder(columns),
|
||
);
|
||
|
||
// Update column order when columns change
|
||
useEffect(() => {
|
||
setColumnOrder(buildColumnOrder(columns));
|
||
}, [columns, buildColumnOrder]);
|
||
|
||
// DnD sensors
|
||
const sensors = useSensors(
|
||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||
);
|
||
|
||
const handleDragEnd = useCallback(
|
||
(event: DragEndEvent) => {
|
||
const { active, over } = event;
|
||
if (over && active.id !== over.id) {
|
||
setColumnOrder((old) => {
|
||
const oldIndex = old.indexOf(active.id as string);
|
||
const newIndex = old.indexOf(over.id as string);
|
||
const newOrder = arrayMove(old, oldIndex, newIndex);
|
||
onColumnReorder?.(newOrder);
|
||
return newOrder;
|
||
});
|
||
}
|
||
},
|
||
[onColumnReorder],
|
||
);
|
||
|
||
// Scroll tracking for sticky column shadow
|
||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||
setIsScrolled(e.currentTarget.scrollLeft > 0);
|
||
}, []);
|
||
|
||
// Close columns menu on click outside
|
||
useEffect(() => {
|
||
function handleClick(e: MouseEvent) {
|
||
if (columnsMenuRef.current && !columnsMenuRef.current.contains(e.target as Node)) {
|
||
setShowColumnsMenu(false);
|
||
}
|
||
}
|
||
if (showColumnsMenu) {
|
||
document.addEventListener("mousedown", handleClick);
|
||
return () => document.removeEventListener("mousedown", handleClick);
|
||
}
|
||
}, [showColumnsMenu]);
|
||
|
||
// Build selection column
|
||
const selectionColumn: ColumnDef<TData> | null = enableRowSelection
|
||
? {
|
||
id: "select",
|
||
header: ({ table }) => (
|
||
<input
|
||
type="checkbox"
|
||
checked={table.getIsAllPageRowsSelected()}
|
||
onChange={table.getToggleAllPageRowsSelectedHandler()}
|
||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
|
||
/>
|
||
),
|
||
cell: ({ row }) => (
|
||
<input
|
||
type="checkbox"
|
||
checked={row.getIsSelected()}
|
||
onChange={row.getToggleSelectedHandler()}
|
||
onClick={(e) => e.stopPropagation()}
|
||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)] cursor-pointer"
|
||
/>
|
||
),
|
||
size: 40,
|
||
enableSorting: false,
|
||
enableHiding: false,
|
||
}
|
||
: null;
|
||
|
||
// Build actions column
|
||
const actionsColumn: ColumnDef<TData> | null = rowActions
|
||
? {
|
||
id: "actions",
|
||
header: () => null,
|
||
cell: ({ row }) => (
|
||
<RowActionsMenu
|
||
row={row.original}
|
||
actions={rowActions(row.original)}
|
||
/>
|
||
),
|
||
size: 48,
|
||
enableSorting: false,
|
||
enableHiding: false,
|
||
}
|
||
: null;
|
||
|
||
const allColumns = useMemo(() => {
|
||
const cols: ColumnDef<TData, TValue>[] = [];
|
||
if (selectionColumn) {cols.push(selectionColumn as ColumnDef<TData, TValue>);}
|
||
cols.push(...columns);
|
||
if (actionsColumn) {cols.push(actionsColumn as ColumnDef<TData, TValue>);}
|
||
return cols;
|
||
}, [columns, selectionColumn, actionsColumn]);
|
||
|
||
// Server-side pagination state derived from props
|
||
const serverPaginationState = serverPagination
|
||
? { pageIndex: serverPagination.page - 1, pageSize: serverPagination.pageSize }
|
||
: undefined;
|
||
|
||
const table = useReactTable({
|
||
data,
|
||
columns: allColumns,
|
||
state: {
|
||
sorting,
|
||
globalFilter,
|
||
columnFilters,
|
||
columnVisibility,
|
||
rowSelection: rowSelectionState,
|
||
columnOrder: enableColumnReordering ? columnOrder : undefined,
|
||
pagination: serverPaginationState ?? pagination,
|
||
},
|
||
onSortingChange: setSorting,
|
||
onGlobalFilterChange: setGlobalFilter,
|
||
onColumnFiltersChange: setColumnFilters,
|
||
onColumnVisibilityChange: setColumnVisibility,
|
||
onRowSelectionChange: (updater) => {
|
||
if (onRowSelectionChange) {
|
||
onRowSelectionChange(updater);
|
||
} else {
|
||
setInternalRowSelection(updater);
|
||
}
|
||
},
|
||
onPaginationChange: serverPagination
|
||
? (updater) => {
|
||
const newVal = typeof updater === "function"
|
||
? updater(serverPaginationState!)
|
||
: updater;
|
||
if (newVal.pageSize !== serverPagination.pageSize) {
|
||
serverPagination.onPageSizeChange(newVal.pageSize);
|
||
} else if (newVal.pageIndex !== serverPagination.page - 1) {
|
||
serverPagination.onPageChange(newVal.pageIndex + 1);
|
||
}
|
||
}
|
||
: setPagination,
|
||
getCoreRowModel: getCoreRowModel(),
|
||
getSortedRowModel: enableSorting ? getSortedRowModel() : undefined,
|
||
getFilteredRowModel: serverPagination ? undefined : getFilteredRowModel(),
|
||
getPaginationRowModel: serverPagination ? undefined : getPaginationRowModel(),
|
||
...(serverPagination ? {
|
||
manualPagination: true,
|
||
pageCount: Math.ceil(serverPagination.totalCount / serverPagination.pageSize),
|
||
} : {}),
|
||
enableRowSelection,
|
||
enableSorting,
|
||
globalFilterFn: fuzzyFilter,
|
||
columnResizeMode: "onChange",
|
||
});
|
||
|
||
const selectedCount = Object.keys(rowSelectionState).filter((k) => rowSelectionState[k]).length;
|
||
const visibleColumns = table.getVisibleFlatColumns().filter((c) => c.id !== "select" && c.id !== "actions");
|
||
|
||
// ─── Render ───
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
{/* Toolbar */}
|
||
<div
|
||
className="flex items-center gap-2 px-4 py-2.5 flex-shrink-0 flex-wrap"
|
||
style={{ borderBottom: "1px solid var(--color-border)" }}
|
||
>
|
||
{title && (
|
||
<div className="flex items-center gap-2 mr-2">
|
||
{titleIcon}
|
||
<span className="text-sm font-semibold" style={{ color: "var(--color-text)" }}>
|
||
{title}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Search */}
|
||
{enableGlobalFilter && (
|
||
<div className="relative flex-1 min-w-[180px] max-w-[320px]">
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: "var(--color-text-muted)" }}>
|
||
<circle cx="11" cy="11" r="8" /><path d="m21 21-4.3-4.3" />
|
||
</svg>
|
||
<input
|
||
type="text"
|
||
value={globalFilter}
|
||
onChange={(e) => {
|
||
setGlobalFilter(e.target.value);
|
||
onServerSearch?.(e.target.value);
|
||
}}
|
||
placeholder={searchPlaceholder}
|
||
className="w-full pl-9 pr-3 py-1.5 text-xs rounded-full outline-none"
|
||
style={{
|
||
background: "var(--color-surface-hover)",
|
||
color: "var(--color-text)",
|
||
border: "1px solid var(--color-border)",
|
||
}}
|
||
/>
|
||
{globalFilter && (
|
||
<button
|
||
type="button"
|
||
onClick={() => { setGlobalFilter(""); onServerSearch?.(""); }}
|
||
className="absolute right-2.5 top-1/2 -translate-y-1/2"
|
||
style={{ color: "var(--color-text-muted)" }}
|
||
>
|
||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 6 6 18" /><path d="m6 6 12 12" /></svg>
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Bulk actions */}
|
||
{selectedCount > 0 && bulkActions && (
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-medium" style={{ color: "var(--color-text-muted)" }}>
|
||
{selectedCount} selected
|
||
</span>
|
||
{bulkActions}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1" />
|
||
|
||
{toolbarExtra}
|
||
|
||
{/* Refresh */}
|
||
{onRefresh && (
|
||
<button
|
||
type="button"
|
||
onClick={onRefresh}
|
||
className="p-1.5 rounded-lg"
|
||
style={{ color: "var(--color-text-muted)" }}
|
||
title="Refresh"
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /><path d="M21 3v5h-5" /><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /><path d="M3 21v-5h5" />
|
||
</svg>
|
||
</button>
|
||
)}
|
||
|
||
{/* Columns menu */}
|
||
<div className="relative" ref={columnsMenuRef}>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowColumnsMenu((v) => !v)}
|
||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs font-medium"
|
||
style={{
|
||
color: "var(--color-text-muted)",
|
||
border: "1px solid var(--color-border)",
|
||
background: showColumnsMenu ? "var(--color-surface-hover)" : "transparent",
|
||
}}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" />
|
||
</svg>
|
||
Columns
|
||
</button>
|
||
{showColumnsMenu && (
|
||
<div
|
||
className="absolute right-0 top-full mt-1 z-50 min-w-[200px] rounded-xl overflow-hidden py-1"
|
||
style={{
|
||
background: "var(--color-surface)",
|
||
border: "1px solid var(--color-border)",
|
||
boxShadow: "var(--shadow-lg)",
|
||
}}
|
||
>
|
||
{/* Sticky first col toggle */}
|
||
<label
|
||
className="flex items-center gap-2 px-3 py-2 text-xs cursor-pointer"
|
||
style={{ color: "var(--color-text-muted)", borderBottom: "1px solid var(--color-border)" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={stickyFirstColumn}
|
||
onChange={() => setStickyFirstColumn((v) => !v)}
|
||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
|
||
/>
|
||
Freeze first column
|
||
</label>
|
||
{visibleColumns.length === 0 ? (
|
||
<div className="px-3 py-2 text-xs" style={{ color: "var(--color-text-muted)" }}>
|
||
No toggleable columns
|
||
</div>
|
||
) : (
|
||
table.getAllLeafColumns()
|
||
.filter((c) => c.id !== "select" && c.id !== "actions" && c.getCanHide())
|
||
.map((column) => (
|
||
<label
|
||
key={column.id}
|
||
className="flex items-center gap-2 px-3 py-1.5 text-xs cursor-pointer"
|
||
style={{ color: "var(--color-text)" }}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={column.getIsVisible()}
|
||
onChange={column.getToggleVisibilityHandler()}
|
||
className="w-3.5 h-3.5 rounded accent-[var(--color-accent)]"
|
||
/>
|
||
{typeof column.columnDef.header === "string"
|
||
? column.columnDef.header
|
||
: String((column.columnDef.meta as Record<string, string> | undefined)?.label ?? column.id)}
|
||
</label>
|
||
))
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Add button */}
|
||
{onAdd && (
|
||
<button
|
||
type="button"
|
||
onClick={onAdd}
|
||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium"
|
||
style={{
|
||
background: "var(--color-accent)",
|
||
color: "white",
|
||
}}
|
||
>
|
||
{addButtonLabel}
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Table */}
|
||
<div
|
||
ref={scrollContainerRef}
|
||
className="flex-1 overflow-auto min-w-0"
|
||
onScroll={handleScroll}
|
||
>
|
||
{loading ? (
|
||
<LoadingSkeleton columnCount={allColumns.length} />
|
||
) : data.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-20 gap-3">
|
||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ color: "var(--color-text-muted)", opacity: 0.4 }}>
|
||
<rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" /><path d="M9 3v18" />
|
||
</svg>
|
||
<p className="text-sm" style={{ color: "var(--color-text-muted)" }}>No data</p>
|
||
</div>
|
||
) : (
|
||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
|
||
<table className="w-full text-sm" style={{ borderCollapse: "separate", borderSpacing: 0 }}>
|
||
<thead>
|
||
{table.getHeaderGroups().map((headerGroup) => (
|
||
<tr key={headerGroup.id}>
|
||
<SortableContext items={columnOrder} strategy={horizontalListSortingStrategy}>
|
||
{headerGroup.headers.map((header, colIdx) => {
|
||
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
|
||
const isSticky = stickyFirstColumn && isFirstData;
|
||
const isSelectCol = header.id === "select";
|
||
const isActionsCol = header.id === "actions";
|
||
const canSort = header.column.getCanSort();
|
||
|
||
const headerStyle: React.CSSProperties = {
|
||
borderColor: "var(--color-border)",
|
||
background: "var(--color-surface)",
|
||
position: "sticky",
|
||
top: 0,
|
||
zIndex: isSticky || isSelectCol ? 31 : 30,
|
||
...(isSticky ? { left: enableRowSelection ? 40 : 0, boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none" } : {}),
|
||
...(isSelectCol ? { left: 0, position: "sticky", zIndex: 31, width: 40 } : {}),
|
||
width: header.getSize(),
|
||
};
|
||
|
||
const content = header.isPlaceholder
|
||
? null
|
||
: flexRender(header.column.columnDef.header, header.getContext());
|
||
|
||
if (enableColumnReordering && !isSelectCol && !isActionsCol) {
|
||
return (
|
||
<SortableHeader
|
||
key={header.id}
|
||
id={header.id}
|
||
style={headerStyle}
|
||
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
|
||
>
|
||
<span
|
||
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
|
||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||
>
|
||
{content}
|
||
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
|
||
</span>
|
||
</SortableHeader>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<th
|
||
key={header.id}
|
||
style={headerStyle}
|
||
className="text-left px-3 py-2.5 font-medium text-xs uppercase tracking-wider whitespace-nowrap border-b select-none"
|
||
>
|
||
<span
|
||
className={`flex items-center gap-1 ${canSort ? "cursor-pointer" : ""}`}
|
||
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||
style={{ color: "var(--color-text-muted)" }}
|
||
>
|
||
{content}
|
||
{canSort && <SortIcon direction={header.column.getIsSorted()} />}
|
||
</span>
|
||
</th>
|
||
);
|
||
})}
|
||
</SortableContext>
|
||
</tr>
|
||
))}
|
||
</thead>
|
||
<tbody>
|
||
{table.getRowModel().rows.map((row, rowIdx) => {
|
||
const isSelected = row.getIsSelected();
|
||
return (
|
||
<tr
|
||
key={row.id}
|
||
className={`transition-colors duration-75 ${onRowClick ? "cursor-pointer" : ""}`}
|
||
style={{
|
||
background: isSelected
|
||
? "var(--color-accent-light)"
|
||
: rowIdx % 2 === 0
|
||
? "transparent"
|
||
: "var(--color-surface)",
|
||
}}
|
||
onClick={() => onRowClick?.(row.original, rowIdx)}
|
||
onMouseEnter={(e) => {
|
||
if (!isSelected)
|
||
{(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";}
|
||
}}
|
||
onMouseLeave={(e) => {
|
||
if (!isSelected)
|
||
{(e.currentTarget as HTMLElement).style.background =
|
||
rowIdx % 2 === 0 ? "transparent" : "var(--color-surface)";}
|
||
}}
|
||
>
|
||
{row.getVisibleCells().map((cell, colIdx) => {
|
||
const isFirstData = colIdx === (enableRowSelection ? 1 : 0);
|
||
const isSticky = stickyFirstColumn && isFirstData;
|
||
const isSelectCol = cell.column.id === "select";
|
||
|
||
const cellStyle: React.CSSProperties = {
|
||
borderColor: "var(--color-border)",
|
||
...(isSticky
|
||
? {
|
||
position: "sticky" as const,
|
||
left: enableRowSelection ? 40 : 0,
|
||
zIndex: 20,
|
||
background: isSelected
|
||
? "var(--color-accent-light)"
|
||
: "var(--color-bg)",
|
||
boxShadow: isScrolled ? "4px 0 8px -2px rgba(0,0,0,0.08)" : "none",
|
||
}
|
||
: {}),
|
||
...(isSelectCol
|
||
? {
|
||
position: "sticky" as const,
|
||
left: 0,
|
||
zIndex: 20,
|
||
background: isSelected
|
||
? "var(--color-accent-light)"
|
||
: "var(--color-bg)",
|
||
width: 40,
|
||
}
|
||
: {}),
|
||
};
|
||
|
||
return (
|
||
<td
|
||
key={cell.id}
|
||
className="px-3 py-2 border-b whitespace-nowrap"
|
||
style={cellStyle}
|
||
>
|
||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</DndContext>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination footer */}
|
||
{!loading && data.length > 0 && (
|
||
<div
|
||
className="flex items-center justify-between px-4 py-2 text-xs flex-shrink-0"
|
||
style={{
|
||
borderTop: "1px solid var(--color-border)",
|
||
color: "var(--color-text-muted)",
|
||
}}
|
||
>
|
||
<span>
|
||
{serverPagination
|
||
? `Showing ${(serverPagination.page - 1) * serverPagination.pageSize + 1}–${Math.min(serverPagination.page * serverPagination.pageSize, serverPagination.totalCount)} of ${serverPagination.totalCount} results`
|
||
: `Showing ${table.getRowModel().rows.length} of ${data.length} results`}
|
||
{selectedCount > 0 && ` (${selectedCount} selected)`}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<span>Rows per page</span>
|
||
<select
|
||
value={serverPagination ? serverPagination.pageSize : pagination.pageSize}
|
||
onChange={(e) => {
|
||
const newSize = Number(e.target.value);
|
||
if (serverPagination) {
|
||
serverPagination.onPageSizeChange(newSize);
|
||
} else {
|
||
setPagination((p) => ({ ...p, pageSize: newSize, pageIndex: 0 }));
|
||
}
|
||
}}
|
||
className="px-1.5 py-0.5 rounded-md text-xs outline-none"
|
||
style={{
|
||
background: "var(--color-surface-hover)",
|
||
color: "var(--color-text)",
|
||
border: "1px solid var(--color-border)",
|
||
}}
|
||
>
|
||
{[20, 50, 100, 250, 500].map((size) => (
|
||
<option key={size} value={size}>{size}</option>
|
||
))}
|
||
</select>
|
||
<span>
|
||
Page {serverPagination ? serverPagination.page : pagination.pageIndex + 1} of {table.getPageCount()}
|
||
</span>
|
||
<div className="flex gap-0.5">
|
||
{serverPagination ? (
|
||
<>
|
||
<PaginationButton onClick={() => serverPagination.onPageChange(1)} disabled={serverPagination.page <= 1} label="«" />
|
||
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page - 1)} disabled={serverPagination.page <= 1} label="‹" />
|
||
<PaginationButton onClick={() => serverPagination.onPageChange(serverPagination.page + 1)} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="›" />
|
||
<PaginationButton onClick={() => serverPagination.onPageChange(Math.ceil(serverPagination.totalCount / serverPagination.pageSize))} disabled={serverPagination.page >= Math.ceil(serverPagination.totalCount / serverPagination.pageSize)} label="»" />
|
||
</>
|
||
) : (
|
||
<>
|
||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: 0 }))} disabled={!table.getCanPreviousPage()} label="«" />
|
||
<PaginationButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()} label="‹" />
|
||
<PaginationButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()} label="›" />
|
||
<PaginationButton onClick={() => setPagination((p) => ({ ...p, pageIndex: table.getPageCount() - 1 }))} disabled={!table.getCanNextPage()} label="»" />
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/* ─── Sub-components ─── */
|
||
|
||
function PaginationButton({ onClick, disabled, label }: { onClick: () => void; disabled: boolean; label: string }) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
onClick={onClick}
|
||
disabled={disabled}
|
||
className="w-6 h-6 rounded flex items-center justify-center text-xs disabled:opacity-30"
|
||
style={{ color: "var(--color-text-muted)", border: "1px solid var(--color-border)" }}
|
||
// biome-ignore lint: using html entity label
|
||
dangerouslySetInnerHTML={{ __html: label }}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function RowActionsMenu<TData>({ row, actions }: { row: TData; actions: RowAction<TData>[] }) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(null);
|
||
|
||
useEffect(() => {
|
||
function handleClick(e: MouseEvent) {
|
||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||
setOpen(false);
|
||
}
|
||
}
|
||
if (open) {
|
||
document.addEventListener("mousedown", handleClick);
|
||
return () => document.removeEventListener("mousedown", handleClick);
|
||
}
|
||
}, [open]);
|
||
|
||
return (
|
||
<div className="relative" ref={ref}>
|
||
<button
|
||
type="button"
|
||
onClick={(e) => { e.stopPropagation(); setOpen((v) => !v); }}
|
||
className="p-1 rounded-md"
|
||
style={{ color: "var(--color-text-muted)" }}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5" /><circle cx="12" cy="12" r="1.5" /><circle cx="12" cy="19" r="1.5" /></svg>
|
||
</button>
|
||
{open && (
|
||
<div
|
||
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-xl overflow-hidden py-1"
|
||
style={{
|
||
background: "var(--color-surface)",
|
||
border: "1px solid var(--color-border)",
|
||
boxShadow: "var(--shadow-lg)",
|
||
}}
|
||
>
|
||
{actions.map((action, i) => (
|
||
<button
|
||
key={i}
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
action.onClick?.(row);
|
||
setOpen(false);
|
||
}}
|
||
className="flex items-center gap-2 w-full px-3 py-1.5 text-xs text-left"
|
||
style={{
|
||
color: action.variant === "destructive" ? "var(--color-error)" : "var(--color-text)",
|
||
}}
|
||
>
|
||
{action.icon}
|
||
{action.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LoadingSkeleton({ columnCount }: { columnCount: number }) {
|
||
return (
|
||
<div className="p-4 space-y-2">
|
||
{Array.from({ length: 12 }).map((_, i) => (
|
||
<div key={i} className="flex gap-3">
|
||
{Array.from({ length: Math.min(columnCount, 6) }).map((_col, j) => (
|
||
<div
|
||
key={j}
|
||
className="h-8 rounded-lg animate-pulse flex-1"
|
||
style={{ background: "var(--color-surface-hover)", animationDelay: `${j * 50}ms` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|