391 lines
12 KiB
TypeScript
391 lines
12 KiB
TypeScript
|
|
import React, { forwardRef, useState, useRef, useEffect, cloneElement, HTMLAttributes } from 'react';
|
||
|
|
import { clsx } from 'clsx';
|
||
|
|
|
||
|
|
export interface TooltipProps extends Omit<HTMLAttributes<HTMLDivElement>, '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<HTMLDivElement, TooltipProps>(({
|
||
|
|
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<HTMLElement>(null);
|
||
|
|
const tooltipRef = useRef<HTMLDivElement>(null);
|
||
|
|
const showTimer = useRef<NodeJS.Timeout>();
|
||
|
|
const hideTimer = useRef<NodeJS.Timeout>();
|
||
|
|
|
||
|
|
// 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 && (
|
||
|
|
<div
|
||
|
|
ref={ref || tooltipRef}
|
||
|
|
className={tooltipClasses}
|
||
|
|
style={tooltipStyle}
|
||
|
|
role="tooltip"
|
||
|
|
onMouseEnter={handleTooltipMouseEnter}
|
||
|
|
onMouseLeave={handleTooltipMouseLeave}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{content}
|
||
|
|
{arrow && <div className={getArrowClasses()} />}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
Tooltip.displayName = 'Tooltip';
|
||
|
|
|
||
|
|
export default Tooltip;
|