import React, { forwardRef, useState, useRef, useEffect, cloneElement, HTMLAttributes } from 'react'; import { clsx } from 'clsx'; export interface TooltipProps extends Omit, 'content'> { content: React.ReactNode; placement?: 'top' | 'bottom' | 'left' | 'right' | 'top-start' | 'top-end' | 'bottom-start' | 'bottom-end' | 'left-start' | 'left-end' | 'right-start' | 'right-end'; trigger?: 'hover' | 'click' | 'focus' | 'manual'; delay?: number; hideDelay?: number; disabled?: boolean; arrow?: boolean; size?: 'sm' | 'md' | 'lg'; variant?: 'default' | 'dark' | 'light' | 'error' | 'warning' | 'success' | 'info'; maxWidth?: string | number; offset?: number; zIndex?: number; portalId?: string; followCursor?: boolean; interactive?: boolean; onVisibleChange?: (visible: boolean) => void; } const Tooltip = forwardRef(({ content, placement = 'top', trigger = 'hover', delay = 100, hideDelay = 100, disabled = false, arrow = true, size = 'md', variant = 'default', maxWidth = 320, offset = 8, zIndex = 1000, followCursor = false, interactive = false, onVisibleChange, className, children, ...props }, ref) => { const [isVisible, setIsVisible] = useState(false); const [position, setPosition] = useState({ top: 0, left: 0 }); const [actualPlacement, setActualPlacement] = useState(placement); const triggerRef = useRef(null); const tooltipRef = useRef(null); const showTimer = useRef(); const hideTimer = useRef(); // Clear timers on unmount useEffect(() => { return () => { if (showTimer.current) clearTimeout(showTimer.current); if (hideTimer.current) clearTimeout(hideTimer.current); }; }, []); // Calculate tooltip position const calculatePosition = (cursorX?: number, cursorY?: number) => { if (!triggerRef.current || !tooltipRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); const tooltipRect = tooltipRef.current.getBoundingClientRect(); const viewport = { width: window.innerWidth, height: window.innerHeight, }; let top = 0; let left = 0; let finalPlacement = placement; // Use cursor position if followCursor is enabled if (followCursor && cursorX !== undefined && cursorY !== undefined) { top = cursorY + offset; left = cursorX + offset; } else { // Calculate position based on placement switch (placement) { case 'top': case 'top-start': case 'top-end': top = triggerRect.top - tooltipRect.height - offset; left = placement === 'top-start' ? triggerRect.left : placement === 'top-end' ? triggerRect.right - tooltipRect.width : triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; break; case 'bottom': case 'bottom-start': case 'bottom-end': top = triggerRect.bottom + offset; left = placement === 'bottom-start' ? triggerRect.left : placement === 'bottom-end' ? triggerRect.right - tooltipRect.width : triggerRect.left + (triggerRect.width - tooltipRect.width) / 2; break; case 'left': case 'left-start': case 'left-end': top = placement === 'left-start' ? triggerRect.top : placement === 'left-end' ? triggerRect.bottom - tooltipRect.height : triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; left = triggerRect.left - tooltipRect.width - offset; break; case 'right': case 'right-start': case 'right-end': top = placement === 'right-start' ? triggerRect.top : placement === 'right-end' ? triggerRect.bottom - tooltipRect.height : triggerRect.top + (triggerRect.height - tooltipRect.height) / 2; left = triggerRect.right + offset; break; } } // Flip if tooltip would go outside viewport const willOverflowTop = top < 0; const willOverflowBottom = top + tooltipRect.height > viewport.height; const willOverflowLeft = left < 0; const willOverflowRight = left + tooltipRect.width > viewport.width; // Adjust for viewport constraints if (willOverflowTop && (placement.startsWith('top') || placement.includes('left') || placement.includes('right'))) { if (placement.startsWith('top')) { finalPlacement = placement.replace('top', 'bottom') as typeof placement; top = triggerRect.bottom + offset; } else { top = Math.max(8, top); } } if (willOverflowBottom && placement.startsWith('bottom')) { finalPlacement = placement.replace('bottom', 'top') as typeof placement; top = triggerRect.top - tooltipRect.height - offset; } if (willOverflowLeft) { if (placement.startsWith('left')) { finalPlacement = placement.replace('left', 'right') as typeof placement; left = triggerRect.right + offset; } else { left = Math.max(8, left); } } if (willOverflowRight) { if (placement.startsWith('right')) { finalPlacement = placement.replace('right', 'left') as typeof placement; left = triggerRect.left - tooltipRect.width - offset; } else { left = Math.min(viewport.width - tooltipRect.width - 8, left); } } setPosition({ top, left }); setActualPlacement(finalPlacement); }; const showTooltip = (cursorX?: number, cursorY?: number) => { if (disabled || !content) return; if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = undefined; } if (!isVisible) { showTimer.current = setTimeout(() => { setIsVisible(true); onVisibleChange?.(true); // Calculate position after showing to get accurate measurements setTimeout(() => calculatePosition(cursorX, cursorY), 0); }, delay); } }; const hideTooltip = () => { if (showTimer.current) { clearTimeout(showTimer.current); showTimer.current = undefined; } if (isVisible) { hideTimer.current = setTimeout(() => { setIsVisible(false); onVisibleChange?.(false); }, hideDelay); } }; const handleMouseEnter = (e: React.MouseEvent) => { if (trigger === 'hover') { showTooltip(followCursor ? e.clientX : undefined, followCursor ? e.clientY : undefined); } }; const handleMouseLeave = () => { if (trigger === 'hover') { hideTooltip(); } }; const handleMouseMove = (e: React.MouseEvent) => { if (trigger === 'hover' && followCursor && isVisible) { calculatePosition(e.clientX, e.clientY); } }; const handleClick = () => { if (trigger === 'click') { if (isVisible) { hideTooltip(); } else { showTooltip(); } } }; const handleFocus = () => { if (trigger === 'focus') { showTooltip(); } }; const handleBlur = () => { if (trigger === 'focus') { hideTooltip(); } }; const handleTooltipMouseEnter = () => { if (interactive && trigger === 'hover') { if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = undefined; } } }; const handleTooltipMouseLeave = () => { if (interactive && trigger === 'hover') { hideTooltip(); } }; // Recalculate position on window resize useEffect(() => { const handleResize = () => { if (isVisible) { calculatePosition(); } }; window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, [isVisible]); // Size classes const sizeClasses = { sm: 'px-2 py-1 text-xs', md: 'px-3 py-2 text-sm', lg: 'px-4 py-3 text-base', }; // Variant classes const variantClasses = { default: 'bg-text-primary text-text-inverse', dark: 'bg-gray-900 text-white', light: 'bg-white text-text-primary border border-border-primary shadow-lg', error: 'bg-color-error text-text-inverse', warning: 'bg-color-warning text-text-inverse', success: 'bg-color-success text-text-inverse', info: 'bg-color-info text-text-inverse', }; // Arrow classes based on placement const getArrowClasses = () => { const baseArrow = 'absolute w-2 h-2 rotate-45'; const arrowColor = variant === 'light' ? 'bg-white border border-border-primary' : variantClasses[variant].split(' ')[0]; switch (actualPlacement.split('-')[0]) { case 'top': return `${baseArrow} ${arrowColor} -bottom-1 left-1/2 -translate-x-1/2`; case 'bottom': return `${baseArrow} ${arrowColor} -top-1 left-1/2 -translate-x-1/2`; case 'left': return `${baseArrow} ${arrowColor} -right-1 top-1/2 -translate-y-1/2`; case 'right': return `${baseArrow} ${arrowColor} -left-1 top-1/2 -translate-y-1/2`; default: return `${baseArrow} ${arrowColor} -bottom-1 left-1/2 -translate-x-1/2`; } }; const tooltipClasses = clsx( 'absolute rounded-lg font-medium leading-tight', 'transition-all duration-200 ease-out', 'pointer-events-auto', sizeClasses[size], variantClasses[variant], { 'opacity-0 scale-95 pointer-events-none': !isVisible, 'opacity-100 scale-100': isVisible, }, className ); const tooltipStyle: React.CSSProperties = { top: position.top, left: position.left, maxWidth: typeof maxWidth === 'number' ? `${maxWidth}px` : maxWidth, zIndex, ...props.style, }; // Clone the trigger element and add event handlers const triggerElement = React.isValidElement(children) ? cloneElement(children as React.ReactElement, { ref: (node: HTMLElement) => { triggerRef.current = node; // Preserve original ref if it exists const originalRef = (children as any).ref; if (typeof originalRef === 'function') { originalRef(node); } else if (originalRef) { originalRef.current = node; } }, onMouseEnter: (e: React.MouseEvent) => { handleMouseEnter(e); // Call original handler if it exists (children as any).props?.onMouseEnter?.(e); }, onMouseLeave: (e: React.MouseEvent) => { handleMouseLeave(); (children as any).props?.onMouseLeave?.(e); }, onMouseMove: (e: React.MouseEvent) => { handleMouseMove(e); (children as any).props?.onMouseMove?.(e); }, onClick: (e: React.MouseEvent) => { handleClick(); (children as any).props?.onClick?.(e); }, onFocus: (e: React.FocusEvent) => { handleFocus(); (children as any).props?.onFocus?.(e); }, onBlur: (e: React.FocusEvent) => { handleBlur(); (children as any).props?.onBlur?.(e); }, }) : children; return ( <> {triggerElement} {content && (
{content} {arrow &&
}
)} ); }); Tooltip.displayName = 'Tooltip'; export default Tooltip;