Files
CapaKraken/apps/web/src/components/ui/InfoTooltip.tsx
T

97 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}