import React, { useCallback, useEffect, useLayoutEffect, useRef, useState, } from 'react'; import { createPortal } from 'react-dom'; interface InfoTooltipProps { /** Help text shown inside the tooltip bubble. */ text: string; } /** * Small "?" trigger that opens a help bubble. The bubble is rendered * through a React portal directly into ``document.body`` so it cannot be * trapped behind sibling cards by an ancestor stacking context — any * ancestor with ``transform`` / ``filter`` / ``will-change`` would * otherwise contain a ``position: fixed`` child and pin even very high * z-index values underneath neighbouring cards. * * @param {InfoTooltipProps} props - Component props. * @returns {JSX.Element} Trigger + portalled tooltip bubble. */ const InfoTooltip = ({ text }: InfoTooltipProps): JSX.Element => { const [open, setOpen] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number } | null>( null ); const triggerRef = useRef(null); const showTooltip = useCallback(() => setOpen(true), []); const hideTooltip = useCallback(() => setOpen(false), []); useLayoutEffect(() => { if (!open || !triggerRef.current) return; const triggerRect = triggerRef.current.getBoundingClientRect(); setCoords({ top: triggerRect.top - 8, left: triggerRect.left + triggerRect.width / 2, }); }, [open]); useEffect(() => { if (!open) return undefined; const closeOnScroll = () => setOpen(false); window.addEventListener('scroll', closeOnScroll, true); window.addEventListener('resize', closeOnScroll); return () => { window.removeEventListener('scroll', closeOnScroll, true); window.removeEventListener('resize', closeOnScroll); }; }, [open]); const tooltipBubble = open && coords && typeof document !== 'undefined' ? createPortal( {text} , document.body ) : null; return ( <> ? {tooltipBubble} ); }; export default InfoTooltip;