diff --git a/src/components/shared/data-table.tsx b/src/components/shared/data-table.tsx index 514b87d3..3d1cea80 100644 --- a/src/components/shared/data-table.tsx +++ b/src/components/shared/data-table.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useMemo, useRef, useState } from 'react'; import { flexRender, getCoreRowModel, @@ -10,6 +10,7 @@ import { type RowSelectionState, type VisibilityState, } from '@tanstack/react-table'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { ArrowDown, ArrowUp, ArrowUpDown, Loader2 } from 'lucide-react'; import { @@ -81,6 +82,29 @@ interface DataTableProps { * needing a preferences migration. */ columnVisibility?: VisibilityState; + /** + * Opt-in row virtualization. Only renders rows in the viewport (plus a + * small overscan), so a 5000-row client-export list stays at 60 fps. + * The virtualizer uses the surrounding scroll container — set + * `virtualHeightPx` (default 600) so we know the visible area at mount. + * + * Off by default because most CRM tables are server-paginated to 20-50 + * rows where virtualization is pure overhead. Pass `virtual` only for + * tables that legitimately hold hundreds-to-thousands of rows in memory. + * + * Constraints: + * - Pagination is incompatible with `virtual` (virtualize ALL rows or + * paginate — pick one). When both are passed, pagination wins and + * virtualization is silently disabled. + * - Mobile card view is unaffected; virtualization only applies to the + * desktop `` rendering at lg: and up. + */ + virtual?: boolean; + /** Container pixel height when `virtual` is on. Defaults to 600. */ + virtualHeightPx?: number; + /** Pixel height of an average row (used for virtualizer sizing). Defaults + * to 48, which matches the Tailwind h-12 cell height shadcn Table uses. */ + virtualRowHeightPx?: number; } export function DataTable({ @@ -101,6 +125,9 @@ export function DataTable({ cardRender, mobileGroupBy, columnVisibility, + virtual, + virtualHeightPx = 600, + virtualRowHeightPx = 48, }: DataTableProps) { const [internalSelection, setInternalSelection] = useState({}); const rowSelectionState = externalSelection ?? internalSelection; @@ -189,9 +216,28 @@ export function DataTable({ const rows = table.getRowModel().rows; + // Virtualization gate — pagination wins over virtual (you can't do both). + const virtualEnabled = Boolean(virtual) && !pagination; + const scrollContainerRef = useRef(null); + const virtualizer = useVirtualizer({ + count: virtualEnabled ? rows.length : 0, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => virtualRowHeightPx, + overscan: 8, + }); + const virtualRows = virtualEnabled ? virtualizer.getVirtualItems() : []; + const virtualPaddingTop = virtualRows[0]?.start ?? 0; + const virtualPaddingBottom = virtualEnabled + ? virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0) + : 0; + return (
-
+
{table.getHeaderGroups().map((headerGroup) => ( @@ -246,8 +292,41 @@ export function DataTable({ )} + ) : virtualEnabled ? ( + <> + {virtualPaddingTop > 0 ? ( + + + + ) : null} + {virtualRows.map((vRow) => { + const row = rows[vRow.index]!; + return ( + onRowClick?.(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + })} + {virtualPaddingBottom > 0 ? ( + + + + ) : null} + ) : ( - table.getRowModel().rows.map((row) => ( + rows.map((row) => (