/* Copyright 2026 Marimo. All rights reserved. */ import { useEffect, useLayoutEffect, useRef } from "react"; import { DEFAULT_VIRTUAL_ROWS, TABLE_HEADER_HEIGHT_PX, TABLE_ROW_HEIGHT_PX, } from "../types"; /** * Manages the scroll container's max-height for the data table. * * Why set max-height on the table's direct wrapper via a ref? * - `position: sticky` only works when the sticky element's nearest scrollable * ancestor is its immediate container. If max-height/overflow are applied on * a grandparent, sticky `` elements will not stick. * - The UI component wraps
in a div with overflow-auto. We * derive the scroll boundary from this wrapper (tableRef.parentElement) to * keep sticky headers working without coupling base UI components to * data-table specifics or expanding their API surface. * * 3 scenarios: * - maxHeight applied directly. This always takes preference * - Virtualize without maxHeight: observed via ResizeObserver on * so the container reacts to header size changes (charts loading, toggles). * - No maxHeight and no virtualization: render everything * in practice virtualization kicks in after 100 rows with pagination disabled */ export function useScrollContainerHeight({ maxHeight, virtualize, }: { maxHeight?: number; virtualize: boolean; }) { const tableRef = useRef(null); // Handle explicit maxHeight and non-virtualize cases synchronously // before paint to avoid flickering. useLayoutEffect(() => { if (!tableRef.current) { return; } const wrapper = tableRef.current.parentElement as HTMLDivElement | null; if (!wrapper) { return; } if (maxHeight) { wrapper.style.maxHeight = `${maxHeight}px`; if (!wrapper.style.overflow) { wrapper.style.overflow = "auto"; } } else if (!virtualize) { wrapper.style.removeProperty("max-height"); } // When virtualizing without an explicit maxHeight, the ResizeObserver // below handles setting maxHeight reactively based on actual header size. }, [maxHeight, virtualize]); // When virtualizing without an explicit maxHeight, observe the for // size changes (column summaries, charts loading async, header toggle) and // recompute the scroll container height accordingly. useEffect(() => { if (!virtualize || maxHeight) { return; } const table = tableRef.current; if (!table) { return; } const wrapper = table.parentElement as HTMLDivElement | null; const thead = table.querySelector("thead"); if (!wrapper || !thead) { return; } const updateMaxHeight = () => { const headerHeight = thead.getBoundingClientRect().height || TABLE_HEADER_HEIGHT_PX; // Skip virtual spacer rows — they have arbitrary heights for scroll offset. const firstDataRow = table.querySelector( "tbody tr:not([data-virtual-spacer])", ); const rowHeight = firstDataRow?.getBoundingClientRect().height || TABLE_ROW_HEIGHT_PX; wrapper.style.maxHeight = `${DEFAULT_VIRTUAL_ROWS * rowHeight + headerHeight}px`; }; // Set initial height updateMaxHeight(); const observer = new ResizeObserver(updateMaxHeight); observer.observe(thead); return () => observer.disconnect(); }, [virtualize, maxHeight]); return tableRef; }