"use client"; import * as React from "react"; import { motion } 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: () => {} }); export function useMenuHighlight( contentRef: React.RefObject, ) { const [rect, setRect] = React.useState<{ top: number; height: number; } | null>(null); 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(); setRect({ top: itemRect.top - contentRect.top + content.scrollTop, height: itemRect.height, }); if (resetTimerRef.current !== null) { window.clearTimeout(resetTimerRef.current); resetTimerRef.current = null; } // Appearing from hidden snaps; moving between rows glides. setInstant(visibleRef.current ? false : nextAppearIsInstantRef.current); nextAppearIsInstantRef.current = false; visibleRef.current = true; setVisible(true); }, [contentRef], ); 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 }; }