import { arrow, autoUpdate, flip, offset, Placement, safePolygon, shift, useClick, useFloating, useHover, useInteractions, useRole, } from '@floating-ui/react' import {Transition} from '@headlessui/react' import {ReactNode, useEffect, useRef, useState} from 'react' // See https://floating-ui.com/docs/react-dom export function Tooltip(props: { text: string | false | undefined | null | ReactNode children: ReactNode className?: string placement?: Placement noTap?: boolean noFade?: boolean hasSafePolygon?: boolean suppressHydrationWarning?: boolean testId?: string }) { const { text, children, className, noTap, noFade, hasSafePolygon, suppressHydrationWarning, testId, } = props const arrowRef = useRef(null) const [open, setOpen] = useState(false) // --- Mobile/tap guards to prevent accidental neighbor tooltips --- // After a tap/click on touch devices, browsers may emit hover/focus-like events // on elements that appear under the finger due to layout shifts (e.g., when a card is removed). // We keep a short global cooldown window during which tooltip "open" requests are ignored. // Additionally, we default hover behavior to mouse-only on touch-capable devices. const nowFn = () => Date.now() const SUPPRESS_MS = 400 // Module-level shared state const g: any = globalThis as any if (g.__tooltipLastTapTs === undefined) g.__tooltipLastTapTs = 0 as number if (g.__tooltipListenersSetup === undefined) g.__tooltipListenersSetup = false as boolean const isTouchCapable = () => { if (typeof window === 'undefined') return false // Prefer pointer hints // noinspection JSUnresolvedReference const nav: any = navigator as any const hasMP = !!nav && typeof nav.maxTouchPoints === 'number' && nav.maxTouchPoints > 0 const hasTP = !!nav && typeof nav.msMaxTouchPoints === 'number' && nav.msMaxTouchPoints > 0 const mm = typeof window.matchMedia === 'function' ? window.matchMedia('(hover: none) and (pointer: coarse)') : null const mm2 = typeof window.matchMedia === 'function' ? window.matchMedia('(any-hover: none)') : null const hasOntouch = 'ontouchstart' in window return hasMP || hasTP || !!mm?.matches || !!mm2?.matches || hasOntouch } useEffect(() => { if (g.__tooltipListenersSetup) return if (typeof window === 'undefined') return const markTap = () => { g.__tooltipLastTapTs = nowFn() } // Mark taps/pointerdowns (especially touch) globally window.addEventListener('touchstart', markTap, {passive: true}) window.addEventListener('touchend', markTap, {passive: true}) window.addEventListener('pointerdown', markTap, {passive: true}) // Fallbacks for some browsers window.addEventListener('mousedown', markTap, {passive: true}) window.addEventListener('click', markTap, {passive: true}) g.__tooltipListenersSetup = true return () => { // We intentionally do not remove listeners to avoid duplicating across many instances. // If component unmounts entirely (hot reload), listeners will be garbage-collected with the page. } }, []) const {x, y, refs, strategy, context} = useFloating({ open: open, onOpenChange: (next) => { if (next) { const dt = nowFn() - g.__tooltipLastTapTs // Ignore open requests shortly after a tap/click (mobile gesture) if (dt >= 0 && dt < SUPPRESS_MS) return } setOpen(next) }, whileElementsMounted: autoUpdate, placement: props.placement ?? 'top', middleware: [offset(8), flip(), shift({padding: 4}), arrow({element: arrowRef})], }) const {getReferenceProps, getFloatingProps} = useInteractions([ useHover(context, { // On touch-capable devices, default to mouse-only hover unless explicitly overridden via noTap=false mouseOnly: noTap ?? isTouchCapable(), handleClose: hasSafePolygon ? safePolygon({buffer: -0.5}) : null, }), useClick(context), useRole(context, {role: 'tooltip'}), ]) return text ? ( <> { const dt = Date.now() - g.__tooltipLastTapTs if (!(dt >= 0 && dt < SUPPRESS_MS)) { setOpen(true) } }} onMouseLeave={() => setOpen(false)} {...getReferenceProps()} > {children} {/* conditionally render tooltip and fade in/out */}
{text}
) : ( <>{children} ) }