返回 DeepSeek-Reasonix
Tooltip.tsx
根目录 / desktop / frontend / src / components / Tooltip.tsx
1 import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2 import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
3 import { createPortal } from "react-dom";
4
5 type TooltipSide = "top" | "bottom" | "left" | "right";
6
7 const GAP = 8;
8 const EDGE_PAD = 8;
9 const ARROW_SIZE = 7;
10 const ARROW_PAD = 12;
11
12 function clamp(value: number, min: number, max: number): number {
13 return Math.min(max, Math.max(min, value));
14 }
15
16 function oppositeSide(side: TooltipSide): TooltipSide {
17 if (side === "top") return "bottom";
18 if (side === "bottom") return "top";
19 if (side === "left") return "right";
20 return "left";
21 }
22
23 function samePosition(
24 current: { left: number; top: number; side: TooltipSide; arrowX: number; arrowY: number },
25 next: { left: number; top: number; side: TooltipSide; arrowX: number; arrowY: number },
26 ): boolean {
27 return (
28 current.side === next.side &&
29 Math.abs(current.left - next.left) < 0.5 &&
30 Math.abs(current.top - next.top) < 0.5 &&
31 Math.abs(current.arrowX - next.arrowX) < 0.5 &&
32 Math.abs(current.arrowY - next.arrowY) < 0.5
33 );
34 }
35
36 export function Tooltip({
37 label,
38 children,
39 side = "top",
40 fill = false,
41 block = false,
42 disabled = false,
43 className,
44 }: {
45 label?: ReactNode;
46 children: ReactNode;
47 side?: TooltipSide;
48 fill?: boolean;
49 block?: boolean;
50 disabled?: boolean;
51 className?: string;
52 }) {
53 const id = useId();
54 const triggerRef = useRef<HTMLElement | null>(null);
55 const tooltipRef = useRef<HTMLDivElement>(null);
56 const showTimerRef = useRef<number | null>(null);
57 const [open, setOpen] = useState(false);
58 const [position, setPosition] = useState({ left: 0, top: 0, side, arrowX: 0, arrowY: 0 });
59 const active = !disabled && label !== undefined && label !== null && label !== "";
60
61 const clearTimer = () => {
62 if (showTimerRef.current === null) return;
63 window.clearTimeout(showTimerRef.current);
64 showTimerRef.current = null;
65 };
66
67 const show = (delay = 180) => {
68 if (!active) return;
69 clearTimer();
70 showTimerRef.current = window.setTimeout(() => setOpen(true), delay);
71 };
72
73 const hide = () => {
74 clearTimer();
75 setOpen(false);
76 };
77
78 const updatePosition = () => {
79 const trigger = triggerRef.current;
80 const tip = tooltipRef.current;
81 if (!trigger || !tip) return;
82 const rect = trigger.getBoundingClientRect();
83 const tipRect = tip.getBoundingClientRect();
84 const space = {
85 top: rect.top - EDGE_PAD,
86 bottom: window.innerHeight - rect.bottom - EDGE_PAD,
87 left: rect.left - EDGE_PAD,
88 right: window.innerWidth - rect.right - EDGE_PAD,
89 };
90 let actualSide = side;
91 if ((side === "top" || side === "bottom") && space[side] < tipRect.height + GAP + ARROW_SIZE) {
92 const opposite = oppositeSide(side);
93 if (space[opposite] > space[side]) actualSide = opposite;
94 } else if ((side === "left" || side === "right") && space[side] < tipRect.width + GAP + ARROW_SIZE) {
95 const opposite = oppositeSide(side);
96 if (space[opposite] > space[side]) actualSide = opposite;
97 }
98
99 let left =
100 actualSide === "left"
101 ? rect.left - tipRect.width - GAP - ARROW_SIZE
102 : actualSide === "right"
103 ? rect.right + GAP + ARROW_SIZE
104 : rect.left + rect.width / 2 - tipRect.width / 2;
105 let top =
106 actualSide === "top"
107 ? rect.top - tipRect.height - GAP - ARROW_SIZE
108 : actualSide === "bottom"
109 ? rect.bottom + GAP + ARROW_SIZE
110 : rect.top + rect.height / 2 - tipRect.height / 2;
111
112 left = clamp(left, EDGE_PAD, window.innerWidth - tipRect.width - EDGE_PAD);
113 top = clamp(top, EDGE_PAD, window.innerHeight - tipRect.height - EDGE_PAD);
114 const arrowX = clamp(rect.left + rect.width / 2 - left, ARROW_PAD, tipRect.width - ARROW_PAD);
115 const arrowY = clamp(rect.top + rect.height / 2 - top, ARROW_PAD, tipRect.height - ARROW_PAD);
116
117 const next = {
118 left,
119 top,
120 side: actualSide,
121 arrowX,
122 arrowY,
123 };
124 setPosition((current) => (samePosition(current, next) ? current : next));
125 };
126
127 useLayoutEffect(() => {
128 if (!open) return;
129 updatePosition();
130 }, [open, label, side]);
131
132 useEffect(() => {
133 if (!open) return;
134 window.addEventListener("resize", updatePosition);
135 window.addEventListener("scroll", updatePosition, true);
136 return () => {
137 window.removeEventListener("resize", updatePosition);
138 window.removeEventListener("scroll", updatePosition, true);
139 };
140 }, [open]);
141
142 useEffect(() => () => clearTimer(), []);
143
144 const triggerClass = `tooltip-trigger${fill ? " tooltip-trigger--fill" : ""}${block ? " tooltip-trigger--block" : ""}${className ? ` ${className}` : ""}`;
145 const setTriggerRef = (node: HTMLElement | null) => {
146 triggerRef.current = node;
147 };
148 const triggerProps = {
149 className: triggerClass,
150 "aria-describedby": open ? id : undefined,
151 onMouseEnter: () => show(),
152 onMouseLeave: hide,
153 onPointerDownCapture: hide,
154 onFocus: () => show(0),
155 onBlur: hide,
156 onKeyDown: (event: ReactKeyboardEvent<HTMLElement>) => {
157 if (event.key === "Escape" || event.key === "Enter" || event.key === " ") hide();
158 },
159 };
160
161 return (
162 <>
163 {block ? <div ref={setTriggerRef} {...triggerProps}>{children}</div> : <span ref={setTriggerRef} {...triggerProps}>{children}</span>}
164 {open &&
165 active &&
166 createPortal(
167 <div
168 id={id}
169 ref={tooltipRef}
170 className={`tooltip tooltip--${position.side}`}
171 role="tooltip"
172 style={{
173 left: position.left,
174 top: position.top,
175 "--tooltip-arrow-x": `${position.arrowX}px`,
176 "--tooltip-arrow-y": `${position.arrowY}px`,
177 } as CSSProperties}
178 >
179 {label}
180 </div>,
181 document.body,
182 )}
183 </>
184 );
185 }
186
186 lines Plain Text