mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-02-20 07:44:01 -05:00
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
import {
|
|
arrow,
|
|
autoUpdate,
|
|
flip,
|
|
offset,
|
|
Placement,
|
|
safePolygon,
|
|
shift,
|
|
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
|
|
}) {
|
|
const {
|
|
text,
|
|
children,
|
|
className,
|
|
noTap,
|
|
noFade,
|
|
hasSafePolygon,
|
|
suppressHydrationWarning,
|
|
} = 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 = 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
|
|
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.
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
const {
|
|
x,
|
|
y,
|
|
reference,
|
|
floating,
|
|
strategy,
|
|
middlewareData,
|
|
context,
|
|
placement,
|
|
} = 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 { x: arrowX, y: arrowY } = middlewareData.arrow ?? {}
|
|
|
|
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,
|
|
}),
|
|
useRole(context, { role: 'tooltip' }),
|
|
])
|
|
// which side of tooltip arrow is on. like: if tooltip is top-left, arrow is on bottom of tooltip
|
|
const arrowSide = {
|
|
top: 'bottom',
|
|
right: 'left',
|
|
bottom: 'top',
|
|
left: 'right',
|
|
}[placement.split('-')[0]] as string
|
|
|
|
return text ? (
|
|
<>
|
|
<span
|
|
suppressHydrationWarning={suppressHydrationWarning}
|
|
className={className}
|
|
ref={reference}
|
|
{...getReferenceProps()}
|
|
>
|
|
{children}
|
|
</span>
|
|
{/* conditionally render tooltip and fade in/out */}
|
|
<Transition
|
|
show={open}
|
|
enter="transition ease-out duration-50"
|
|
enterFrom="opacity-0"
|
|
enterTo="opacity-100"
|
|
leave={noFade ? '' : 'transition ease-in duration-150'}
|
|
leaveFrom="opacity-100"
|
|
leaveTo="opacity-0"
|
|
// div attributes
|
|
role="tooltip"
|
|
ref={floating}
|
|
style={{ position: strategy, top: y ?? 0, left: x ?? 0 }}
|
|
className="text-ink-1000 bg-canvas-50 z-20 w-max max-w-xs whitespace-normal rounded px-2 py-1 text-center text-sm font-medium"
|
|
suppressHydrationWarning={suppressHydrationWarning}
|
|
{...getFloatingProps()}
|
|
>
|
|
{text}
|
|
<div
|
|
ref={arrowRef}
|
|
className="bg-canvas-50 absolute h-2 w-2 rotate-45"
|
|
style={{
|
|
top: arrowY != null ? arrowY : '',
|
|
left: arrowX != null ? arrowX : '',
|
|
right: '',
|
|
bottom: '',
|
|
[arrowSide]: '-4px',
|
|
}}
|
|
/>
|
|
</Transition>
|
|
</>
|
|
) : (
|
|
<>{children}</>
|
|
)
|
|
}
|