"use client"; import { AnimatePresence, motion, useReducedMotion, type Variants, } from "motion/react"; import { cloneElement, isValidElement, type PointerEvent, type ReactElement, type ReactNode, useCallback, useEffect, useId, useMemo, useRef, useState, } from "react"; import { createPortal } from "react-dom"; import { EASE_OUT } from "@/lib/ease"; import { useDismiss } from "@/lib/hooks/use-dismiss"; import { useHoverGesture } from "@/lib/hooks/use-hover-gesture"; import { useTapGesture } from "@/lib/hooks/use-tap-gesture"; import { cn } from "@/lib/utils"; type Side = "top" | "right" | "bottom" | "left"; export interface TooltipProps { content: ReactNode; children: ReactElement; side?: Side; /** Delay before showing (ms). Default 120. */ delay?: number; className?: string; /** Classes for the outer wrapper span. Use to fix baseline / fill parent. */ wrapperClassName?: string; } // Gap between trigger and tooltip, in px. const GAP = 8; // Centering transform for the fixed-positioned anchor point, per side. const anchorTransform: Record = { top: "translate(-50%, -100%)", bottom: "translate(-50%, 0)", left: "translate(-100%, -50%)", right: "translate(0, -50%)", }; const transformOrigin: Record = { top: "center bottom", bottom: "center top", left: "right center", right: "left center", }; // Offset is in the direction *away* from the trigger — content originates near // the trigger and rises into resting position. const offsetFrom: Record = { top: { y: 8 }, bottom: { y: -8 }, left: { x: 8 }, right: { x: -8 }, }; function buildVariants(side: Side): Variants { const o = offsetFrom[side]; return { initial: { opacity: 0, scale: 0.9, filter: "blur(5px)", x: o.x ?? 0, y: o.y ?? 0, }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", x: 0, y: 0, transition: { type: "spring", stiffness: 380, damping: 30, mass: 0.7, opacity: { duration: 0.14, ease: EASE_OUT }, filter: { duration: 0.18, ease: EASE_OUT }, }, }, exit: { opacity: 0, scale: 0.94, filter: "blur(3px)", x: (o.x ?? 0) * 0.6, y: (o.y ?? 0) * 0.6, transition: { duration: 0.12, ease: EASE_OUT }, }, }; } const REDUCED_VARIANTS: Variants = { initial: { opacity: 0 }, animate: { opacity: 1, transition: { duration: 0.14, ease: EASE_OUT } }, exit: { opacity: 0, transition: { duration: 0.1, ease: EASE_OUT } }, }; // Once any tooltip has just closed, neighbouring tooltips open without the // initial delay — moving along a toolbar feels instant after the first one. const WARM_WINDOW_MS = 300; let lastHiddenAt = 0; export function Tooltip({ content, children, side = "top", delay = 120, className, wrapperClassName, }: TooltipProps) { const [open, setOpen] = useState(false); const [coords, setCoords] = useState<{ top: number; left: number } | null>( null, ); const id = useId(); const timer = useRef | null>(null); const anchorRef = useRef(null); const hover = useHoverGesture(); const reduce = useReducedMotion(); // Anchor point in viewport coords, on the edge of the trigger facing `side`. // Position:fixed means these viewport coords place the tooltip directly, so // it escapes every ancestor's stacking context and overflow. const place = useCallback(() => { const el = anchorRef.current; if (!el) return; const r = el.getBoundingClientRect(); const cx = r.left + r.width / 2; const cy = r.top + r.height / 2; const point: Record = { top: { top: r.top - GAP, left: cx }, bottom: { top: r.bottom + GAP, left: cx }, left: { top: cy, left: r.left - GAP }, right: { top: cy, left: r.right + GAP }, }; setCoords(point[side]); }, [side]); const show = useCallback(() => { if (timer.current) clearTimeout(timer.current); const warm = Date.now() - lastHiddenAt < WARM_WINDOW_MS; timer.current = setTimeout( () => { place(); setOpen(true); }, warm ? 0 : delay, ); }, [delay, place]); const hide = useCallback(() => { if (timer.current) { clearTimeout(timer.current); timer.current = null; } if (open) lastHiddenAt = Date.now(); setOpen(false); }, [open]); // A finger never hovers, and Safari does not focus a button on tap either, so // the label is only reachable if the tap itself opens the tooltip. A click // carries no pointerType, so the pointerdown that preceded it is what says // whether this was a tap; keyboard activation arrives with no pointerdown at // all, and focus has already shown the label there. const tap = useTapGesture(); const toggleOnTap = useCallback(() => { const gesture = tap.take(); if (!gesture || gesture.pointerType === "mouse") return; if (gesture.state) { hide(); return; } if (timer.current) clearTimeout(timer.current); place(); setOpen(true); }, [hide, place, tap]); // ...and closed again by the next tap that lands somewhere else. The label // covers nothing interactive, so that tap passes through to what it hit. useDismiss(open, hide, anchorRef); // Keep the tooltip pinned to the trigger while it's open and the page scrolls // or resizes (fixed coords are viewport-relative). useEffect(() => { if (!open) return; const onMove = () => place(); window.addEventListener("scroll", onMove, true); window.addEventListener("resize", onMove); return () => { window.removeEventListener("scroll", onMove, true); window.removeEventListener("resize", onMove); }; }, [open, place]); const variants = useMemo( () => (reduce ? REDUCED_VARIANTS : buildVariants(side)), [reduce, side], ); if (!isValidElement(children)) return children; // The label describes the trigger, so it has to name the trigger itself. // Everything else the tooltip needs is read off the anchor below instead of // cloned on: a handler written onto the child is the child's handler as far // as that child can tell, and a component that owns its activation — // hard-wiring onClick and spreading the rest of its props over it, as // ThemeToggle does — then runs the tooltip's instead of its own. Composing // with `props.onClick` cannot save it either, because a component element's // props hold nothing the component does internally. const trigger = cloneElement(children as ReactElement>, { "aria-describedby": id, }); return ( <> {/* biome-ignore lint/a11y/noStaticElementInteractions: the anchor is not a control — it observes the trigger it wraps. Every event listed reaches it on its own (pointerdown/click/keydown/pointercancel bubble, focus and blur arrive as focusin/focusout, and enter/leave are derived from pointerover/pointerout along a path the anchor is on), so the trigger keeps every handler it came with. */} { if (hover.enter(event)) show(); }} onPointerLeave={(event: PointerEvent) => { if (hover.leave(event)) hide(); }} onFocus={show} onBlur={hide} onPointerDown={(event: PointerEvent) => tap.start(event, open)} // A gesture the platform took away sends no click, and a key press // starts an activation that never had a pointer behind it. Either way // the record has to go, or the next click reads a finger that has long // since lifted. onPointerCancel={tap.drop} onKeyDown={tap.drop} onClick={toggleOnTap} > {trigger} {typeof document !== "undefined" ? createPortal( {open && coords ? ( {content} ) : null} , document.body, ) : null} ); }