ADD new frontend
This commit is contained in:
391
frontend/src/components/ui/Tooltip/Tooltip.tsx
Normal file
391
frontend/src/components/ui/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,391 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user