/** * @file Tooltip.tsx * @description The dashboard's tooltip look, and the one renderer that gives it * to every control in the app. * * ## Why a global renderer instead of a wrapper component * The UI already labels its icon-only controls with the native `title` * attribute — over two hundred of them. A `` wrapper would mean * touching every one of those call sites to get a designed bubble, and the * next `title=` someone writes would be back to the browser's grey box. * {@link GlobalTooltip} instead listens once at the document: on hover or * keyboard focus it takes the element's `title`, suppresses the native tooltip * by removing the attribute for as long as the bubble is up, and restores it * on the way out. Nothing else in the app has to know it exists, and a control * keeps its native tooltip (and its accessible name) when JS has not run. * * ## The look * Anchored to the CONTROL, not the cursor — a bubble that chases the mouse is * hard to read and lands over the thing it describes. It flips above/below and * clamps to the viewport, carries an arrow pointing at its anchor, fades in * after a short delay so a cursor crossing a toolbar does not strobe, and is * built from the theme's own tokens so it is legible in light mode too (the * old bubble was a hardcoded near-black). * * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; /** The shared bubble surface, so every tooltip in the app reads as one thing. */ export const TOOLTIP_SURFACE = "pointer-events-none z-[9999] max-w-xs rounded-lg border border-border bg-surface-2 px-2.5 py-1.5 " + "text-[11px] leading-snug text-fg-primary shadow-lg shadow-black/20 " + "whitespace-pre-wrap break-words"; /** How long the pointer must rest on a control before its tooltip appears. */ const SHOW_DELAY_MS = 350; /** Gap between the control and the bubble, and the minimum viewport margin. */ const GAP = 8; /** Opt an element (or a subtree) out: `data-tip-off` keeps the native title. */ const SELECTOR = "[title]:not([data-tip-off])"; type Anchored = { text: string; rect: DOMRect }; type Placed = { left: number; top: number; arrow: number; above: boolean }; /** * Mount ONCE, near the router root. Every `title` in the app becomes a * designed tooltip; nothing else has to change. */ export function GlobalTooltip() { const [tip, setTip] = useState(null); const [pos, setPos] = useState(null); const bubble = useRef(null); // The element whose `title` is currently parked in `data-tip`, so it can be // put back even if the pointer never leaves it (a route change, a re-render // that unmounts it, Escape). const parked = useRef(null); const timer = useRef(undefined); const restore = useCallback(() => { window.clearTimeout(timer.current); const el = parked.current; if (el) { const text = el.dataset.tip; if (text !== undefined) { el.setAttribute("title", text); delete el.dataset.tip; } parked.current = null; } setTip(null); setPos(null); }, []); const open = useCallback( (el: HTMLElement, delay: number) => { const text = (el.getAttribute("title") || "").trim(); if (!text) return; restore(); timer.current = window.setTimeout(() => { // Re-read at fire time: the control may have been re-rendered, removed // or had its title changed while the delay was running. if (!el.isConnected) return; const current = (el.getAttribute("title") || "").trim(); if (!current) return; el.dataset.tip = current; el.removeAttribute("title"); parked.current = el; setTip({ text: current, rect: el.getBoundingClientRect() }); }, delay); }, [restore] ); useEffect(() => { const target = (e: Event) => { const node = e.target; return node instanceof Element ? (node.closest(SELECTOR) as HTMLElement | null) : null; }; const onOver = (e: PointerEvent) => { const el = target(e); if (el) open(el, SHOW_DELAY_MS); }; const onOut = (e: PointerEvent) => { // Moving between children of the same control must not flicker. const el = target(e); const to = e.relatedTarget; if (el && to instanceof Node && el.contains(to)) return; if (el || parked.current) restore(); }; // Keyboard users get the same tooltip, immediately — a focus ring that // reveals nothing is the whole reason icon-only buttons are hard to learn. const onFocus = (e: FocusEvent) => { const el = target(e); if (el) open(el, 0); }; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") restore(); }; document.addEventListener("pointerover", onOver); document.addEventListener("pointerout", onOut); document.addEventListener("pointerdown", restore); document.addEventListener("focusin", onFocus); document.addEventListener("focusout", restore); document.addEventListener("keydown", onKey); // A bubble anchored to a rect is wrong the moment anything moves. window.addEventListener("scroll", restore, true); window.addEventListener("resize", restore); return () => { document.removeEventListener("pointerover", onOver); document.removeEventListener("pointerout", onOut); document.removeEventListener("pointerdown", restore); document.removeEventListener("focusin", onFocus); document.removeEventListener("focusout", restore); document.removeEventListener("keydown", onKey); window.removeEventListener("scroll", restore, true); window.removeEventListener("resize", restore); restore(); }; }, [open, restore]); // Placed after the first paint, when the bubble's real size is known — // measuring it is the only way to flip and clamp without guessing. useEffect(() => { if (!tip || !bubble.current) return; const { width, height } = bubble.current.getBoundingClientRect(); const vw = document.documentElement.clientWidth; const vh = window.innerHeight; const centre = tip.rect.left + tip.rect.width / 2; let left = centre - width / 2; left = Math.min(Math.max(left, GAP), Math.max(GAP, vw - width - GAP)); // Below by default; above only when below would run off the screen AND // above actually fits, so a control at the very bottom still gets a bubble. const above = tip.rect.bottom + GAP + height > vh && tip.rect.top - height - GAP >= GAP; setPos({ left, top: above ? tip.rect.top - height - GAP : tip.rect.bottom + GAP, // The arrow points at the control even after the bubble was clamped. arrow: Math.min(Math.max(centre - left, 12), Math.max(12, width - 12)), above, }); }, [tip]); if (!tip) return null; return createPortal(
{tip.text} {/* Two borders only: a fully bordered diamond draws its other two edges straight across the bubble it is attached to. */}
, document.body ); } export default GlobalTooltip;