veza/apps/web/src/components/ui/tooltip/useTooltip.ts

178 lines
5.3 KiB
TypeScript
Raw Normal View History

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> = {
top: 'top-full left-1/2 -translate-x-1/2 border-t-card border-l-transparent border-r-transparent border-b-transparent',
bottom:
'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',
right:
'right-full top-1/2 -translate-y-1/2 border-r-card border-t-transparent border-b-transparent border-l-transparent',
};