Files
phokus/src/components/Tooltip.tsx
T
LyAhn 827e1a8ecf style: format frontend with prettier
Mechanical one-shot pass of pnpm format over src/, tests/, tools/, and
root configs. No functional changes; build and type-check verified.
2026-07-04 20:23:32 +01:00

172 lines
5.7 KiB
TypeScript

import { MouseEvent, ReactNode, useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { motion } from 'framer-motion'
type TooltipSide = 'top' | 'bottom' | 'left' | 'right'
type TooltipAlign = 'center' | 'start' | 'end'
// Horizontal alignment only applies to top/bottom; left/right center vertically.
function positionClasses(side: TooltipSide, align: TooltipAlign): string {
if (side === 'left') return 'right-full top-1/2 mr-1.5 -translate-y-1/2'
if (side === 'right') return 'left-full top-1/2 ml-1.5 -translate-y-1/2'
const vertical = side === 'top' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
const horizontal =
align === 'start' ? 'left-0' : align === 'end' ? 'right-0' : 'left-1/2 -translate-x-1/2'
return `${vertical} ${horizontal}`
}
const BASE_CLASSES =
'pointer-events-none z-50 whitespace-nowrap rounded-md border border-white/10 bg-gray-800 px-2 py-1 text-[11px] text-gray-200 shadow-lg'
const STATIC_CLASSES = `${BASE_CLASSES} transition-opacity duration-100`
/**
* Lightweight custom tooltip — fades in (faster than the native one) after a
* tunable hover `delay`, and hides instantly on leave or click. By default it
* anchors to a `side` of the wrapper. `anchorToCursor` shows at the pointer's
* entry position; `followCursor` keeps tracking the pointer.
*/
export function Tooltip({
label,
delay = 400,
side = 'bottom',
align = 'center',
block = false,
anchorToCursor = false,
followCursor = false,
disabled = false,
className = '',
children,
}: {
label: ReactNode
/** Milliseconds the pointer must hover before the tooltip appears (0 = instant). */
delay?: number
side?: TooltipSide
/** Horizontal alignment for top/bottom tooltips. */
align?: TooltipAlign
/** Full-width block wrapper (e.g. wrapping a grid cell) instead of inline. */
block?: boolean
/** Position at the cursor when shown, without following subsequent movement. */
anchorToCursor?: boolean
/** Track the cursor (fixed position) instead of anchoring to a side. */
followCursor?: boolean
/** Render the wrapper but suppress tooltip display. */
disabled?: boolean
/** Extra classes for the wrapper (e.g. layout). */
className?: string
children: ReactNode
}) {
const [visible, setVisible] = useState(false)
const [coords, setCoords] = useState({ x: 0, y: 0 })
const [mounted, setMounted] = useState(false)
const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const frame = useRef<number | undefined>(undefined)
const tooltipRef = useRef<HTMLSpanElement>(null)
// Resolve a viewport-safe position near the cursor: below-right by default,
// but flipped above the cursor if it would overflow the bottom edge, and
// clamped so it never runs off the right/left.
const place = (clientX: number, clientY: number) => {
const tip = tooltipRef.current
const tipWidth = tip?.offsetWidth ?? 0
const tipHeight = tip?.offsetHeight ?? 24
const gap = 16
let top = clientY + gap
if (top + tipHeight > window.innerHeight - 4) {
top = clientY - gap - tipHeight
}
let left = clientX + 14
if (left + tipWidth > window.innerWidth - 4) left = window.innerWidth - 4 - tipWidth
if (left < 4) left = 4
setCoords({ x: left, y: top })
}
const clear = () => {
if (timer.current) clearTimeout(timer.current)
timer.current = undefined
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = undefined
}
const show = (event: MouseEvent<HTMLElement>) => {
if (disabled) return
clear()
if (anchorToCursor || followCursor) place(event.clientX, event.clientY)
if (delay <= 0) {
setVisible(true)
return
}
timer.current = setTimeout(() => setVisible(true), delay)
}
const hide = () => {
clear()
setVisible(false)
}
const move = (event: MouseEvent<HTMLElement>) => {
if (!followCursor) return
const { clientX, clientY } = event
if (frame.current !== undefined) cancelAnimationFrame(frame.current)
frame.current = requestAnimationFrame(() => {
frame.current = undefined
place(clientX, clientY)
})
}
useEffect(() => {
setMounted(true)
return clear
}, [])
useEffect(() => {
if (disabled) hide()
}, [disabled])
const Wrapper = block ? 'div' : 'span'
const cursorTooltip = (
<motion.span
ref={tooltipRef}
role="tooltip"
aria-hidden={!visible}
// Portaled + fixed so transformed parents and scroll containers cannot
// distort cursor-anchored tooltip coordinates.
className={`fixed ${BASE_CLASSES}`}
initial={false}
animate={{ left: coords.x, top: coords.y, opacity: visible ? 1 : 0 }}
transition={{
left: followCursor
? { type: 'spring', stiffness: 700, damping: 45, mass: 0.35 }
: { duration: 0 },
top: followCursor
? { type: 'spring', stiffness: 520, damping: 34, mass: 0.45 }
: { duration: 0 },
opacity: { duration: visible ? 0.1 : 0.05 },
}}
>
{label}
</motion.span>
)
return (
<Wrapper
className={`${block ? 'relative block w-full' : 'relative inline-flex'} ${className}`}
onMouseEnter={show}
onMouseMove={followCursor ? move : undefined}
onMouseLeave={hide}
onPointerDown={hide}
>
{children}
{disabled ? null : anchorToCursor || followCursor ? (
mounted ? (
createPortal(cursorTooltip, document.body)
) : null
) : (
<span
role="tooltip"
aria-hidden={!visible}
className={`absolute ${STATIC_CLASSES} ${positionClasses(side, align)} ${visible ? 'opacity-100' : 'opacity-0'}`}
>
{label}
</span>
)}
</Wrapper>
)
}