This commit refactors the DataTable and ObjectTable components to enhance state management. In DataTable, the column visibility state is now set more efficiently by defaulting to an empty object when no initial visibility is provided. In ObjectTable, local entries are introduced to maintain alignment with server updates, and a new callback for local value changes is added to EditableCell, improving the responsiveness of the UI during data edits. Additionally, the handling of row selection during bulk delete operations is updated to use local entries, ensuring consistency across the component's state.
866 lines
28 KiB
TypeScript
866 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(() => {
|
||
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>
|
||
);
|
||
}
|