summaryrefslogtreecommitdiff
path: root/components/client-table-v3/client-virtual-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/client-table-v3/client-virtual-table.tsx')
-rw-r--r--components/client-table-v3/client-virtual-table.tsx309
1 files changed, 309 insertions, 0 deletions
diff --git a/components/client-table-v3/client-virtual-table.tsx b/components/client-table-v3/client-virtual-table.tsx
new file mode 100644
index 00000000..7a092326
--- /dev/null
+++ b/components/client-table-v3/client-virtual-table.tsx
@@ -0,0 +1,309 @@
+"use client";
+
+import * as React from "react";
+import { Table, flexRender } from "@tanstack/react-table";
+import { useVirtualizer } from "@tanstack/react-virtual";
+import {
+ DndContext,
+ closestCenter,
+ KeyboardSensor,
+ PointerSensor,
+ useSensor,
+ useSensors,
+ DragEndEvent,
+} from "@dnd-kit/core";
+import {
+ arrayMove,
+ SortableContext,
+ horizontalListSortingStrategy,
+} from "@dnd-kit/sortable";
+import { cn } from "@/lib/utils";
+import { Loader2, ChevronRight, ChevronDown } from "lucide-react";
+
+import { ClientTableToolbar } from "../client-table/client-table-toolbar";
+import { exportToExcel } from "../client-table/export-utils";
+import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination";
+import { ClientTableViewOptions } from "../client-table/client-table-view-options";
+
+import { ClientTableColumnHeader } from "./client-table-column-header";
+import { ClientTablePreset } from "./client-table-preset";
+import { ClientVirtualTableProps } from "./types";
+
+export function ClientVirtualTable<TData>({
+ table,
+ isLoading = false,
+ height = "100%",
+ estimateRowHeight = 40,
+ className,
+ actions,
+ customToolbar,
+ enableExport = true,
+ onExport,
+ enableUserPreset = false,
+ tableKey,
+ getRowClassName,
+ onRowClick,
+ renderHeaderVisualFeedback,
+}: ClientVirtualTableProps<TData>) {
+ // --- DnD Sensors ---
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8,
+ },
+ }),
+ useSensor(KeyboardSensor)
+ );
+
+ // --- Drag Handler ---
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (active && over && active.id !== over.id) {
+ const activeId = active.id as string;
+ const overId = over.id as string;
+
+ const activeColumn = table.getColumn(activeId);
+ const overColumn = table.getColumn(overId);
+
+ if (activeColumn && overColumn) {
+ const activePinState = activeColumn.getIsPinned();
+ const overPinState = overColumn.getIsPinned();
+
+ // If dragging between different pin states, update the pin state
+ if (activePinState !== overPinState) {
+ activeColumn.pin(overPinState);
+ }
+
+ // Reorder
+ const currentOrder = table.getState().columnOrder;
+ const oldIndex = currentOrder.indexOf(activeId);
+ const newIndex = currentOrder.indexOf(overId);
+
+ if (oldIndex !== -1 && newIndex !== -1) {
+ table.setColumnOrder(arrayMove(currentOrder, oldIndex, newIndex));
+ }
+ }
+ }
+ };
+
+ // --- Virtualization ---
+ const tableContainerRef = React.useRef<HTMLDivElement>(null);
+ const { rows } = table.getRowModel();
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => estimateRowHeight,
+ overscan: 10,
+ });
+
+ const virtualRows = rowVirtualizer.getVirtualItems();
+ const totalSize = rowVirtualizer.getTotalSize();
+
+ const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
+ const paddingBottom =
+ virtualRows.length > 0
+ ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0)
+ : 0;
+
+ // --- Export ---
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(table.getFilteredRowModel().rows.map((r) => r.original));
+ return;
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original);
+ // Note: exportToExcel needs columns definition. table.getAllColumns() or visible columns?
+ // Using table.getAllLeafColumns() usually.
+ await exportToExcel(currentData, table.getAllLeafColumns(), `export-${new Date().toISOString().slice(0, 10)}.xlsx`);
+ };
+
+ const columns = table.getVisibleLeafColumns();
+ const data = table.getFilteredRowModel().rows; // or just rows which is from getRowModel
+
+ return (
+ <div
+ className={cn("flex flex-col gap-4", className)}
+ style={{ height }}
+ >
+ <ClientTableToolbar
+ globalFilter={table.getState().globalFilter ?? ""}
+ setGlobalFilter={table.setGlobalFilter}
+ totalRows={table.getRowCount()}
+ visibleRows={table.getRowModel().rows.length}
+ onExport={enableExport ? handleExport : undefined}
+ viewOptions={
+ <>
+ <ClientTableViewOptions table={table} />
+ {enableUserPreset && tableKey && (
+ <ClientTablePreset table={table} tableKey={tableKey} />
+ )}
+ </>
+ }
+ customToolbar={customToolbar}
+ actions={actions}
+ />
+
+ <div
+ ref={tableContainerRef}
+ className="relative border rounded-md overflow-auto bg-background flex-1 min-h-0"
+ >
+ {isLoading && (
+ <div className="absolute inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm">
+ <Loader2 className="h-8 w-8 animate-spin text-primary" />
+ </div>
+ )}
+
+ <DndContext
+ sensors={sensors}
+ collisionDetection={closestCenter}
+ onDragEnd={handleDragEnd}
+ >
+ <table
+ className="table-fixed border-collapse w-full min-w-full"
+ style={{ width: table.getTotalSize() }}
+ >
+ <thead className="sticky top-0 z-40 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ <SortableContext
+ items={headerGroup.headers.map((h) => h.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+ {headerGroup.headers.map((header) => (
+ <ClientTableColumnHeader
+ key={header.id}
+ header={header}
+ enableReordering={true}
+ renderHeaderVisualFeedback={renderHeaderVisualFeedback}
+ />
+ ))}
+ </SortableContext>
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.length === 0 && !isLoading ? (
+ <tr>
+ <td colSpan={columns.length} className="h-24 text-center">
+ No results.
+ </td>
+ </tr>
+ ) : (
+ virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index];
+
+ // --- Group Header Rendering ---
+ if (row.getIsGrouped()) {
+ const groupingColumnId = row.groupingColumnId ?? "";
+ const groupingValue = row.getGroupingValue(groupingColumnId);
+
+ return (
+ <tr
+ key={row.id}
+ className="hover:bg-muted/50 border-b bg-muted/30"
+ style={{ height: `${virtualRow.size}px` }}
+ >
+ <td
+ colSpan={columns.length}
+ className="px-4 py-2 text-left font-medium cursor-pointer"
+ onClick={row.getToggleExpandedHandler()}
+ >
+ <div className="flex items-center gap-2">
+ {row.getIsExpanded() ? (
+ <ChevronDown className="h-4 w-4" />
+ ) : (
+ <ChevronRight className="h-4 w-4" />
+ )}
+ <span className="flex items-center gap-2">
+ <span className="font-bold capitalize">
+ {groupingColumnId}:
+ </span>
+ <span>
+ {String(groupingValue)}
+ </span>
+ <span className="text-muted-foreground text-sm font-normal">
+ ({row.subRows.length})
+ </span>
+ </span>
+ </div>
+ </td>
+ </tr>
+ );
+ }
+
+ // --- Normal Row Rendering ---
+ return (
+ <tr
+ key={row.id}
+ className={cn(
+ "hover:bg-muted/50 border-b last:border-0",
+ getRowClassName ? getRowClassName(row.original, row.index) : "",
+ onRowClick ? "cursor-pointer" : ""
+ )}
+ style={{ height: `${virtualRow.size}px` }}
+ onClick={(e) => onRowClick?.(row, e)}
+ >
+ {row.getVisibleCells().map((cell) => {
+ const isPinned = cell.column.getIsPinned();
+ const isGrouped = cell.column.getIsGrouped();
+
+ const style: React.CSSProperties = {
+ width: cell.column.getSize(),
+ };
+ if (isPinned === "left") {
+ style.position = "sticky";
+ style.left = `${cell.column.getStart("left")}px`;
+ style.zIndex = 20;
+ } else if (isPinned === "right") {
+ style.position = "sticky";
+ style.right = `${cell.column.getAfter("right")}px`;
+ style.zIndex = 20;
+ }
+
+ return (
+ <td
+ key={cell.id}
+ className={cn(
+ "px-2 py-0 text-sm truncate border-b bg-background",
+ isGrouped ? "bg-muted/20" : ""
+ )}
+ style={style}
+ >
+ {cell.getIsGrouped() ? null : cell.getIsAggregated() ? (
+ flexRender(
+ cell.column.columnDef.aggregatedCell ??
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )
+ ) : cell.getIsPlaceholder() ? null : (
+ flexRender(cell.column.columnDef.cell, cell.getContext())
+ )}
+ </td>
+ );
+ })}
+ </tr>
+ );
+ })
+ )}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </DndContext>
+ </div>
+
+ <ClientDataTablePagination table={table} />
+ </div>
+ );
+}
+
+