2026-02-06 09:32:14 +00:00
|
|
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
|
|
|
|
|
|
|
|
type Position = 'top' | 'bottom' | 'left' | 'right';
|
|
|
|
|
|
|
|
|
|
export function useTooltip(
|
|
|
|
|
position: Position,
|
|
|
|
|
trigger: 'hover' | 'click' | 'focus',
|
|
|
|
|
delay: number,
|
|
|
|
|
disabled: boolean,
|
|
|
|
|
) {
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
calculatePosition();
|
|
|
|
|
}
|
|
|
|
|
}, [visible, calculatePosition]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (isMounted && visible) {
|
|
|
|
|
calculatePosition();
|
|
|
|
|
}
|
|
|
|
|
}, [isMounted, visible, calculatePosition]);
|
|
|
|
|
|
|
|
|
|
const showTooltip = useCallback(() => {
|
|
|
|
|
if (timeoutRef.current) {
|
|
|
|
|
clearTimeout(timeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
timeoutRef.current = setTimeout(() => {
|
|
|
|
|
setVisible(true);
|
|
|
|
|
setIsMounted(true);
|
|
|
|
|
}, delay);
|
|
|
|
|
}, [delay]);
|
|
|
|
|
|
|
|
|
|
const hideTooltip = useCallback(() => {
|
|
|
|
|
if (timeoutRef.current) {
|
|
|
|
|
clearTimeout(timeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
if (hideTimeoutRef.current) {
|
|
|
|
|
clearTimeout(hideTimeoutRef.current);
|
|
|
|
|
}
|
|
|
|
|
hideTimeoutRef.current = setTimeout(() => {
|
|
|
|
|
setVisible(false);
|
|
|
|
|
}, 100);
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
const handleClick = useCallback(() => {
|
|
|
|
|
if (trigger === 'click') {
|
|
|
|
|
setVisible((v) => {
|
|
|
|
|
const next = !v;
|
|
|
|
|
if (next) setIsMounted(true);
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}, [trigger]);
|
|
|
|
|
|
|
|
|
|
const triggerProps =
|
|
|
|
|
trigger === 'hover'
|
|
|
|
|
? { onMouseEnter: showTooltip, onMouseLeave: hideTooltip }
|
|
|
|
|
: trigger === 'click'
|
|
|
|
|
? { onClick: handleClick }
|
|
|
|
|
: { onFocus: showTooltip, onBlur: hideTooltip };
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
return () => {
|
|
|
|
|
if (timeoutRef.current) clearTimeout(timeoutRef.current);
|
|
|
|
|
if (hideTimeoutRef.current) clearTimeout(hideTimeoutRef.current);
|
|
|
|
|
};
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
visible,
|
|
|
|
|
isMounted,
|
|
|
|
|
calculatedPosition,
|
|
|
|
|
tooltipStyle,
|
|
|
|
|
wrapperRef,
|
|
|
|
|
tooltipRef,
|
|
|
|
|
triggerProps: disabled ? {} : triggerProps,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const positionClasses: Record<
|
|
|
|
|
Position,
|
|
|
|
|
string
|
|
|
|
|
> = {
|
|
|
|
|
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',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const arrowClasses: Record<Position, string> = {
|
2026-02-07 13:23:04 +00:00
|
|
|
top: 'top-full left-1/2 -translate-x-1/2 border-t-card border-l-transparent border-r-transparent border-b-transparent',
|
2026-02-06 09:32:14 +00:00
|
|
|
bottom:
|
2026-02-07 13:23:04 +00:00
|
|
|
'bottom-full left-1/2 -translate-x-1/2 border-b-card border-l-transparent border-r-transparent border-t-transparent',
|
|
|
|
|
left: 'left-full top-1/2 -translate-y-1/2 border-l-card border-t-transparent border-b-transparent border-r-transparent',
|
2026-02-06 09:32:14 +00:00
|
|
|
right:
|
2026-02-07 13:23:04 +00:00
|
|
|
'right-full top-1/2 -translate-y-1/2 border-r-card border-t-transparent border-b-transparent border-l-transparent',
|
2026-02-06 09:32:14 +00:00
|
|
|
};
|