Files
bakery-ia/frontend/src/components/ui/Tooltip/Tooltip.tsx

391 lines
12 KiB
TypeScript
Raw Normal View History

2025-08-28 10:41:04 +02:00
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;