327 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|