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
4 changes: 3 additions & 1 deletion examples/ui/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ def _(mo):
{"first_name": "Michael", "last_name": "Scott"},
{"first_name": "Jim", "last_name": "Halpert"},
{"first_name": "Pam", "last_name": "Beesly"},
]
],
# Show full name on hover for each row using column placeholders
hover_template="{{first_name}} {{last_name}}",
)
table
return (table,)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* Copyright 2024 Marimo. All rights reserved. */
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { render } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { TooltipProvider } from "@/components/ui/tooltip";
import { DataTable } from "../data-table";
Expand Down Expand Up @@ -57,4 +57,42 @@ describe("DataTable", () => {
// Verify the rowSelection prop is maintained
expect(commonProps.rowSelection).toEqual(initialRowSelection);
});

it("applies hoverTemplate to the row title using row values", () => {
interface RowData {
id: number;
first: string;
last: string;
}

const testData: RowData[] = [
{ id: 1, first: "Michael", last: "Scott" },
{ id: 2, first: "Jim", last: "Halpert" },
];

const columns: Array<ColumnDef<RowData>> = [
{ accessorKey: "first", header: "First" },
{ accessorKey: "last", header: "Last" },
];

render(
<TooltipProvider>
<DataTable
data={testData}
columns={columns}
selection={null}
totalRows={2}
totalColumns={2}
pagination={false}
hoverTemplate={"{{first}} {{last}}"}
/>
</TooltipProvider>,
);

// Grab all rows and assert title attribute computed from template
const rows = screen.getAllByRole("row");
// The first row is header; subsequent rows correspond to data
expect(rows[1]).toHaveAttribute("title", "Michael Scott");
expect(rows[2]).toHaveAttribute("title", "Jim Halpert");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/* Copyright 2025 Marimo. All rights reserved. */
"use no memo";

import type { InitialTableState, TableFeature } from "@tanstack/react-table";
import type { CellHoverTemplateTableState } from "./types";

export const CellHoverTemplateFeature: TableFeature = {
getInitialState: (state?: InitialTableState): CellHoverTemplateTableState => {
return {
...state,
cellHoverTemplate: null,
};
},
};
11 changes: 11 additions & 0 deletions frontend/src/components/data-table/cell-hover-template/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/* Copyright 2025 Marimo. All rights reserved. */
/* eslint-disable @typescript-eslint/no-empty-interface */

export interface CellHoverTemplateTableState {
cellHoverTemplate: string | null;
}

// Use declaration merging to add our new feature APIs
declare module "@tanstack/react-table" {
interface TableState extends CellHoverTemplateTableState {}
}
9 changes: 7 additions & 2 deletions frontend/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { Table } from "@/components/ui/table";
import type { GetRowIds } from "@/plugins/impl/DataTablePlugin";
import { cn } from "@/utils/cn";
import type { PanelType } from "../editor/chrome/panels/context-aware-panel/context-aware-panel";
import { CellHoverTemplateFeature } from "./cell-hover-template/feature";
import { CellSelectionFeature } from "./cell-selection/feature";
import type { CellSelectionState } from "./cell-selection/types";
import { CellStylingFeature } from "./cell-styling/feature";
Expand Down Expand Up @@ -63,6 +64,7 @@ interface DataTableProps<TData> extends Partial<DownloadActionProps> {
rowSelection?: RowSelectionState;
cellSelection?: CellSelectionState;
cellStyling?: CellStyleState | null;
hoverTemplate?: string | null;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onCellSelectionChange?: OnChangeFn<CellSelectionState>;
getRowIds?: GetRowIds;
Expand Down Expand Up @@ -103,6 +105,7 @@ const DataTableInternal = <TData,>({
rowSelection,
cellSelection,
cellStyling,
hoverTemplate,
paginationState,
setPaginationState,
downloadAs,
Expand Down Expand Up @@ -176,6 +179,7 @@ const DataTableInternal = <TData,>({
ColumnFormattingFeature,
CellSelectionFeature,
CellStylingFeature,
CellHoverTemplateFeature,
CopyColumnFeature,
FocusRowFeature,
],
Expand Down Expand Up @@ -233,10 +237,11 @@ const DataTableInternal = <TData,>({
? {}
: // No pagination, show all rows
{ pagination: { pageIndex: 0, pageSize: data.length } }),
rowSelection,
cellSelection,
rowSelection: rowSelection ?? {},
cellSelection: cellSelection ?? [],
cellStyling,
columnPinning: columnPinning,
cellHoverTemplate: hoverTemplate,
},
});

Expand Down
28 changes: 28 additions & 0 deletions frontend/src/components/data-table/renderers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,25 @@ export const DataTableBody = <TData,>({
handleCellsKeyDown,
} = useCellRangeSelection({ table });

function applyHoverTemplate(
template: string,
cells: Array<Cell<TData, unknown>>,
): string {
const variableRegex = /{{(\w+)}}/g;
// Map column id -> stringified value
const idToValue = new Map<string, string>();
for (const c of cells) {
const v = c.getValue();
// Prefer empty string for nulls to keep tooltip clean
const s = renderUnknownValue({ value: v, nullAsEmptyString: true });
idToValue.set(c.column.id, s);
}
return template.replace(variableRegex, (_substr, varName: string) => {
const val = idToValue.get(varName);
return val !== undefined ? val : `{{${varName}}}`;
});
}

const renderCells = (cells: Array<Cell<TData, unknown>>) => {
return cells.map((cell) => {
const { className, style: pinningstyle } = getPinningStyles(cell.column);
Expand All @@ -101,6 +120,7 @@ export const DataTableBody = <TData,>({
cell.getUserStyling?.() || {},
pinningstyle,
);

return (
<TableCell
tabIndex={0}
Expand Down Expand Up @@ -145,10 +165,18 @@ export const DataTableBody = <TData,>({
const isRowViewedInPanel =
rowViewerPanelOpen && viewedRowIdx === rowIndex;

// Compute hover title once per row using this row's cells (visible or hidden)
const hoverTemplate = table.getState().cellHoverTemplate || null;
const rowCells = row.getAllCells();
const rowTitle = hoverTemplate
? applyHoverTemplate(hoverTemplate, rowCells)
: undefined;

return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
title={rowTitle}
// These classes ensure that empty rows (nulls) still render
className={cn(
"border-t h-6",
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/plugins/impl/DataTablePlugin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export const DataTablePlugin = createPlugin<S>("marimo-table")
maxColumns: z.union([z.number(), z.literal("all")]).default("all"),
hasStableRowId: z.boolean().default(false),
cellStyles: z.record(z.record(z.object({}).passthrough())).optional(),
hoverTemplate: z.string().optional(),
// Whether to load the data lazily.
lazy: z.boolean().default(false),
// If lazy, this will preload the first page of data
Expand Down Expand Up @@ -385,6 +386,7 @@ interface DataTableProps<T> extends Data<T>, DataTableFunctions {
// Filters
enableFilters?: boolean;
cellStyles?: CellStyleState | null;
hoverTemplate?: string | null;
toggleDisplayHeader?: () => void;
host: HTMLElement;
cellId?: CellId | null;
Expand Down Expand Up @@ -707,6 +709,7 @@ const DataTableComponent = ({
totalColumns,
get_row_ids,
cellStyles,
hoverTemplate,
toggleDisplayHeader,
calculate_top_k_rows,
preview_column,
Expand Down Expand Up @@ -904,6 +907,7 @@ const DataTableComponent = ({
rowSelection={rowSelection}
cellSelection={cellSelection}
cellStyling={cellStyles}
hoverTemplate={hoverTemplate}
downloadAs={showDownload ? downloadAs : undefined}
enableSearch={enableSearch}
searchQuery={searchQuery}
Expand Down
3 changes: 3 additions & 0 deletions marimo/_plugins/ui/_impl/table.py
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,7 @@ def style_cell(_rowId, _columnName, value):
on_change (Callable[[Union[List[JSONType], Dict[str, List[JSONType]], IntoDataFrame, List[TableCell]]], None], optional):
Optional callback to run when this element's value changes.
style_cell (Callable[[str, str, Any], Dict[str, Any]], optional): A function that takes the row id, column name and value and returns a dictionary of CSS styles.
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.
label (str, optional): A descriptive name for the table. Defaults to "".
Expand Down Expand Up @@ -439,6 +440,7 @@ def __init__(
]
] = None,
style_cell: Optional[Callable[[str, str, Any], dict[str, Any]]] = None,
hover_template: Optional[str] = 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 @@ -666,6 +668,7 @@ def __init__(
"wrapped-columns": wrapped_columns,
"has-stable-row-id": self._has_stable_row_id,
"cell-styles": search_result_styles,
"hover-template": hover_template,
"lazy": _internal_lazy,
"preload": _internal_preload,
},
Expand Down
Loading