2.9 KB120 lines
Blame
1"use client";
2
3import {
4 useState,
5 useRef,
6 useEffect,
7 cloneElement,
8 isValidElement,
9} from "react";
10import { createPortal } from "react-dom";
11
12export interface TooltipProps {
13 content: string;
14 children: React.ReactElement;
15 side?: "top" | "bottom" | "left" | "right";
16 delay?: number;
17}
18
19export function Tooltip({
20 content,
21 children,
22 side = "top",
23 delay = 300,
24}: TooltipProps) {
25 const [visible, setVisible] = useState(false);
26 const [pos, setPos] = useState({ top: 0, left: 0 });
27 const [mounted, setMounted] = useState(false);
28 const triggerRef = useRef<HTMLElement>(null);
29 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
30
31 useEffect(() => {
32 setMounted(true);
33 return () => {
34 if (timerRef.current) clearTimeout(timerRef.current);
35 };
36 }, []);
37
38 function show() {
39 timerRef.current = setTimeout(() => {
40 if (!triggerRef.current) return;
41 const rect = triggerRef.current.getBoundingClientRect();
42 const gap = 6;
43
44 let top = 0;
45 let left = 0;
46
47 switch (side) {
48 case "top":
49 top = rect.top - gap + window.scrollY;
50 left = rect.left + rect.width / 2 + window.scrollX;
51 break;
52 case "bottom":
53 top = rect.bottom + gap + window.scrollY;
54 left = rect.left + rect.width / 2 + window.scrollX;
55 break;
56 case "left":
57 top = rect.top + rect.height / 2 + window.scrollY;
58 left = rect.left - gap + window.scrollX;
59 break;
60 case "right":
61 top = rect.top + rect.height / 2 + window.scrollY;
62 left = rect.right + gap + window.scrollX;
63 break;
64 }
65
66 setPos({ top, left });
67 setVisible(true);
68 }, delay);
69 }
70
71 function hide() {
72 if (timerRef.current) clearTimeout(timerRef.current);
73 setVisible(false);
74 }
75
76 const transform = {
77 top: "translate(-50%, -100%)",
78 bottom: "translate(-50%, 0)",
79 left: "translate(-100%, -50%)",
80 right: "translate(0, -50%)",
81 }[side];
82
83 if (!isValidElement(children)) return children;
84
85 return (
86 <>
87 {cloneElement(children as React.ReactElement<Record<string, unknown>>, {
88 ref: triggerRef,
89 onMouseEnter: show,
90 onMouseLeave: hide,
91 onFocus: show,
92 onBlur: hide,
93 })}
94 {mounted &&
95 visible &&
96 createPortal(
97 <div
98 role="tooltip"
99 className="text-xs px-2 py-1"
100 style={{
101 position: "absolute",
102 top: pos.top,
103 left: pos.left,
104 transform,
105 backgroundColor: "var(--bg-inset)",
106 border: "1px solid var(--border)",
107 color: "var(--text-secondary)",
108 whiteSpace: "nowrap",
109 zIndex: 9999,
110 pointerEvents: "none",
111 }}
112 >
113 {content}
114 </div>,
115 document.body
116 )}
117 </>
118 );
119}
120