"use client"; import * as React from "react"; import { animate, motion, useMotionValue, useTransform, type Transition } from "framer-motion"; import { createSuperHover, type SuperHoverEventDetail } from "super-hover"; /** * Sliding hover pill shared by DropdownMenu and ContextMenu. Items report * hover/focus through {@link MenuHighlightContext}; the pill glides between * them. Flat neutral fill — no border, no shadow, no lighting. * * Pointer events alone are not enough: browsers deliberately stop updating * `:hover` while a list scrolls, so in a scrollable menu the pill would stay * pinned to whichever row the pointer last physically crossed. `super-hover` * hit-tests under the pointer every frame, so the pill follows the row that is * actually beneath the cursor as the list moves under it. */ /** Marks a menu row as a hit-test target for the per-frame hover tracker. */ export const SUPER_HOVER_ATTR = { "data-super-hover": "" } as const; export const MenuHighlightContext = React.createContext<{ reportHover: (element: HTMLElement) => void; }>({ reportHover: () => {} }); /** The flat pill glides with both edges together. */ const GLIDE: Transition = { type: "spring", bounce: 0, duration: 0.15 }; /** * On Liquid Glass each edge rides its own spring: the leading edge runs ahead * and the trailing one catches up, so the pill stretches toward the row it is * heading for and gathers back up as it lands. */ const LIQUID_LEAD: Transition = { type: "spring", stiffness: 700, damping: 44 }; const LIQUID_TRAIL: Transition = { type: "spring", stiffness: 320, damping: 30 }; export function useMenuHighlight( contentRef: React.RefObject, liquid = false, ) { const top = useMotionValue(0); const bottom = useMotionValue(0); const height = useTransform(() => bottom.get() - top.get()); const [visible, setVisible] = React.useState(false); /** * The pill snaps into place the first time it appears and glides thereafter. * That decision is made when hover is reported — never read during render, * since a ref read at render time can leave the transition stale. */ const [instant, setInstant] = React.useState(true); const visibleRef = React.useRef(false); const nextAppearIsInstantRef = React.useRef(true); const resetTimerRef = React.useRef(null); const reportHover = React.useCallback( (element: HTMLElement) => { const content = contentRef.current; if (!content) return; const contentRect = content.getBoundingClientRect(); const itemRect = element.getBoundingClientRect(); const nextTop = itemRect.top - contentRect.top + content.scrollTop; const nextBottom = nextTop + itemRect.height; if (resetTimerRef.current !== null) { window.clearTimeout(resetTimerRef.current); resetTimerRef.current = null; } // Appearing from hidden snaps; moving between rows glides. const instantMove = visibleRef.current ? false : nextAppearIsInstantRef.current; if (instantMove) { top.jump(nextTop); bottom.jump(nextBottom); } else { const down = nextTop > top.get(); animate(top, nextTop, liquid ? (down ? LIQUID_TRAIL : LIQUID_LEAD) : GLIDE); animate(bottom, nextBottom, liquid ? (down ? LIQUID_LEAD : LIQUID_TRAIL) : GLIDE); } setInstant(instantMove); nextAppearIsInstantRef.current = false; visibleRef.current = true; setVisible(true); }, [bottom, contentRef, liquid, top], ); const clear = React.useCallback(() => { visibleRef.current = false; setVisible(false); // Leaving and returning quickly keeps the glide, so a brief overshoot off // the menu doesn't make the pill jump when the pointer comes back. if (resetTimerRef.current !== null) { window.clearTimeout(resetTimerRef.current); } resetTimerRef.current = window.setTimeout(() => { nextAppearIsInstantRef.current = true; resetTimerRef.current = null; }, 50); }, []); React.useEffect( () => () => { if (resetTimerRef.current !== null) { window.clearTimeout(resetTimerRef.current); } }, [], ); // Per-frame hit-testing, scoped to this menu so it never fights another open // surface. Torn down with the menu, which is why it is created on mount // rather than kept alive globally. React.useEffect(() => { const content = contentRef.current; if (!content) return undefined; const handleEnter = (event: Event) => { const detail = (event as CustomEvent).detail; if (detail.current instanceof HTMLElement) { reportHover(detail.current); } }; const handleLeave = (event: Event) => { const detail = (event as CustomEvent).detail; // Only clear when the pointer left the menu entirely; moving between two // rows fires leave-then-enter and would otherwise flicker the pill. if (!detail.current) { clear(); } }; content.addEventListener("superhoverenter", handleEnter); content.addEventListener("superhoverleave", handleLeave); const controller = createSuperHover({ root: content, events: ["enter", "leave"], }); return () => { content.removeEventListener("superhoverenter", handleEnter); content.removeEventListener("superhoverleave", handleLeave); controller.destroy(); }; }, [clear, contentRef, reportHover]); const ctx = React.useMemo(() => ({ reportHover }), [reportHover]); const element = ( ); return { ctx, clear, element }; }