web/app/components/ui/tooltip.tsxblame
View source
4dfd09b1"use client";
4dfd09b2
4dfd09b3import {
4dfd09b4 useState,
4dfd09b5 useRef,
4dfd09b6 useEffect,
4dfd09b7 cloneElement,
4dfd09b8 isValidElement,
4dfd09b9} from "react";
4dfd09b10import { createPortal } from "react-dom";
4dfd09b11
4dfd09b12export interface TooltipProps {
4dfd09b13 content: string;
4dfd09b14 children: React.ReactElement;
4dfd09b15 side?: "top" | "bottom" | "left" | "right";
4dfd09b16 delay?: number;
4dfd09b17}
4dfd09b18
4dfd09b19export function Tooltip({
4dfd09b20 content,
4dfd09b21 children,
4dfd09b22 side = "top",
4dfd09b23 delay = 300,
4dfd09b24}: TooltipProps) {
4dfd09b25 const [visible, setVisible] = useState(false);
4dfd09b26 const [pos, setPos] = useState({ top: 0, left: 0 });
4dfd09b27 const [mounted, setMounted] = useState(false);
4dfd09b28 const triggerRef = useRef<HTMLElement>(null);
4dfd09b29 const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
4dfd09b30
4dfd09b31 useEffect(() => {
4dfd09b32 setMounted(true);
4dfd09b33 return () => {
4dfd09b34 if (timerRef.current) clearTimeout(timerRef.current);
4dfd09b35 };
4dfd09b36 }, []);
4dfd09b37
4dfd09b38 function show() {
4dfd09b39 timerRef.current = setTimeout(() => {
4dfd09b40 if (!triggerRef.current) return;
4dfd09b41 const rect = triggerRef.current.getBoundingClientRect();
4dfd09b42 const gap = 6;
4dfd09b43
4dfd09b44 let top = 0;
4dfd09b45 let left = 0;
4dfd09b46
4dfd09b47 switch (side) {
4dfd09b48 case "top":
4dfd09b49 top = rect.top - gap + window.scrollY;
4dfd09b50 left = rect.left + rect.width / 2 + window.scrollX;
4dfd09b51 break;
4dfd09b52 case "bottom":
4dfd09b53 top = rect.bottom + gap + window.scrollY;
4dfd09b54 left = rect.left + rect.width / 2 + window.scrollX;
4dfd09b55 break;
4dfd09b56 case "left":
4dfd09b57 top = rect.top + rect.height / 2 + window.scrollY;
4dfd09b58 left = rect.left - gap + window.scrollX;
4dfd09b59 break;
4dfd09b60 case "right":
4dfd09b61 top = rect.top + rect.height / 2 + window.scrollY;
4dfd09b62 left = rect.right + gap + window.scrollX;
4dfd09b63 break;
4dfd09b64 }
4dfd09b65
4dfd09b66 setPos({ top, left });
4dfd09b67 setVisible(true);
4dfd09b68 }, delay);
4dfd09b69 }
4dfd09b70
4dfd09b71 function hide() {
4dfd09b72 if (timerRef.current) clearTimeout(timerRef.current);
4dfd09b73 setVisible(false);
4dfd09b74 }
4dfd09b75
4dfd09b76 const transform = {
4dfd09b77 top: "translate(-50%, -100%)",
4dfd09b78 bottom: "translate(-50%, 0)",
4dfd09b79 left: "translate(-100%, -50%)",
4dfd09b80 right: "translate(0, -50%)",
4dfd09b81 }[side];
4dfd09b82
4dfd09b83 if (!isValidElement(children)) return children;
4dfd09b84
4dfd09b85 return (
4dfd09b86 <>
4dfd09b87 {cloneElement(children as React.ReactElement<Record<string, unknown>>, {
4dfd09b88 ref: triggerRef,
4dfd09b89 onMouseEnter: show,
4dfd09b90 onMouseLeave: hide,
4dfd09b91 onFocus: show,
4dfd09b92 onBlur: hide,
4dfd09b93 })}
4dfd09b94 {mounted &&
4dfd09b95 visible &&
4dfd09b96 createPortal(
4dfd09b97 <div
4dfd09b98 role="tooltip"
4dfd09b99 className="text-xs px-2 py-1"
4dfd09b100 style={{
4dfd09b101 position: "absolute",
4dfd09b102 top: pos.top,
4dfd09b103 left: pos.left,
4dfd09b104 transform,
4dfd09b105 backgroundColor: "var(--bg-inset)",
4dfd09b106 border: "1px solid var(--border)",
4dfd09b107 color: "var(--text-secondary)",
4dfd09b108 whiteSpace: "nowrap",
4dfd09b109 zIndex: 9999,
4dfd09b110 pointerEvents: "none",
4dfd09b111 }}
4dfd09b112 >
4dfd09b113 {content}
4dfd09b114 </div>,
4dfd09b115 document.body
4dfd09b116 )}
4dfd09b117 </>
4dfd09b118 );
4dfd09b119}