veza/apps/web/src/components/ui/tooltip.tsx

327 lines
8.3 KiB
TypeScript

import { useState, useEffect, useRef, useCallback } from 'react';
import { cn } from '@/lib/utils';
/**
* TooltipProps - Propriétés du composant Tooltip
*
* @interface TooltipProps
*/
export interface TooltipProps {
/**
* Contenu du tooltip (texte ou élément React)
*/
content: React.ReactNode;
/**
* Élément enfant qui déclenche le tooltip
*/
children: React.ReactNode;
/**
* Position du tooltip par rapport à l'élément
*
* - `top`: Au-dessus
* - `bottom`: En-dessous
* - `left`: À gauche
* - `right`: À droite
*
* @default 'top'
*/
position?: 'top' | 'bottom' | 'left' | 'right';
/**
* Événement qui déclenche l'affichage du tooltip
*
* - `hover`: Au survol (par défaut)
* - `click`: Au clic
* - `focus`: Au focus
*
* @default 'hover'
*/
trigger?: 'hover' | 'click' | 'focus';
/**
* Délai avant l'affichage en millisecondes
*
* @default 200
*/
delay?: number;
/**
* Si `true`, affiche une flèche pointant vers l'élément
*
* @default true
*/
showArrow?: boolean;
/**
* Largeur maximale du tooltip en pixels
*
* @default 300
*/
maxWidth?: number;
/**
* Si `true`, désactive le tooltip
*
* @default false
*/
disabled?: boolean;
/**
* Classes CSS personnalisées
*/
className?: string;
}
/**
* Tooltip - Composant de tooltip avec design system Kodo
*
* Composant de tooltip avec :
* - Positionnement intelligent (flip et shift automatiques)
* - Plusieurs triggers (hover, click, focus)
* - Délai configurable
* - Flèche optionnelle
* - Gestion du viewport
*
* @example
* ```tsx
* // Tooltip simple au survol
* <Tooltip content="Information supplémentaire">
* <button>Hover me</button>
* </Tooltip>
* ```
*
* @example
* ```tsx
* // Tooltip avec position et trigger personnalisés
* <Tooltip
* content="Cliquez pour plus d'infos"
* position="bottom"
* trigger="click"
* delay={300}
* >
* <button>Click me</button>
* </Tooltip>
* ```
*
* @component
* @param {TooltipProps} props - Propriétés du composant
* @returns {JSX.Element} Wrapper avec tooltip positionné
*/
export function Tooltip({
content,
children,
position = 'top',
trigger = 'hover',
delay = 200,
showArrow = true,
maxWidth = 300,
disabled = false,
className,
}: TooltipProps) {
const [visible, setVisible] = useState(false);
const [isMounted, setIsMounted] = useState(false);
const [calculatedPosition, setCalculatedPosition] = useState(position);
const [tooltipStyle, setTooltipStyle] = useState<React.CSSProperties>({});
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
// Calcul du positionnement avec flip et shift
const calculatePosition = useCallback(() => {
if (!wrapperRef.current || !tooltipRef.current || !visible) return;
const wrapperRect = wrapperRef.current.getBoundingClientRect();
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const margin = 8;
let newPosition = position;
let offsetX = 0;
let offsetY = 0;
// Flip: inverser la position si le tooltip sort de l'écran
switch (position) {
case 'top':
if (wrapperRect.top - tooltipRect.height - margin < 0) {
newPosition = 'bottom';
}
break;
case 'bottom':
if (wrapperRect.bottom + tooltipRect.height + margin > viewportHeight) {
newPosition = 'top';
}
break;
case 'left':
if (wrapperRect.left - tooltipRect.width - margin < 0) {
newPosition = 'right';
}
break;
case 'right':
if (wrapperRect.right + tooltipRect.width + margin > viewportWidth) {
newPosition = 'left';
}
break;
}
// Shift: ajuster la position pour rester dans la viewport
if (newPosition === 'top' || newPosition === 'bottom') {
const centerX = wrapperRect.left + wrapperRect.width / 2;
const tooltipHalfWidth = tooltipRect.width / 2;
const minX = margin;
const maxX = viewportWidth - margin;
if (centerX - tooltipHalfWidth < minX) {
offsetX = minX - (centerX - tooltipHalfWidth);
} else if (centerX + tooltipHalfWidth > maxX) {
offsetX = maxX - (centerX + tooltipHalfWidth);
}
} else {
const centerY = wrapperRect.top + wrapperRect.height / 2;
const tooltipHalfHeight = tooltipRect.height / 2;
const minY = margin;
const maxY = viewportHeight - margin;
if (centerY - tooltipHalfHeight < minY) {
offsetY = minY - (centerY - tooltipHalfHeight);
} else if (centerY + tooltipHalfHeight > maxY) {
offsetY = maxY - (centerY + tooltipHalfHeight);
}
}
setCalculatedPosition(newPosition);
setTooltipStyle({
...(offsetX !== 0 && {
transform: `translate(calc(-50% + ${offsetX}px), 0)`,
}),
...(offsetY !== 0 && {
transform: `translate(0, calc(-50% + ${offsetY}px))`,
}),
});
}, [position, visible]);
useEffect(() => {
if (visible) {
setIsMounted(true);
calculatePosition();
}
}, [visible, calculatePosition]);
useEffect(() => {
if (isMounted && visible) {
calculatePosition();
}
}, [isMounted, visible, calculatePosition]);
const showTooltip = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
setVisible(true);
}, delay);
};
const hideTooltip = () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
hideTimeoutRef.current = setTimeout(() => {
setVisible(false);
}, 100);
};
const handleClick = () => {
if (trigger === 'click') {
setVisible(!visible);
}
};
const triggerProps = {
hover: {
onMouseEnter: showTooltip,
onMouseLeave: hideTooltip,
},
click: {
onClick: handleClick,
},
focus: {
onFocus: showTooltip,
onBlur: hideTooltip,
},
}[trigger];
const positionClasses = {
top: 'bottom-full left-1/2 -translate-x-1/2 mb-2',
bottom: 'top-full left-1/2 -translate-x-1/2 mt-2',
left: 'right-full top-1/2 -translate-y-1/2 mr-2',
right: 'left-full top-1/2 -translate-y-1/2 ml-2',
};
const arrowClasses = {
top: 'top-full left-1/2 -translate-x-1/2 border-t-kodo-ink border-l-transparent border-r-transparent border-b-transparent',
bottom:
'bottom-full left-1/2 -translate-x-1/2 border-b-kodo-ink border-l-transparent border-r-transparent border-t-transparent',
left: 'left-full top-1/2 -translate-y-1/2 border-l-kodo-ink border-t-transparent border-b-transparent border-r-transparent',
right:
'right-full top-1/2 -translate-y-1/2 border-r-kodo-ink border-t-transparent border-b-transparent border-l-transparent',
};
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (hideTimeoutRef.current) {
clearTimeout(hideTimeoutRef.current);
}
};
}, []);
if (disabled) {
return <>{children}</>;
}
const tooltipContent = (
<>
{isMounted && (
<div
ref={tooltipRef}
className={cn(
'absolute z-50 px-3 py-1.5 text-sm text-white bg-kodo-ink rounded-md shadow-lg',
'border border-kodo-steel pointer-events-none',
'transition-all duration-200',
positionClasses[calculatedPosition],
visible ? 'opacity-100 scale-100' : 'opacity-0 scale-95',
className,
)}
role="tooltip"
style={{ maxWidth: `${maxWidth}px`, ...tooltipStyle }}
>
{content}
{showArrow && (
<div
className={cn(
'absolute w-0 h-0 border-4',
arrowClasses[calculatedPosition],
)}
/>
)}
</div>
)}
</>
);
return (
<div ref={wrapperRef} className="relative inline-block" {...triggerProps}>
{children}
{tooltipContent}
</div>
);
}