/* Copyright 2026 Marimo. All rights reserved. */ import type { Cell, RowData, Table } from "@tanstack/react-table"; import { type ReactNode, useEffect, useId, useLayoutEffect, useRef, useState, } from "react"; import useEvent from "react-use-event-hook"; import { computeCellTooltipContent } from "./content"; // Matches the default TooltipProvider delay (MarimoApp.tsx) for visual parity // with the rest of the app's tooltips. const TOOLTIP_DELAY_MS = 400; export interface HoverTooltipState { rect: { top: number; left: number; width: number; height: number }; content: ReactNode; } export function useTableHoverTooltip({ table, }: { table: Table; }) { const hoverTemplate = table.getState().cellHoverTemplate || null; const [tooltipState, setTooltipState] = useState( null, ); const timer = useRef(null); // Stable id linking the focused/hovered cell to the tooltip content for // assistive tech (the radix trigger is an aria-hidden phantom anchor). const tooltipContentId = useId(); const anchorCell = useRef(null); // Focus fires for pointer interactions too; track pointer state so // click/drag-select focus doesn't show a tooltip (keyboard focus still does). const pointerDown = useRef(false); const clearTimer = () => { if (timer.current != null) { clearTimeout(timer.current); timer.current = null; } }; const hideTooltip = useEvent(() => { clearTimer(); setTooltipState(null); }); const showFor = (target: HTMLElement, content: ReactNode) => { anchorCell.current = target; const r = target.getBoundingClientRect(); setTooltipState({ rect: { top: r.top, left: r.left, width: r.width, height: r.height }, content, }); }; // Point the real cell at the tooltip content while it is shown. Done in a // layout effect (after commit) so React's re-render from `setTooltipState` // can't clobber an imperatively set attribute; cleanup unlinks the previous // cell. useLayoutEffect(() => { if (!tooltipState) { return; } const cell = anchorCell.current; cell?.setAttribute("aria-describedby", tooltipContentId); return () => cell?.removeAttribute("aria-describedby"); }, [tooltipState, tooltipContentId]); useEffect(() => { const onDown = () => { pointerDown.current = true; }; const onUp = () => { pointerDown.current = false; }; window.addEventListener("mousedown", onDown, { capture: true }); window.addEventListener("mouseup", onUp, { capture: true }); return () => { window.removeEventListener("mousedown", onDown, { capture: true }); window.removeEventListener("mouseup", onUp, { capture: true }); }; }, []); const handleCellMouseOver = useEvent( (e: React.MouseEvent, cell: Cell) => { // Suppress while a mouse button is held (range-select drag). if (e.buttons !== 0) { return; } const target = e.currentTarget as HTMLElement; const content = computeCellTooltipContent(cell, hoverTemplate); if (content == null || content === "") { hideTooltip(); return; } clearTimer(); timer.current = window.setTimeout( () => showFor(target, content), TOOLTIP_DELAY_MS, ); }, ); const handleCellMouseLeave = useEvent(() => hideTooltip()); // Keyboard parity: cells are tabIndex=0, native `title` showed on focus too. const handleCellFocus = useEvent( (e: React.FocusEvent, cell: Cell) => { // Cancel any pending hover-show so a stale timer can't overwrite the // focus-triggered tooltip after the delay. clearTimer(); // Focus also fires for click/drag-select; only keyboard focus (no pointer // held) should show the tooltip, mirroring the hover drag suppression. if (pointerDown.current) { return; } const content = computeCellTooltipContent(cell, hoverTemplate); if (content == null || content === "") { return; } showFor(e.currentTarget as HTMLElement, content); }, ); const handleCellBlur = useEvent(() => hideTooltip()); // The anchor rect is captured at hover time, so any scroll or resize leaves // it stale; hide instead of tracking. Capture catches scrolls inside the // table's own container too (scroll events don't bubble but do fire in // capture). useEffect(() => { const opts = { passive: true, capture: true } as const; window.addEventListener("scroll", hideTooltip, opts); window.addEventListener("resize", hideTooltip); return () => { window.removeEventListener("scroll", hideTooltip, { capture: true }); window.removeEventListener("resize", hideTooltip); }; }, [hideTooltip]); useEffect(() => clearTimer, []); return { tooltipState, tooltipContentId, hideTooltip, handleCellMouseOver, handleCellMouseLeave, handleCellFocus, handleCellBlur, }; }