diff --git a/examples/ui/table.py b/examples/ui/table.py index 72568363019..716985582bd 100644 --- a/examples/ui/table.py +++ b/examples/ui/table.py @@ -32,6 +32,19 @@ def _(table): table.value return +@app.cell +def _(mo): + # Demonstrate a long table with a sticky header and a custom max height + long_rows = [{"row": i, "first_name": f"First {i}", "last_name": f"Last {i}"} for i in range(200)] + long_table = mo.ui.table( + long_rows, + pagination=False, + max_height=300, + ) + long_table + return (long_table,) + + if __name__ == "__main__": app.run() diff --git a/frontend/src/components/data-table/data-table.tsx b/frontend/src/components/data-table/data-table.tsx index 01038eaa168..5e7dd0740c9 100644 --- a/frontend/src/components/data-table/data-table.tsx +++ b/frontend/src/components/data-table/data-table.tsx @@ -47,6 +47,7 @@ import { getStableRowId } from "./utils"; interface DataTableProps extends Partial { wrapperClassName?: string; className?: string; + maxHeight?: number; columns: Array>; data: TData[]; // Sorting @@ -95,6 +96,7 @@ interface DataTableProps extends Partial { const DataTableInternal = ({ wrapperClassName, className, + maxHeight, columns, data, selection, @@ -250,6 +252,32 @@ const DataTableInternal = ({ const rowViewerPanelOpen = isPanelOpen?.("row-viewer") ?? false; + const tableRef = React.useRef(null); + + // Why use a ref to set max-height on the wrapper? + // - 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 table headers (th) will not stick. + // - We keep the scroll wrapper colocated with the base Table component, but + // derive the scroll boundary from maxHeight here to avoid coupling UI base + // components to data-table specifics or expanding their API surface. + // - Setting styles on the table's direct wrapper ensures the header sticks + // reliably across browsers without changing upstream components. + React.useEffect(() => { + if (!tableRef.current) return; + const wrapper = tableRef.current.parentElement as HTMLDivElement | null; + if (!wrapper) return; + if (maxHeight) { + wrapper.style.maxHeight = `${maxHeight}px`; + // Ensure wrapper scrolls + if (!wrapper.style.overflow) { + wrapper.style.overflow = "auto"; + } + } else { + wrapper.style.removeProperty("max-height"); + } + }, [maxHeight]); + return (
@@ -263,11 +291,11 @@ const DataTableInternal = ({ reloading={reloading} /> )} - +
{showLoadingBar && (
)} - {renderTableHeader(table)} + {renderTableHeader(table, Boolean(maxHeight))} ( table: Table, + isSticky?: boolean, ): JSX.Element | null { if (!table.getRowModel().rows?.length) { return null; @@ -57,7 +58,7 @@ export function renderTableHeader( }; return ( - + {renderHeaderGroup(table.getLeftHeaderGroups())} {renderHeaderGroup(table.getCenterHeaderGroups())} @@ -106,9 +107,9 @@ export const DataTableBody = ({ const s = renderUnknownValue({ value: v, nullAsEmptyString: true }); idToValue.set(c.column.id, s); } - return template.replace(variableRegex, (_substr, varName: string) => { + return template.replaceAll(variableRegex, (_substr, varName: string) => { const val = idToValue.get(varName); - return val !== undefined ? val : `{{${varName}}}`; + return val === undefined ? `{{${varName}}}` : val; }); } diff --git a/frontend/src/plugins/impl/DataTablePlugin.tsx b/frontend/src/plugins/impl/DataTablePlugin.tsx index b12002561ee..48f0ece339d 100644 --- a/frontend/src/plugins/impl/DataTablePlugin.tsx +++ b/frontend/src/plugins/impl/DataTablePlugin.tsx @@ -168,6 +168,7 @@ interface Data { totalRows: number | TooManyRows; pagination: boolean; pageSize: number; + maxHeight?: number; selection: DataTableSelection; showDownload: boolean; showFilters: boolean; @@ -253,6 +254,7 @@ export const DataTablePlugin = createPlugin("marimo-table") totalColumns: z.number(), maxColumns: z.union([z.number(), z.literal("all")]).default("all"), hasStableRowId: z.boolean().default(false), + maxHeight: z.number().optional(), cellStyles: z.record(z.record(z.object({}).passthrough())).optional(), hoverTemplate: z.string().optional(), // Whether to load the data lazily. @@ -649,6 +651,7 @@ export const LoadingDataTableComponent = memo( toggleDisplayHeader={toggleDisplayHeader} getRow={getRow} cellId={cellId} + maxHeight={props.maxHeight} /> ); @@ -715,6 +718,7 @@ const DataTableComponent = ({ preview_column, getRow, cellId, + maxHeight, }: DataTableProps & DataTableSearchProps & { data: unknown[]; @@ -894,6 +898,7 @@ const DataTableComponent = ({ data={data} columns={columns} className={className} + maxHeight={maxHeight} sorting={sorting} totalRows={totalRows} totalColumns={totalColumns} diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py index 1251b99f3a0..da8d6ff2ef6 100644 --- a/marimo/_plugins/ui/_impl/table.py +++ b/marimo/_plugins/ui/_impl/table.py @@ -335,6 +335,9 @@ def style_cell(_rowId, _columnName, value): hover_template (str, optional): A template for the hover text of the table. max_columns (int, optional): Maximum number of columns to display. Defaults to the configured default_table_max_columns (50 by default). Set to None to show all columns. + max_height (int, optional): Maximum height of the table body in pixels. When set, + the table becomes vertically scrollable and the header will be made sticky + in the UI to remain visible while scrolling. Defaults to None. label (str, optional): A descriptive name for the table. Defaults to "". """ @@ -441,6 +444,7 @@ def __init__( ] = None, style_cell: Optional[Callable[[str, str, Any], dict[str, Any]]] = None, hover_template: Optional[str] = None, + max_height: Optional[int] = None, # The _internal_* arguments are for overriding and unit tests # table should take the value unconditionally _internal_column_charts_row_limit: Optional[int] = None, @@ -671,6 +675,9 @@ def __init__( "hover-template": hover_template, "lazy": _internal_lazy, "preload": _internal_preload, + "max-height": int(max_height) + if max_height is not None + else None, }, on_change=on_change, functions=( diff --git a/tests/_plugins/ui/_impl/test_table.py b/tests/_plugins/ui/_impl/test_table.py index 7ff3d45812f..dcf6cbcfb13 100644 --- a/tests/_plugins/ui/_impl/test_table.py +++ b/tests/_plugins/ui/_impl/test_table.py @@ -1919,6 +1919,18 @@ def test_default_table_max_columns(): assert get_default_table_max_columns() == DEFAULT_MAX_COLUMNS +def test_table_max_height(): + table = ui.table( + [{"a": i} for i in range(100)], pagination=False, max_height=300 + ) + # Backend should expose optional UI hints when max_height is set + assert table._component_args["max-height"] == 300 + + table_no_height = ui.table([1, 2, 3]) + # Keys may be absent when not configured + assert table_no_height._component_args["max-height"] is None + + def test_calculate_top_k_rows(): table = ui.table({"A": [1, 3, 3, None, None]}) result = table._calculate_top_k_rows(