summaryrefslogtreecommitdiff
path: root/components/client-table/client-virtual-table.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'components/client-table/client-virtual-table.tsx')
-rw-r--r--components/client-table/client-virtual-table.tsx362
1 files changed, 362 insertions, 0 deletions
diff --git a/components/client-table/client-virtual-table.tsx b/components/client-table/client-virtual-table.tsx
new file mode 100644
index 00000000..4825741f
--- /dev/null
+++ b/components/client-table/client-virtual-table.tsx
@@ -0,0 +1,362 @@
+"use client"
+
+import * as React from "react"
+import { rankItem } from "@tanstack/match-sorter-utils"
+import {
+ useReactTable,
+ getCoreRowModel,
+ getSortedRowModel,
+ getFilteredRowModel,
+ getPaginationRowModel,
+ ColumnDef,
+ SortingState,
+ ColumnFiltersState,
+ flexRender,
+ PaginationState,
+ OnChangeFn,
+ ColumnOrderState,
+ VisibilityState,
+ ColumnPinningState,
+ FilterFn,
+ Table,
+ RowSelectionState,
+ Row,
+} 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 { ClientTableToolbar } from "./client-table-toolbar"
+import { exportToExcel } from "./export-utils"
+import { ClientDataTablePagination } from "@/components/client-data-table/data-table-pagination"
+import { ClientTableColumnHeader } from "./client-table-column-header"
+import { ClientTableViewOptions } from "./client-table-view-options"
+
+export interface ClientVirtualTableProps<TData, TValue> {
+ data: TData[]
+ columns: ColumnDef<TData, TValue>[]
+ height?: string | number
+ className?: string
+ actions?: React.ReactNode
+ enableExport?: boolean
+ onExport?: (data: TData[]) => void
+
+ // Pagination Props
+ enablePagination?: boolean
+ manualPagination?: boolean
+ pageCount?: number
+ rowCount?: number
+ pagination?: PaginationState
+ onPaginationChange?: OnChangeFn<PaginationState>
+
+ // Style Props
+ getRowClassName?: (originalRow: TData, index: number) => string
+
+ // Table Meta
+ meta?: any
+
+ // Row ID
+ getRowId?: (originalRow: TData, index: number, parent?: any) => string
+
+ // Selection Props
+ enableRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ enableMultiRowSelection?: boolean | ((row: Row<TData>) => boolean)
+ rowSelection?: RowSelectionState
+ onRowSelectionChange?: OnChangeFn<RowSelectionState>
+}
+
+function ClientVirtualTableInner<TData, TValue>(
+ {
+ data,
+ columns,
+ height = "500px", // Default height
+ className,
+ actions,
+ enableExport = true,
+ onExport,
+
+ // Pagination defaults
+ enablePagination = false,
+ manualPagination = false,
+ pageCount,
+ rowCount,
+ pagination: controlledPagination,
+ onPaginationChange,
+
+ // Style defaults
+ getRowClassName,
+
+ // Meta & RowID
+ meta,
+ getRowId,
+
+ // Selection defaults
+ enableRowSelection,
+ enableMultiRowSelection,
+ rowSelection: controlledRowSelection,
+ onRowSelectionChange,
+ }: ClientVirtualTableProps<TData, TValue>,
+ ref: React.Ref<Table<TData>>
+) {
+ // State
+ const [sorting, setSorting] = React.useState<SortingState>([])
+ const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
+ const [globalFilter, setGlobalFilter] = React.useState("")
+ const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
+ const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: [], right: [] })
+ const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
+ () => columns.map((c) => c.id || (c as any).accessorKey) as string[]
+ )
+
+ // Internal Pagination State
+ const [internalPagination, setInternalPagination] = React.useState<PaginationState>({
+ pageIndex: 0,
+ pageSize: 50,
+ })
+
+ // Internal Row Selection State
+ const [internalRowSelection, setInternalRowSelection] = React.useState<RowSelectionState>({})
+
+ // Fuzzy Filter
+ const fuzzyFilter: FilterFn<TData> = (row, columnId, value, addMeta) => {
+ const itemRank = rankItem(row.getValue(columnId), value)
+ addMeta({ itemRank })
+ return itemRank.passed
+ }
+
+ // Combine controlled and uncontrolled states
+ const pagination = controlledPagination ?? internalPagination
+ const setPagination = onPaginationChange ?? setInternalPagination
+
+ const rowSelection = controlledRowSelection ?? internalRowSelection
+ const setRowSelection = onRowSelectionChange ?? setInternalRowSelection
+
+ // Table Instance
+ const table = useReactTable({
+ data,
+ columns,
+ state: {
+ sorting,
+ columnFilters,
+ globalFilter,
+ pagination,
+ columnVisibility,
+ columnPinning,
+ columnOrder,
+ rowSelection,
+ },
+ manualPagination,
+ pageCount: manualPagination ? pageCount : undefined,
+ rowCount: manualPagination ? rowCount : undefined,
+ onSortingChange: setSorting,
+ onColumnFiltersChange: setColumnFilters,
+ onGlobalFilterChange: setGlobalFilter,
+ onPaginationChange: setPagination,
+ onColumnVisibilityChange: setColumnVisibility,
+ onColumnPinningChange: setColumnPinning,
+ onColumnOrderChange: setColumnOrder,
+ onRowSelectionChange: setRowSelection,
+ enableRowSelection,
+ enableMultiRowSelection,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getFilteredRowModel: getFilteredRowModel(),
+ getPaginationRowModel: enablePagination ? getPaginationRowModel() : undefined,
+ columnResizeMode: "onChange",
+ filterFns: {
+ fuzzy: fuzzyFilter,
+ },
+ globalFilterFn: fuzzyFilter,
+ meta,
+ getRowId,
+ })
+
+ // Expose table instance via ref
+ React.useImperativeHandle(ref, () => table, [table])
+
+ // DnD Sensors
+ const sensors = useSensors(
+ useSensor(PointerSensor, {
+ activationConstraint: {
+ distance: 8, // 8px movement required to start drag
+ },
+ }),
+ useSensor(KeyboardSensor)
+ )
+
+ // Handle Drag End
+ const handleDragEnd = (event: DragEndEvent) => {
+ const { active, over } = event
+ if (active && over && active.id !== over.id) {
+ setColumnOrder((items) => {
+ const oldIndex = items.indexOf(active.id as string)
+ const newIndex = items.indexOf(over.id as string)
+ return arrayMove(items, oldIndex, newIndex)
+ })
+ }
+ }
+
+ // Virtualization
+ const tableContainerRef = React.useRef<HTMLDivElement>(null)
+ const { rows } = table.getRowModel()
+
+ const rowVirtualizer = useVirtualizer({
+ count: rows.length,
+ getScrollElement: () => tableContainerRef.current,
+ estimateSize: () => 40, // Estimated row height
+ 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 Handler
+ const handleExport = async () => {
+ if (onExport) {
+ onExport(data)
+ return
+ }
+ const currentData = table.getFilteredRowModel().rows.map((row) => row.original)
+ await exportToExcel(currentData, columns, `export-${new Date().toISOString().slice(0,10)}.xlsx`)
+ }
+
+ return (
+ <div className={`flex flex-col gap-4 ${className || ""}`}>
+ <ClientTableToolbar
+ globalFilter={globalFilter}
+ setGlobalFilter={setGlobalFilter}
+ totalRows={manualPagination ? (rowCount ?? data.length) : data.length}
+ visibleRows={rows.length}
+ onExport={enableExport ? handleExport : undefined}
+ actions={
+ <>
+ {actions}
+ <ClientTableViewOptions table={table} />
+ </>
+ }
+ />
+
+ <div
+ ref={tableContainerRef}
+ className="relative border rounded-md overflow-auto"
+ style={{ height }}
+ >
+ <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-10 bg-muted">
+ {table.getHeaderGroups().map((headerGroup) => (
+ <tr key={headerGroup.id}>
+ <SortableContext
+ items={columnOrder}
+ strategy={horizontalListSortingStrategy}
+ >
+ {headerGroup.headers.map((header) => (
+ <ClientTableColumnHeader
+ key={header.id}
+ header={header}
+ enableReordering={true}
+ />
+ ))}
+ </SortableContext>
+ </tr>
+ ))}
+ </thead>
+ <tbody>
+ {paddingTop > 0 && (
+ <tr>
+ <td style={{ height: `${paddingTop}px` }} />
+ </tr>
+ )}
+ {virtualRows.map((virtualRow) => {
+ const row = rows[virtualRow.index]
+ return (
+ <tr
+ key={row.id}
+ className={cn(
+ "hover:bg-muted/50 border-b last:border-0",
+ getRowClassName ? getRowClassName(row.original, row.index) : ""
+ )}
+ style={{ height: `${virtualRow.size}px` }}
+ >
+ {row.getVisibleCells().map((cell) => {
+ // Handle pinned cells
+ const isPinned = cell.column.getIsPinned()
+ const style: React.CSSProperties = {
+ width: cell.column.getSize(),
+ }
+ if (isPinned === "left") {
+ style.position = "sticky"
+ style.left = `${cell.column.getStart("left")}px`
+ style.zIndex = 10
+ style.backgroundColor = "var(--background)" // Ensure opacity
+ } else if (isPinned === "right") {
+ style.position = "sticky"
+ style.right = `${cell.column.getAfter("right")}px`
+ style.zIndex = 10
+ style.backgroundColor = "var(--background)"
+ }
+
+ return (
+ <td
+ key={cell.id}
+ className="px-4 py-2 text-sm truncate border-b"
+ style={style}
+ >
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext()
+ )}
+ </td>
+ )
+ })}
+ </tr>
+ )
+ })}
+ {paddingBottom > 0 && (
+ <tr>
+ <td style={{ height: `${paddingBottom}px` }} />
+ </tr>
+ )}
+ </tbody>
+ </table>
+ </DndContext>
+ </div>
+
+ {enablePagination && (
+ <ClientDataTablePagination table={table} />
+ )}
+ </div>
+ )
+}
+
+export const ClientVirtualTable = React.memo(
+ React.forwardRef(ClientVirtualTableInner)
+) as <TData, TValue>(
+ props: ClientVirtualTableProps<TData, TValue> & { ref?: React.Ref<Table<TData>> }
+) => React.ReactElement