import { useState, useRef, useCallback, useEffect, useId, type ReactNode } from "react"; import { createPortal } from "react-dom"; interface TooltipProps { label: string; children: ReactNode; delay?: number; side?: "top" | "bottom"; } // Rough bubble height (padding + one text line) used to decide flipping // before the bubble has rendered; exact height isn't needed for the guard. const APPROX_BUBBLE_H = 28; const VIEWPORT_MARGIN = 8; export function Tooltip({ label, children, delay = 400, side = "top" }: TooltipProps) { const [visible, setVisible] = useState(false); const [pos, setPos] = useState({ x: 0, y: 0 }); const [resolvedSide, setResolvedSide] = useState<"top" | "bottom">(side); const timerRef = useRef | null>(null); const triggerRef = useRef(null); // WCAG 4.1.2: programmatically associate the bubble with its trigger. const tooltipId = useId(); const show = useCallback(() => { if (timerRef.current) clearTimeout(timerRef.current); timerRef.current = setTimeout(() => { const el = triggerRef.current; if (!el) return; const child = el.firstElementChild as HTMLElement | null; const rect = (child ?? el).getBoundingClientRect(); if (rect.width === 0 && rect.height === 0) return; // Flip when the preferred side would clip the viewport edge. let nextSide = side; if (side === "top" && rect.top - APPROX_BUBBLE_H - 6 < VIEWPORT_MARGIN) { nextSide = "bottom"; } else if ( side === "bottom" && rect.bottom + APPROX_BUBBLE_H + 6 > window.innerHeight - VIEWPORT_MARGIN ) { nextSide = "top"; } const x = Math.min( Math.max(rect.left + rect.width / 2, VIEWPORT_MARGIN), window.innerWidth - VIEWPORT_MARGIN, ); setResolvedSide(nextSide); setPos({ x, y: nextSide === "top" ? rect.top - 6 : rect.bottom + 6, }); setVisible(true); }, delay); }, [delay, side]); const hide = useCallback(() => { if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current = null; } setVisible(false); }, []); // WCAG 1.4.13: tooltip content must be dismissible with Escape. useEffect(() => { if (!visible) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") hide(); }; document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [visible, hide]); return ( <> {children} {visible && createPortal(
, document.body, )} ); }