/** * Tooltip.tsx * * A simple tooltip component that renders via portal to escape overflow:hidden containers. * Used for method toggle buttons (PCA/UMAP/t-SNE) in EmbeddingProjector and other components. * * @package @glam/ui */ import React, { useState, useRef, useEffect, useCallback } from 'react'; import { createPortal } from 'react-dom'; import '../styles/Tooltip.css'; interface TooltipProps { content: string; children: React.ReactNode; position?: 'top' | 'bottom' | 'left' | 'right'; delay?: number; maxWidth?: number; } export const Tooltip: React.FC = ({ content, children, position = 'bottom', delay = 300, maxWidth = 300, }) => { const [isVisible, setIsVisible] = useState(false); const [coords, setCoords] = useState<{ x: number; y: number } | null>(null); const triggerRef = useRef(null); const timeoutRef = useRef | null>(null); const isHoveringRef = useRef(false); const calculatePosition = useCallback(() => { if (!triggerRef.current) return null; const rect = triggerRef.current.getBoundingClientRect(); let x = 0; let y = 0; switch (position) { case 'top': x = rect.left + rect.width / 2; y = rect.top - 8; break; case 'bottom': x = rect.left + rect.width / 2; y = rect.bottom + 8; break; case 'left': x = rect.left - 8; y = rect.top + rect.height / 2; break; case 'right': x = rect.right + 8; y = rect.top + rect.height / 2; break; } return { x, y }; }, [position]); const showTooltip = useCallback(() => { if (!isHoveringRef.current) return; const newCoords = calculatePosition(); if (newCoords) { setCoords(newCoords); setIsVisible(true); } }, [calculatePosition]); const handleMouseEnter = useCallback(() => { isHoveringRef.current = true; // Clear any existing timeout if (timeoutRef.current) { clearTimeout(timeoutRef.current); } // Calculate position immediately but delay visibility timeoutRef.current = setTimeout(showTooltip, delay); }, [delay, showTooltip]); const handleMouseLeave = useCallback(() => { isHoveringRef.current = false; if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } setIsVisible(false); setCoords(null); }, []); // Cleanup on unmount useEffect(() => { return () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } }; }, []); // Recalculate position on scroll/resize while visible useEffect(() => { if (!isVisible) return; const handleReposition = () => { const newCoords = calculatePosition(); if (newCoords) { setCoords(newCoords); } }; window.addEventListener('scroll', handleReposition, true); window.addEventListener('resize', handleReposition); return () => { window.removeEventListener('scroll', handleReposition, true); window.removeEventListener('resize', handleReposition); }; }, [isVisible, calculatePosition]); // Don't render tooltip if no coords yet const tooltipElement = isVisible && content && coords ? (
{content}
) : null; return ( <> {children} {tooltipElement && createPortal(tooltipElement, document.body)} ); }; export default Tooltip;