/* Copyright 2026 Marimo. All rights reserved. */ "use no memo"; import { type Cell, type Column, type ColumnDef, flexRender, type HeaderGroup, type Row, type Table, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { type JSX, useLayoutEffect, useRef, useState } from "react"; import useEvent from "react-use-event-hook"; import { TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { cn } from "@/utils/cn"; import { getCellDomProps } from "./cell-utils"; import { COLUMN_WRAPPING_STYLES } from "./column-wrapping/feature"; import { DataTableContextMenu } from "./context-menu"; import { CellRangeSelectionIndicator } from "./range-focus/cell-selection-indicator"; import { useCellRangeSelection } from "./range-focus/use-cell-range-selection"; import { useScrollIntoViewOnFocus } from "./range-focus/use-scroll-into-view"; import { AUTO_WIDTH_MAX_COLUMNS, TABLE_ROW_HEIGHT_PX } from "./types"; import { stringifyUnknownValue } from "./utils"; export function renderTableHeader( table: Table, isSticky?: boolean, ): JSX.Element | null { if (!table.getRowModel().rows?.length) { return null; } const renderHeaderGroup = (headerGroups: HeaderGroup[]) => { return headerGroups.map((headerGroup) => headerGroup.headers.map((header) => { const { className, style } = getPinningStyles(header.column); return ( { columnSizingHandler({ table, column: header.column, thead }); }} > {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} ); }), ); }; return ( {renderHeaderGroup(table.getLeftHeaderGroups())} {renderHeaderGroup(table.getCenterHeaderGroups())} {renderHeaderGroup(table.getRightHeaderGroups())} {table.getAllColumns().length <= AUTO_WIDTH_MAX_COLUMNS && ( )} ); } interface DataTableBodyProps { table: Table; columns: ColumnDef[]; rowViewerPanelOpen: boolean; getRowIndex?: (row: TData, idx: number) => number; viewedRowIdx?: number; virtualize?: boolean; } export const DataTableBody = ({ table, columns, rowViewerPanelOpen, getRowIndex, viewedRowIdx, virtualize = false, }: DataTableBodyProps) => { const rows = table.getRowModel().rows; // Find the scroll container (tbody -> table -> overflow-auto wrapper div). // Using useState so that when the element becomes available after mount, // useVirtualizer re-observes the correct element. const [scrollElement, setScrollElement] = useState(null); const tableRef = useRef(null); useLayoutEffect(() => { // tbody.parentElement = table, table.parentElement = overflow wrapper setScrollElement(tableRef.current?.parentElement?.parentElement ?? null); }, []); // Always call useVirtualizer (rules of hooks); count=0 when not virtualizing const virtualizer = useVirtualizer({ count: virtualize ? rows.length : 0, getScrollElement: () => scrollElement, estimateSize: () => TABLE_ROW_HEIGHT_PX, overscan: 10, }); // Automatically scroll focused cells into view. // In virtual mode, off-screen cells won't be in the DOM so this silently no-ops for them. useScrollIntoViewOnFocus(tableRef); const { handleCellMouseDown, handleCellMouseUp, handleCellMouseOver, handleCellsKeyDown, handleCopy: handleCopyAllCells, } = useCellRangeSelection({ table }); const contextMenuCell = useRef | null>(null); const handleContextMenu = useEvent((cell: Cell) => { contextMenuCell.current = cell; }); function applyHoverTemplate( template: string, cells: Cell[], ): string { const variableRegex = /{{(\w+)}}/g; // Map column id -> stringified value const idToValue = new Map(); for (const c of cells) { const v = c.getValue(); // Prefer empty string for nulls to keep tooltip clean const s = stringifyUnknownValue({ value: v, nullAsEmptyString: true }); idToValue.set(c.column.id, s); } return template.replaceAll(variableRegex, (_substr, varName: string) => { const val = idToValue.get(varName); return val === undefined ? `{{${varName}}}` : val; }); } const renderCells = (cells: Cell[]) => { return cells.map((cell) => { const { className, style: pinningstyle } = getPinningStyles(cell.column); const style = Object.assign( {}, cell.getUserStyling?.() || {}, pinningstyle, ); const title = cell.getHoverTitle?.() ?? undefined; return ( handleCellMouseDown(e, cell)} onMouseUp={handleCellMouseUp} onMouseOver={(e) => handleCellMouseOver(e, cell)} onContextMenu={() => handleContextMenu(cell)} >
{flexRender(cell.column.columnDef.cell, cell.getContext())}
); }); }; const handleRowClick = (row: Row) => { if (rowViewerPanelOpen) { const rowIndex = getRowIndex?.(row.original, row.index) ?? row.index; row.focusRow?.(rowIndex); } }; const hoverTemplate = table.getState().cellHoverTemplate || null; const renderRow = (row: Row) => { // Only find the row index if the row viewer panel is open const rowIndex = rowViewerPanelOpen ? (getRowIndex?.(row.original, row.index) ?? row.index) : undefined; const isRowViewedInPanel = rowViewerPanelOpen && viewedRowIdx === rowIndex; // Compute hover title once per row using all visible cells let rowTitle: string | undefined; if (hoverTemplate) { const visibleCells = row.getVisibleCells?.() ?? [ ...row.getLeftVisibleCells(), ...row.getCenterVisibleCells(), ...row.getRightVisibleCells(), ]; rowTitle = applyHoverTemplate(hoverTemplate, visibleCells); } return ( handleRowClick(row)} > {renderCells(row.getLeftVisibleCells())} {renderCells(row.getCenterVisibleCells())} {renderCells(row.getRightVisibleCells())} {columns.length <= AUTO_WIDTH_MAX_COLUMNS && ( )} ); }; const hasFillerColumn = columns.length <= AUTO_WIDTH_MAX_COLUMNS; const totalColSpan = columns.length + (hasFillerColumn ? 1 : 0); const renderRows = () => { if (rows.length === 0) { return ( No results. ); } if (virtualize) { const virtualItems = virtualizer.getVirtualItems(); const totalSize = virtualizer.getTotalSize(); return ( <> {virtualItems[0]?.start > 0 && ( )} {virtualItems.map((vItem) => renderRow(rows[vItem.index]))} {virtualItems.length > 0 && ( )} ); } return rows.map((row) => renderRow(row)); }; const tableBody = ( {renderRows()} ); return ( ); }; function getPinningStyles( column: Column, ): React.HTMLAttributes { const isPinned = column.getIsPinned(); const isLastLeftPinnedColumn = isPinned === "left" && column.getIsLastColumn("left"); const isFirstRightPinnedColumn = isPinned === "right" && column.getIsFirstColumn("right"); return { className: cn(isPinned && "bg-inherit", "shadow-r z-10"), style: { boxShadow: isLastLeftPinnedColumn && column.id !== "__select__" ? "-4px 0 4px -4px var(--slate-8) inset" : isFirstRightPinnedColumn ? "4px 0 4px -4px var(--slate-8) inset" : undefined, left: isPinned === "left" ? `${column.getStart("left")}px` : undefined, right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined, opacity: 1, position: isPinned ? "sticky" : "relative", zIndex: isPinned ? 1 : 0, width: column.getSize(), }, }; } // Update column sizes in table state for column pinning offsets // https://github.com/TanStack/table/discussions/3947#discussioncomment-9564867 function columnSizingHandler({ table, column, thead, }: { table: Table; column: Column; thead: HTMLTableCellElement | null; }): void { if (!thead) { return; } // Round to avoid infinite re-render loops: the browser's table layout // algorithm may render a at a slightly different width than the // CSS `width` we set via column.getSize(), so a strict float === float // comparison never stabilizes. Rounding to integers ensures convergence // after at most one cycle. const measuredWidth = Math.round(thead.getBoundingClientRect().width); if (table.getState().columnSizing[column.id] === measuredWidth) { return; } table.setColumnSizing((prevSizes) => ({ ...prevSizes, [column.id]: measuredWidth, })); }