diff --git a/examples/ui/table.py b/examples/ui/table.py index 72568363019..60659146f83 100644 --- a/examples/ui/table.py +++ b/examples/ui/table.py @@ -1,6 +1,6 @@ import marimo -__generated_with = "0.15.5" +__generated_with = "0.16.0" app = marimo.App() @@ -22,6 +22,11 @@ def _(mo): ], # Show full name on hover for each row using column placeholders hover_template="{{first_name}} {{last_name}}", + # Add header tooltip for column headers (shown via info icon + title) + header_tooltip={ + "first_name": "Employee's first name", + "last_name": "Employee's 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 30a128c4e91..894cac97516 100644 --- a/frontend/src/components/data-table/__tests__/data-table.test.tsx +++ b/frontend/src/components/data-table/__tests__/data-table.test.tsx @@ -95,4 +95,42 @@ describe("DataTable", () => { expect(rows[1]).toHaveAttribute("title", "Michael Scott"); expect(rows[2]).toHaveAttribute("title", "Jim Halpert"); }); + + it("applies headerHoverText mapping to column header titles", () => { + 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( + + + , + ); + + // Header row is role="row" as well; get all headers by role="columnheader" + const headers = screen.getAllByRole("columnheader"); + // Order should correspond to columns array + expect(headers[0]).toHaveAttribute("title", "Given name"); + expect(headers[1]).toHaveAttribute("title", "Family name"); + }); }); diff --git a/frontend/src/components/data-table/column-header.tsx b/frontend/src/components/data-table/column-header.tsx index d95072652aa..da159299fad 100644 --- a/frontend/src/components/data-table/column-header.tsx +++ b/frontend/src/components/data-table/column-header.tsx @@ -3,7 +3,7 @@ import type { Column } from "@tanstack/react-table"; import { capitalize } from "lodash-es"; -import { FilterIcon, MinusIcon, TextIcon, XIcon } from "lucide-react"; +import { FilterIcon, InfoIcon, MinusIcon, TextIcon, XIcon } from "lucide-react"; import { useMemo, useRef, useState } from "react"; import { useLocale } from "react-aria"; import { @@ -67,12 +67,14 @@ interface DataTableColumnHeaderProps extends React.HTMLAttributes { column: Column; header: React.ReactNode; + headerTitle?: string; calculateTopKRows?: CalculateTopKRows; } export const DataTableColumnHeader = ({ column, header, + headerTitle, className, calculateTopKRows, }: DataTableColumnHeaderProps) => { @@ -86,7 +88,16 @@ export const DataTableColumnHeader = ({ // No sorting or filtering if (!column.getCanSort() && !column.getCanFilter()) { - return
{header}
; + return ( +
+ + {header} + {headerTitle && ( + + )} + +
+ ); } const hasFilter = column.getFilterValue() !== undefined; @@ -102,8 +113,14 @@ export const DataTableColumnHeader = ({ className, )} data-testid="data-table-sort-button" + title={headerTitle} > - {header} + + {header} + {headerTitle && ( + + )} + ({ chartSpecModel, textJustifyColumns, wrappedColumns, + headerTooltip, showDataTypes, calculateTopKRows, }: { @@ -112,6 +113,7 @@ export function generateColumns({ chartSpecModel?: ColumnChartSpecModel; textJustifyColumns?: Record; wrappedColumns?: string[]; + headerTooltip?: Record; showDataTypes?: boolean; calculateTopKRows?: CalculateTopKRows; }): Array> { @@ -165,6 +167,7 @@ export function generateColumns({ header: ({ column }) => { const stats = chartSpecModel?.getColumnStats(key); const dtype = column.columnDef.meta?.dtype; + const headerTitle = headerTooltip?.[key]; const dtypeHeader = showDataTypes && dtype ? (
@@ -188,6 +191,7 @@ export function generateColumns({ ); diff --git a/frontend/src/components/data-table/data-table.tsx b/frontend/src/components/data-table/data-table.tsx index 01038eaa168..252d3ae702a 100644 --- a/frontend/src/components/data-table/data-table.tsx +++ b/frontend/src/components/data-table/data-table.tsx @@ -66,6 +66,7 @@ interface DataTableProps extends Partial { cellSelection?: CellSelectionState; cellStyling?: CellStyleState | null; hoverTemplate?: string | null; + headerTooltip?: Record | undefined; onRowSelectionChange?: OnChangeFn; onCellSelectionChange?: OnChangeFn; getRowIds?: GetRowIds; @@ -107,6 +108,7 @@ const DataTableInternal = ({ cellSelection, cellStyling, hoverTemplate, + headerTooltip, paginationState, setPaginationState, downloadAs, @@ -267,7 +269,7 @@ const DataTableInternal = ({ {showLoadingBar && (
)} - {renderTableHeader(table)} + {renderTableHeader(table, headerTooltip)} ( table: Table, + headerTooltip?: Record, ): JSX.Element | null { if (!table.getRowModel().rows?.length) { return null; @@ -35,6 +37,12 @@ export function renderTableHeader( return headerGroups.map((headerGroup) => headerGroup.headers.map((header) => { const { className, style } = getPinningStyles(header.column); + const accessorKey: string = ( + header.column.columnDef as AccessorKeyColumnDefBase + )?.accessorKey as string; + const lookupKey: string = accessorKey ?? header.column.id; + const headerHoverTitle: string | undefined = + headerTooltip?.[lookupKey] || undefined; return ( ( className, )} style={style} + title={headerHoverTitle} ref={(thead) => { columnSizingHandler(thead, table, header.column); }} > - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} + {header.isPlaceholder ? null : ( +
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} +
+ )}
); }), @@ -106,9 +120,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; }); } @@ -165,11 +179,15 @@ export const DataTableBody = ({ const isRowViewedInPanel = rowViewerPanelOpen && viewedRowIdx === rowIndex; - // Compute hover title once per row using this row's cells (visible or hidden) + // Compute hover title once per row using all visible cells const hoverTemplate = table.getState().cellHoverTemplate || null; - const rowCells = row.getAllCells(); + const visibleCells = row.getVisibleCells?.() ?? [ + ...row.getLeftVisibleCells(), + ...row.getCenterVisibleCells(), + ...row.getRightVisibleCells(), + ]; const rowTitle = hoverTemplate - ? applyHoverTemplate(hoverTemplate, rowCells) + ? applyHoverTemplate(hoverTemplate, visibleCells) : undefined; return ( diff --git a/frontend/src/plugins/impl/DataTablePlugin.tsx b/frontend/src/plugins/impl/DataTablePlugin.tsx index b12002561ee..99c0a5ac0dd 100644 --- a/frontend/src/plugins/impl/DataTablePlugin.tsx +++ b/frontend/src/plugins/impl/DataTablePlugin.tsx @@ -182,6 +182,7 @@ interface Data { freezeColumnsRight?: string[]; textJustifyColumns?: Record; wrappedColumns?: string[]; + headerTooltip?: Record; totalColumns: number; maxColumns: number | "all"; hasStableRowId: boolean; @@ -249,6 +250,7 @@ export const DataTablePlugin = createPlugin("marimo-table") .record(z.enum(["left", "center", "right"])) .optional(), wrappedColumns: z.array(z.string()).optional(), + headerTooltip: z.record(z.string()).optional(), fieldTypes: columnToFieldTypesSchema.nullish(), totalColumns: z.number(), maxColumns: z.union([z.number(), z.literal("all")]).default("all"), @@ -346,6 +348,7 @@ export const DataTablePlugin = createPlugin("marimo-table") data={props.data.data} value={props.value} setValue={props.setValue} + headerInfo={props.data.headerTooltip} /> @@ -387,6 +390,7 @@ interface DataTableProps extends Data, DataTableFunctions { enableFilters?: boolean; cellStyles?: CellStyleState | null; hoverTemplate?: string | null; + headerInfo?: Record | undefined; toggleDisplayHeader?: () => void; host: HTMLElement; cellId?: CellId | null; @@ -706,6 +710,7 @@ const DataTableComponent = ({ freezeColumnsRight, textJustifyColumns, wrappedColumns, + headerTooltip, totalColumns, get_row_ids, cellStyles, @@ -779,6 +784,7 @@ const DataTableComponent = ({ fieldTypes: memoizedClampedFieldTypes, textJustifyColumns: memoizedTextJustifyColumns, wrappedColumns: memoizedWrappedColumns, + headerTooltip: headerTooltip, // Only show data types if they are explicitly set showDataTypes: showDataTypes, calculateTopKRows: calculate_top_k_rows, @@ -791,6 +797,7 @@ const DataTableComponent = ({ memoizedClampedFieldTypes, memoizedTextJustifyColumns, memoizedWrappedColumns, + headerTooltip, calculate_top_k_rows, ], ); @@ -908,6 +915,7 @@ const DataTableComponent = ({ cellSelection={cellSelection} cellStyling={cellStyles} hoverTemplate={hoverTemplate} + headerTooltip={headerTooltip} 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 1251b99f3a0..2976e248c83 100644 --- a/marimo/_plugins/ui/_impl/table.py +++ b/marimo/_plugins/ui/_impl/table.py @@ -328,6 +328,7 @@ def style_cell(_rowId, _columnName, value): text_justify_columns (Dict[str, Literal["left", "center", "right"]], optional): Dictionary of column names to text justification options: left, center, right. wrapped_columns (List[str], optional): List of column names to wrap. + header_tooltip (Dict[str, str], optional): Mapping from column names to tooltip text on the column header. label (str, optional): Markdown label for the element. Defaults to "". 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. @@ -422,6 +423,7 @@ def __init__( dict[str, Literal["left", "center", "right"]] ] = None, wrapped_columns: Optional[list[str]] = None, + header_tooltip: Optional[dict[str, str]] = None, show_download: bool = True, max_columns: MaxColumnsType = MAX_COLUMNS_NOT_PROVIDED, *, @@ -634,6 +636,7 @@ def __init__( _validate_column_formatting( text_justify_columns, wrapped_columns, column_names_set ) + _validate_header_tooltip(header_tooltip, column_names_set) field_types = self._manager.get_field_types() @@ -666,6 +669,7 @@ def __init__( "freeze-columns-right": freeze_columns_right, "text-justify-columns": text_justify_columns, "wrapped-columns": wrapped_columns, + "header-tooltip": header_tooltip, "has-stable-row-id": self._has_stable_row_id, "cell-styles": search_result_styles, "hover-template": hover_template, @@ -1413,3 +1417,19 @@ def _validate_column_formatting( raise ValueError( f"Column '{next(iter(invalid))}' not found in table." ) + + +def _validate_header_tooltip( + header_tooltip: Optional[dict[str, str]], + column_names_set: set[str], +) -> None: + """Validate header tooltip mapping. + + Ensures all specified columns exist in the table. + """ + if header_tooltip: + invalid = set(header_tooltip.keys()) - column_names_set + if invalid: + raise ValueError( + f"Column '{next(iter(invalid))}' not found in table." + ) diff --git a/tests/_plugins/ui/_impl/tables/test_default_table.py b/tests/_plugins/ui/_impl/tables/test_default_table.py index 6282ced3c84..736c1104d4d 100644 --- a/tests/_plugins/ui/_impl/tables/test_default_table.py +++ b/tests/_plugins/ui/_impl/tables/test_default_table.py @@ -11,6 +11,7 @@ from marimo._dependencies.dependencies import DependencyManager from marimo._output.hypertext import Html +from marimo._plugins.ui._impl.table import _validate_header_tooltip from marimo._plugins.ui._impl.tables.default_table import DefaultTableManager from marimo._plugins.ui._impl.tables.table_manager import ( TableCell, @@ -1010,3 +1011,16 @@ def test_to_json(self) -> None: self.manager.to_json_str() == '[{"inf":"Infinity","nan":"NaN","timedelta":"1 day, 2:03:00","path":"test.txt","complex":"(1+2j)","bytes":"hello","memoryview":"hello","range":[0,1,2,3,4,5,6,7,8,9],"html":{"mimetype":"text/html","data":"

Hello World

"}}]' ) + + +def test_validate_header_tooltip_valid() -> None: + columns = {"name", "age", "birth_year"} + mapping = {"name": "Name of person", "age": "Age in years"} + _validate_header_tooltip(mapping, columns) + + +def test_validate_header_tooltip_invalid() -> None: + columns = {"name", "age", "birth_year"} + mapping = {"does_not_exist": "oops"} + with pytest.raises(ValueError): + _validate_header_tooltip(mapping, columns)