import React, { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, memo, useCallback, } from "react"; import { createPortal } from "react-dom"; import { isFocusVisibleTarget, shouldHandleHoverPointer, } from "../utils/pointer"; interface TooltipProps { text?: string; children: React.ReactElement; enabled?: boolean; position?: "top"; followAnchor?: boolean; } const Tooltip: React.FC = ({ text, children, enabled = true, followAnchor = false, }) => { const [visible, setVisible] = useState(false); const [pos, setPos] = useState<{ left: number; top: number } | null>(null); const anchorRef = useRef(null); const tooltipId = useId(); const followRafRef = useRef(null); const safeText = useMemo(() => (text ?? "").toString(), [text]); const active = enabled && safeText.trim().length > 0; if (!React.isValidElement(children)) { return <>{children}; } const updatePosition = useCallback(() => { const el = anchorRef.current; if (!el) return; const rect = el.getBoundingClientRect(); setPos({ left: rect.left + rect.width / 2, top: rect.top }); }, []); useLayoutEffect(() => { if (!active || !visible) return; updatePosition(); const onUpdate = () => updatePosition(); window.addEventListener("scroll", onUpdate, true); window.addEventListener("resize", onUpdate); return () => { window.removeEventListener("scroll", onUpdate, true); window.removeEventListener("resize", onUpdate); }; }, [active, visible, safeText, updatePosition]); useEffect(() => { if (!followAnchor || !active || !visible) return; const tick = () => { updatePosition(); followRafRef.current = requestAnimationFrame(tick); }; followRafRef.current = requestAnimationFrame(tick); return () => { if (followRafRef.current) { cancelAnimationFrame(followRafRef.current); followRafRef.current = null; } }; }, [followAnchor, active, visible, updatePosition]); const transform = useMemo(() => { return "translate(-50%, calc(-100% - 8px))"; }, []); const background = "#1f2937"; const color = "#ffffff"; const arrowStyle = useMemo(() => { const base: React.CSSProperties = { position: "absolute", width: 0, height: 0, borderStyle: "solid", borderWidth: 6, borderColor: "transparent", }; return { ...base, left: "50%", top: "100%", transform: "translateX(-50%)", borderTopColor: background, }; }, []); const chain = useCallback( (a?: (...args: any[]) => void, b?: (...args: any[]) => void) => { return (...args: any[]) => { a?.(...args); b?.(...args); }; }, [], ); const mergeRefs = useCallback( ( a: React.Ref | undefined, b: (node: HTMLElement | null) => void, ) => { return (node: HTMLElement | null) => { if (typeof a === "function") { a(node); } else if (a && typeof a === "object" && "current" in (a as object)) { (a as React.MutableRefObject).current = node; } b(node); }; }, [], ); const child = children as React.ReactElement & { ref?: React.Ref; }; const childProps = (child.props ?? {}) as Record; const describedBy = (childProps["aria-describedby"] as string | undefined) ?? undefined; const clonedChild = React.cloneElement(child, { onPointerEnter: active ? chain( (child.props as any)?.onPointerEnter, (event: React.PointerEvent) => { if (shouldHandleHoverPointer(event.pointerType)) { setVisible(true); } }, ) : (child.props as any)?.onPointerEnter, onPointerLeave: active ? chain( (child.props as any)?.onPointerLeave, (event: React.PointerEvent) => { if (shouldHandleHoverPointer(event.pointerType)) { setVisible(false); } }, ) : (child.props as any)?.onPointerLeave, onPointerDown: active ? chain((child.props as any)?.onPointerDown, () => setVisible(false)) : (child.props as any)?.onPointerDown, onFocus: active ? chain( (child.props as any)?.onFocus, (event: React.FocusEvent) => { setVisible(isFocusVisibleTarget(event.currentTarget)); }, ) : (child.props as any)?.onFocus, onBlur: active ? chain((child.props as any)?.onBlur, () => setVisible(false)) : (child.props as any)?.onBlur, ref: mergeRefs(child.ref, (node) => { anchorRef.current = node; }), "aria-describedby": active && visible ? tooltipId : describedBy, } as any); return ( <> {clonedChild} {active && visible && pos && typeof document !== "undefined" ? createPortal(
, document.body, ) : null} ); }; export default memo(Tooltip);