"use client"; import { cloneElement, useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type PointerEvent, type ReactElement, type ReactNode } from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion, useMotionValue, useReducedMotion, useSpring } from "motion/react"; type TooltipFloatProps = { /** Tooltip text. */ content: ReactNode; /** One focusable element, like a button or link. */ children: ReactElement<{ "aria-describedby"?: string }>; /** Preferred side. Flips by itself when there is no room. */ side?: "top" | "bottom"; /** Milliseconds of hover before it shows. Keyboard focus shows it at once. */ delay?: number; /** Replaces the default look. */ className?: string; }; const GAP = 10; const EDGE = 8; const noop = () => () => {}; /* TooltipFloat: a label that hangs in the air above its target, bobbing a little and leaning toward the pointer. Works on keyboard focus too, Esc hides it, and the pointer can move onto it. */ export function TooltipFloat({ content, children, side = "top", delay = 150, className = "bg-[#241e44] text-white shadow-lg shadow-black/40", }: TooltipFloatProps) { const reduce = useReducedMotion(); const id = useId(); const anchor = useRef(null); const tip = useRef(null); const timer = useRef(undefined); const [open, setOpen] = useState(false); const browser = useSyncExternalStore(noop, () => true, () => false); const lean = useMotionValue(0); const x = useSpring(lean, { stiffness: 300, damping: 20 }); const toggle = (next: boolean, wait: number) => { window.clearTimeout(timer.current); timer.current = window.setTimeout(() => setOpen(next), wait); }; const onMove = (e: PointerEvent) => { const box = e.currentTarget.getBoundingClientRect(); lean.set(Math.max(-10, Math.min(10, (e.clientX - box.left - box.width / 2) * 0.15))); }; // Position is measured before paint: flip when the side has no room, slide to stay on screen. useLayoutEffect(() => { const el = tip.current; const box = anchor.current?.getBoundingClientRect(); if (!open || !el || !box) return; const { offsetWidth: w, offsetHeight: h } = el; const { clientWidth, clientHeight } = document.documentElement; const fitsTop = box.top - h - GAP >= EDGE; const fitsBottom = box.bottom + h + GAP <= clientHeight - EDGE; const onTop = side === "top" ? fitsTop || !fitsBottom : fitsTop && !fitsBottom; const middle = box.left + box.width / 2; const left = Math.max(EDGE, Math.min(clientWidth - w - EDGE, middle - w / 2)); el.dataset.side = onTop ? "top" : "bottom"; el.style.top = `${onTop ? box.top - h - GAP : box.bottom + GAP}px`; el.style.left = `${left}px`; el.style.setProperty("--ax", `${middle - left}px`); }, [open, side]); // Esc or any scroll hides it. useEffect(() => { if (!open) return; const onKey = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") setOpen(false); }; const onScroll = () => setOpen(false); window.addEventListener("keydown", onKey); window.addEventListener("scroll", onScroll, true); return () => { window.removeEventListener("keydown", onKey); window.removeEventListener("scroll", onScroll, true); }; }, [open]); useEffect(() => () => window.clearTimeout(timer.current), []); return ( <> e.pointerType === "mouse" && toggle(true, delay)} onPointerLeave={() => toggle(false, 120)} onPointerMove={onMove} onFocus={() => toggle(true, 0)} onBlur={() => toggle(false, 0)} > {cloneElement(children, { "aria-describedby": id })} {content} {browser && createPortal( {open && (
toggle(true, 0)} onPointerLeave={() => toggle(false, 120)} className="group fixed z-60 w-max max-w-[min(15rem,calc(100vw-1rem))]" > {content}
)}
, document.body, )} ); }