97 lines
3.3 KiB
TypeScript
97 lines
3.3 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import { createPortal } from "react-dom";
|
||
|
||
interface InfoTooltipProps {
|
||
content: React.ReactNode;
|
||
/** Position relative to the trigger icon. Default: "top" */
|
||
position?: "top" | "bottom";
|
||
/** Extra width class, e.g. "w-72". Default: "w-60" */
|
||
width?: string;
|
||
}
|
||
|
||
/**
|
||
* Small ℹ icon that shows a tooltip on hover / focus.
|
||
* Rendered via a portal into document.body so it's never clipped by
|
||
* ancestor overflow:hidden containers (table cells, widget cards, etc.).
|
||
*/
|
||
export function InfoTooltip({ content, position = "top", width = "w-60" }: InfoTooltipProps) {
|
||
const [show, setShow] = useState(false);
|
||
const [coords, setCoords] = useState({ top: 0, left: 0 });
|
||
const btnRef = useRef<HTMLButtonElement>(null);
|
||
|
||
function computeCoords() {
|
||
if (!btnRef.current) return;
|
||
const rect = btnRef.current.getBoundingClientRect();
|
||
if (position === "top") {
|
||
setCoords({
|
||
top: rect.top + window.scrollY - 8, // 8px gap + arrow
|
||
left: rect.left + window.scrollX + rect.width / 2,
|
||
});
|
||
} else {
|
||
setCoords({
|
||
top: rect.bottom + window.scrollY + 8,
|
||
left: rect.left + window.scrollX + rect.width / 2,
|
||
});
|
||
}
|
||
}
|
||
|
||
function handleShow() {
|
||
computeCoords();
|
||
setShow(true);
|
||
}
|
||
|
||
// Recompute on scroll/resize while shown so tooltip follows the trigger
|
||
useEffect(() => {
|
||
if (!show) return;
|
||
const update = () => computeCoords();
|
||
window.addEventListener("scroll", update, true);
|
||
window.addEventListener("resize", update);
|
||
return () => {
|
||
window.removeEventListener("scroll", update, true);
|
||
window.removeEventListener("resize", update);
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [show]);
|
||
|
||
const tooltipStyle: React.CSSProperties =
|
||
position === "top"
|
||
? { position: "fixed", top: coords.top, left: coords.left, transform: "translate(-50%, -100%)" }
|
||
: { position: "fixed", top: coords.top, left: coords.left, transform: "translateX(-50%)" };
|
||
|
||
const arrowClass =
|
||
position === "top"
|
||
? "top-full border-t-gray-900 border-l-transparent border-r-transparent border-b-transparent border-l-4 border-r-4 border-t-4 border-b-0"
|
||
: "bottom-full border-b-gray-900 border-l-transparent border-r-transparent border-t-transparent border-l-4 border-r-4 border-b-4 border-t-0";
|
||
|
||
return (
|
||
<span className="relative inline-flex items-center">
|
||
<button
|
||
ref={btnRef}
|
||
type="button"
|
||
onMouseEnter={handleShow}
|
||
onMouseLeave={() => setShow(false)}
|
||
onFocus={handleShow}
|
||
onBlur={() => setShow(false)}
|
||
className="ml-1 w-3.5 h-3.5 rounded-full bg-gray-200 dark:bg-gray-600 text-gray-500 dark:text-gray-300 text-[9px] font-bold flex items-center justify-center hover:bg-gray-300 dark:hover:bg-gray-500 cursor-help flex-shrink-0 leading-none"
|
||
aria-label="More information"
|
||
>
|
||
i
|
||
</button>
|
||
|
||
{show &&
|
||
createPortal(
|
||
<div
|
||
style={tooltipStyle}
|
||
className={`z-[9999] ${width} bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-xl pointer-events-none`}
|
||
>
|
||
{content}
|
||
<span className={`absolute left-1/2 -translate-x-1/2 w-0 h-0 ${arrowClass}`} />
|
||
</div>,
|
||
document.body,
|
||
)}
|
||
</span>
|
||
);
|
||
}
|