diff --git a/web/components/widgets/tooltip.tsx b/web/components/widgets/tooltip.tsx index c289efe..a61fc84 100644 --- a/web/components/widgets/tooltip.tsx +++ b/web/components/widgets/tooltip.tsx @@ -40,18 +40,36 @@ export function Tooltip(props: { const [open, setOpen] = useState(false) - // --- Mobile tap suppression to prevent accidental neighbor tooltips --- - // After a tap/click on touch devices, browsers may emit hover/focus events on elements - // that appear under the finger due to layout shifts (e.g., when a card is removed). + // --- 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. - // This helps avoid ghost tooltips appearing on adjacent cards after actions like hide/remove. + // Additionally, we default hover behavior to mouse-only on touch-capable devices. const nowFn = () => Date.now() + const SUPPRESS_MS = 900 // Module-level shared state // eslint-disable-next-line @typescript-eslint/no-explicit-any 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 @@ -60,9 +78,11 @@ export function Tooltip(props: { } // Mark taps/pointerdowns (especially touch) globally window.addEventListener('touchstart', markTap, {passive: true}) + window.addEventListener('touchend', markTap, {passive: true}) window.addEventListener('pointerdown', markTap, {passive: true}) - // Fallback for some browsers + // 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. @@ -86,7 +106,7 @@ export function Tooltip(props: { if (next) { const dt = nowFn() - g.__tooltipLastTapTs // Ignore open requests shortly after a tap/click (mobile gesture) - if (dt >= 0 && dt < 400) return + if (dt >= 0 && dt < SUPPRESS_MS) return } setOpen(next) }, @@ -104,7 +124,8 @@ export function Tooltip(props: { const { getReferenceProps, getFloatingProps } = useInteractions([ useHover(context, { - mouseOnly: noTap, + // 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, }), useRole(context, { role: 'tooltip' }),