"use client"; import { useVirtualizer } from "@tanstack/react-virtual"; import { useReducedMotion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Checkbox } from "@/components/motion/checkbox"; import { cn } from "@/lib/utils"; import { EditableCell } from "./editable-cell"; import { RowHandle } from "./row-handle"; import { SkeletonRows } from "./skeleton-rows"; import { TableHeader } from "./table-header"; import type { HeaderCellRefs, TableProps } from "./types"; import { useColumnReorder } from "./use-column-reorder"; import { useColumnResize } from "./use-column-resize"; import { useColumnSort } from "./use-column-sort"; import { useRowSelection } from "./use-row-selection"; import { CHECKBOX_WIDTH, alignText, readCell } from "./utils"; export type { SortDirection, SortState, TableColumn, TableProps, } from "./types"; export function Table({ data, columns, getRowId, selectable = false, selectedRowIds, defaultSelectedRowIds, onSelectionChange, sort: sortProp, defaultSort = null, onSortChange, resizable = false, minColumnWidth = 64, onColumnResize, reorderable = false, onColumnOrderChange, onCellEdit, onColumnRename, onInsertRow, onDeleteRow, onInsertColumn, onDeleteColumn, rowHeight = 48, height = 440, overscan = 10, onEndReached, loading = false, skeletonRows = 3, emptyState = "No data", className, }: TableProps) { const reduce = useReducedMotion(); const scrollRef = useRef(null); const thRefs: HeaderCellRefs = useRef< Record >({}); const rows = useMemo( () => data.map((row, index) => ({ row, id: getRowId ? getRowId(row, index) : String(index), })), [data, getRowId], ); const { orderedColumns, dragKey, dropIndex, startReorder, moveReorder, endReorder, } = useColumnReorder({ columns, thRefs, onColumnOrderChange }); const { sort, sortedRows, toggleSort } = useColumnSort({ rows, columns, sort: sortProp, defaultSort, onSortChange, }); const { widths, startResize, moveResize, endResize } = useColumnResize({ orderedColumns, thRefs, minColumnWidth, onColumnResize, }); const { selected, allSelected, someSelected, toggleAll, toggleRow } = useRowSelection({ sortedRows, selectedRowIds, defaultSelectedRowIds, onSelectionChange, }); const virtualizer = useVirtualizer({ count: sortedRows.length, getScrollElement: () => scrollRef.current, estimateSize: () => rowHeight, overscan, }); const virtualItems = virtualizer.getVirtualItems(); const totalSize = virtualizer.getTotalSize(); const paddingTop = virtualItems.length > 0 ? virtualItems[0].start : 0; const paddingBottom = virtualItems.length > 0 ? totalSize - virtualItems[virtualItems.length - 1].end : 0; const hasRowMenu = !!(onInsertRow || onDeleteRow); const hasColumnMenu = !!(onInsertColumn || onDeleteColumn); // Only shrink-wrap (w-max) once every column has an explicit resized width; // otherwise stay fill-width so a flexible column can't size to cell content. const sized = orderedColumns.length > 0 && orderedColumns.every((c) => widths[c.key] != null); // Infinite scroll: fire onEndReached once per near-bottom dwell, paused while // loading; the guard resets when the load completes. const endReachedRef = useRef(false); useEffect(() => { if (!loading) endReachedRef.current = false; }, [loading]); const handleScroll = useCallback(() => { const el = scrollRef.current; if (!el || !onEndReached || loading || endReachedRef.current) return; if (el.scrollHeight - el.scrollTop - el.clientHeight < rowHeight * 4) { endReachedRef.current = true; onEndReached(); } }, [onEndReached, loading, rowHeight]); const [activeColumn, setActiveColumn] = useState(null); // Small delay on leave so the pointer can cross the gap from the header cell // to the portal handle without the column deactivating. const deactivateTimer = useRef | null>(null); const activateColumn = useCallback((key: string) => { if (deactivateTimer.current) clearTimeout(deactivateTimer.current); deactivateTimer.current = null; setActiveColumn(key); }, []); const deactivateColumn = useCallback(() => { if (deactivateTimer.current) clearTimeout(deactivateTimer.current); deactivateTimer.current = setTimeout(() => setActiveColumn(null), 100); }, []); const rowRefs = useRef>({}); const [activeRow, setActiveRow] = useState<{ id: string; index: number } | null>( null, ); const rowTimer = useRef | null>(null); const activateRow = useCallback((id: string, index: number) => { if (rowTimer.current) clearTimeout(rowTimer.current); rowTimer.current = null; setActiveRow({ id, index }); }, []); const deactivateRow = useCallback(() => { if (rowTimer.current) clearTimeout(rowTimer.current); rowTimer.current = setTimeout(() => setActiveRow(null), 100); }, []); const activeRowEl = activeRow ? rowRefs.current[activeRow.id] : null; // Real columns + checkbox; the trailing spacer adds one more in colSpans. const leadColumns = columns.length + (selectable ? 1 : 0); return (
{selectable ? : null} {orderedColumns.map((column) => { const override = widths[column.key]; const width = override ? `${override}px` : column.width; return ( ); })} {/* Empty filler owns the leftover space — no gap, content unpinned. */} {sortedRows.length === 0 ? ( loading ? ( ) : ( ) ) : ( <> {paddingTop > 0 ? ( ) : null} {virtualItems.map((vItem) => { const entry = sortedRows[vItem.index]; const isSelected = selected.has(entry.id); return ( { rowRefs.current[entry.id] = el; }} data-selected={isSelected} style={{ height: rowHeight }} onPointerEnter={ hasRowMenu ? () => activateRow(entry.id, vItem.index) : undefined } onPointerLeave={hasRowMenu ? deactivateRow : undefined} className={cn( "border-border/60 border-b transition-colors", "data-[selected=true]:bg-primary/5", "hover:bg-muted/50", )} > {selectable ? ( ) : null} {orderedColumns.map((column) => ( ))} ); })} {paddingBottom > 0 ? ( ) : null} {loading ? ( ) : null} )}
{emptyState}
toggleRow(entry.id)} aria-label={`Select row ${vItem.index + 1}`} />
{!column.cell && column.editable ? ( onCellEdit?.(entry.id, column.key, next) } /> ) : ( readCell(entry.row, column) )}
{hasRowMenu && activeRow ? ( activateRow(activeRow.id, activeRow.index)} onLeave={deactivateRow} /> ) : null}
); }