diff --git a/examples/ui/table.py b/examples/ui/table.py index 51b483da2d5..72568363019 100644 --- a/examples/ui/table.py +++ b/examples/ui/table.py @@ -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,) diff --git a/frontend/src/components/data-table/__tests__/data-table.test.tsx b/frontend/src/components/data-table/__tests__/data-table.test.tsx index b5986f3d147..30a128c4e91 100644 --- a/frontend/src/components/data-table/__tests__/data-table.test.tsx +++ b/frontend/src/components/data-table/__tests__/data-table.test.tsx @@ -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"; @@ -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> = [ + { accessorKey: "first", header: "First" }, + { accessorKey: "last", header: "Last" }, + ]; + + render( + + + , + ); + + // 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"); + }); }); diff --git a/frontend/src/components/data-table/cell-hover-template/feature.ts b/frontend/src/components/data-table/cell-hover-template/feature.ts new file mode 100644 index 00000000000..0beace674d3 --- /dev/null +++ b/frontend/src/components/data-table/cell-hover-template/feature.ts @@ -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, + }; + }, +}; diff --git a/frontend/src/components/data-table/cell-hover-template/types.ts b/frontend/src/components/data-table/cell-hover-template/types.ts new file mode 100644 index 00000000000..35363f83792 --- /dev/null +++ b/frontend/src/components/data-table/cell-hover-template/types.ts @@ -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 {} +} diff --git a/frontend/src/components/data-table/data-table.tsx b/frontend/src/components/data-table/data-table.tsx index dc2c0e38a95..7927abb4d24 100644 --- a/frontend/src/components/data-table/data-table.tsx +++ b/frontend/src/components/data-table/data-table.tsx @@ -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"; @@ -63,6 +64,7 @@ interface DataTableProps extends Partial { rowSelection?: RowSelectionState; cellSelection?: CellSelectionState; cellStyling?: CellStyleState | null; + hoverTemplate?: string | null; onRowSelectionChange?: OnChangeFn; onCellSelectionChange?: OnChangeFn; getRowIds?: GetRowIds; @@ -103,6 +105,7 @@ const DataTableInternal = ({ rowSelection, cellSelection, cellStyling, + hoverTemplate, paginationState, setPaginationState, downloadAs, @@ -176,6 +179,7 @@ const DataTableInternal = ({ ColumnFormattingFeature, CellSelectionFeature, CellStylingFeature, + CellHoverTemplateFeature, CopyColumnFeature, FocusRowFeature, ], @@ -233,10 +237,11 @@ const DataTableInternal = ({ ? {} : // No pagination, show all rows { pagination: { pageIndex: 0, pageSize: data.length } }), - rowSelection, - cellSelection, + rowSelection: rowSelection ?? {}, + cellSelection: cellSelection ?? [], cellStyling, columnPinning: columnPinning, + cellHoverTemplate: hoverTemplate, }, }); diff --git a/frontend/src/components/data-table/renderers.tsx b/frontend/src/components/data-table/renderers.tsx index 61b387045e2..058e1d84ca7 100644 --- a/frontend/src/components/data-table/renderers.tsx +++ b/frontend/src/components/data-table/renderers.tsx @@ -93,6 +93,25 @@ export const DataTableBody = ({ handleCellsKeyDown, } = useCellRangeSelection({ table }); + function applyHoverTemplate( + template: string, + cells: Array>, + ): 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 = 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>) => { return cells.map((cell) => { const { className, style: pinningstyle } = getPinningStyles(cell.column); @@ -101,6 +120,7 @@ export const DataTableBody = ({ cell.getUserStyling?.() || {}, pinningstyle, ); + return ( ({ 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 ( ("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 @@ -385,6 +386,7 @@ interface DataTableProps extends Data, DataTableFunctions { // Filters enableFilters?: boolean; cellStyles?: CellStyleState | null; + hoverTemplate?: string | null; toggleDisplayHeader?: () => void; host: HTMLElement; cellId?: CellId | null; @@ -707,6 +709,7 @@ const DataTableComponent = ({ totalColumns, get_row_ids, cellStyles, + hoverTemplate, toggleDisplayHeader, calculate_top_k_rows, preview_column, @@ -904,6 +907,7 @@ const DataTableComponent = ({ rowSelection={rowSelection} cellSelection={cellSelection} cellStyling={cellStyles} + hoverTemplate={hoverTemplate} downloadAs={showDownload ? downloadAs : undefined} enableSearch={enableSearch} searchQuery={searchQuery} diff --git a/marimo/_plugins/ui/_impl/table.py b/marimo/_plugins/ui/_impl/table.py index 1bf062a935c..1251b99f3a0 100644 --- a/marimo/_plugins/ui/_impl/table.py +++ b/marimo/_plugins/ui/_impl/table.py @@ -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 "". @@ -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, @@ -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, },