glam/packages/ui/src/components/Tooltip.tsx
2025-12-21 00:01:54 +01:00

162 lines
4 KiB
TypeScript

/**
* 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<TooltipProps> = ({
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<HTMLSpanElement>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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 ? (
<div
className={`ui-tooltip ui-tooltip--${position}`}
style={{
left: coords.x,
top: coords.y,
maxWidth: maxWidth,
}}
role="tooltip"
aria-hidden={!isVisible}
>
<div className="ui-tooltip__content">{content}</div>
<div className="ui-tooltip__arrow" />
</div>
) : null;
return (
<>
<span
ref={triggerRef}
className="ui-tooltip-trigger"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
onFocus={handleMouseEnter}
onBlur={handleMouseLeave}
>
{children}
</span>
{tooltipElement && createPortal(tooltipElement, document.body)}
</>
);
};
export default Tooltip;