Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions examples/ui/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
32 changes: 30 additions & 2 deletions frontend/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { getStableRowId } from "./utils";
interface DataTableProps<TData> extends Partial<DownloadActionProps> {
wrapperClassName?: string;
className?: string;
maxHeight?: number;
columns: Array<ColumnDef<TData>>;
data: TData[];
// Sorting
Expand Down Expand Up @@ -95,6 +96,7 @@ interface DataTableProps<TData> extends Partial<DownloadActionProps> {
const DataTableInternal = <TData,>({
wrapperClassName,
className,
maxHeight,
columns,
data,
selection,
Expand Down Expand Up @@ -250,6 +252,32 @@ const DataTableInternal = <TData,>({

const rowViewerPanelOpen = isPanelOpen?.("row-viewer") ?? false;

const tableRef = React.useRef<HTMLTableElement | null>(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 (
<div className={cn(wrapperClassName, "flex flex-col space-y-1")}>
<FilterPills filters={filters} table={table} />
Expand All @@ -263,11 +291,11 @@ const DataTableInternal = <TData,>({
reloading={reloading}
/>
)}
<Table className="relative">
<Table className="relative" ref={tableRef}>
{showLoadingBar && (
<div className="absolute top-0 left-0 h-[3px] w-1/2 bg-primary animate-slide" />
)}
{renderTableHeader(table)}
{renderTableHeader(table, Boolean(maxHeight))}
<CellSelectionProvider>
<DataTableBody
table={table}
Expand Down
7 changes: 4 additions & 3 deletions frontend/src/components/data-table/renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { useScrollIntoViewOnFocus } from "./range-focus/use-scroll-into-view";

export function renderTableHeader<TData>(
table: Table<TData>,
isSticky?: boolean,
): JSX.Element | null {
if (!table.getRowModel().rows?.length) {
return null;
Expand Down Expand Up @@ -57,7 +58,7 @@ export function renderTableHeader<TData>(
};

return (
<TableHeader>
<TableHeader className={cn(isSticky && "sticky top-0 z-10")}>
<TableRow>
{renderHeaderGroup(table.getLeftHeaderGroups())}
{renderHeaderGroup(table.getCenterHeaderGroups())}
Expand Down Expand Up @@ -106,9 +107,9 @@ export const DataTableBody = <TData,>({
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;
});
}

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/plugins/impl/DataTablePlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ interface Data<T> {
totalRows: number | TooManyRows;
pagination: boolean;
pageSize: number;
maxHeight?: number;
selection: DataTableSelection;
showDownload: boolean;
showFilters: boolean;
Expand Down Expand Up @@ -253,6 +254,7 @@ export const DataTablePlugin = createPlugin<S>("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.
Expand Down Expand Up @@ -649,6 +651,7 @@ export const LoadingDataTableComponent = memo(
toggleDisplayHeader={toggleDisplayHeader}
getRow={getRow}
cellId={cellId}
maxHeight={props.maxHeight}
/>
);

Expand Down Expand Up @@ -715,6 +718,7 @@ const DataTableComponent = ({
preview_column,
getRow,
cellId,
maxHeight,
}: DataTableProps<unknown> &
DataTableSearchProps & {
data: unknown[];
Expand Down Expand Up @@ -894,6 +898,7 @@ const DataTableComponent = ({
data={data}
columns={columns}
className={className}
maxHeight={maxHeight}
sorting={sorting}
totalRows={totalRows}
totalColumns={totalColumns}
Expand Down
7 changes: 7 additions & 0 deletions marimo/_plugins/ui/_impl/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "".
"""

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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=(
Expand Down
12 changes: 12 additions & 0 deletions tests/_plugins/ui/_impl/test_table.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading