162 lines
4 KiB
TypeScript
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;
|