The Coline design system: accessible React components for Coline Apps, themed live by the host workspace, and for Coline Sites in light and dark. Tailwind v4 stylesheet included. MIT.
| 5 | import { createSuperHover, type SuperHoverEventDetail } from "super-hover"; |
| 6 |
| 7 | /** |
| 8 | * Sliding hover pill shared by DropdownMenu and ContextMenu. Items report |
| 9 | * hover/focus through {@link MenuHighlightContext}; the pill glides between |
| 10 | * them. Flat neutral fill — no border, no shadow, no lighting. |
| 11 | * |
| 12 | * Pointer events alone are not enough: browsers deliberately stop updating |
| 13 | * `:hover` while a list scrolls, so in a scrollable menu the pill would stay |
| 14 | * pinned to whichever row the pointer last physically crossed. `super-hover` |
| 15 | * hit-tests under the pointer every frame, so the pill follows the row that is |
| 16 | * actually beneath the cursor as the list moves under it. |
| 17 | */ |
| 18 |
| 19 | /** Marks a menu row as a hit-test target for the per-frame hover tracker. */ |
| 20 | export const SUPER_HOVER_ATTR = { "data-super-hover": "" } as const; |
| 21 |
| 22 | export const MenuHighlightContext = React.createContext<{ |
| 23 | reportHover: (element: HTMLElement) => void; |
| 24 | }>({ reportHover: () => {} }); |
| 25 |
| 26 | export function useMenuHighlight( |
| 27 | contentRef: React.RefObject<HTMLDivElement | null>, |
| 28 | ) { |
| 29 | const [rect, setRect] = React.useState<{ |
| 30 | top: number; |
| 31 | height: number; |
| 32 | } | null>(null); |
| 33 | const [visible, setVisible] = React.useState(false); |
| 34 | /** |
| 35 | * The pill snaps into place the first time it appears and glides thereafter. |
| 36 | * That decision is made when hover is reported — never read during render, |
| 37 | * since a ref read at render time can leave the transition stale. |
| 38 | */ |
| 39 | const [instant, setInstant] = React.useState(true); |
| 40 | const visibleRef = React.useRef(false); |
| 41 | const nextAppearIsInstantRef = React.useRef(true); |
| 42 | const resetTimerRef = React.useRef<number | null>(null); |
| 43 |
| 44 | const reportHover = React.useCallback( |
| 45 | (element: HTMLElement) => { |
| 46 | const content = contentRef.current; |
| 47 | if (!content) return; |
| 48 | const contentRect = content.getBoundingClientRect(); |
| 49 | const itemRect = element.getBoundingClientRect(); |
| 50 | setRect({ |
| 51 | top: itemRect.top - contentRect.top + content.scrollTop, |
| 52 | height: itemRect.height, |
| 53 | }); |
| 54 |
| 55 | if (resetTimerRef.current !== null) { |
| 56 | window.clearTimeout(resetTimerRef.current); |
| 57 | resetTimerRef.current = null; |
| 58 | } |
| 59 |
| 60 | // Appearing from hidden snaps; moving between rows glides. |
| 61 | setInstant(visibleRef.current ? false : nextAppearIsInstantRef.current); |
| 62 | nextAppearIsInstantRef.current = false; |
| 63 | visibleRef.current = true; |
| 64 | setVisible(true); |
| 65 | }, |
| 66 | [contentRef], |
| 67 | ); |
| 68 |
| 69 | const clear = React.useCallback(() => { |
| 70 | visibleRef.current = false; |
| 71 | setVisible(false); |
| 72 | // Leaving and returning quickly keeps the glide, so a brief overshoot off |
| 73 | // the menu doesn't make the pill jump when the pointer comes back. |
| 74 | if (resetTimerRef.current !== null) { |
| 75 | window.clearTimeout(resetTimerRef.current); |
| 76 | } |
| 77 | resetTimerRef.current = window.setTimeout(() => { |
| 78 | nextAppearIsInstantRef.current = true; |
| 79 | resetTimerRef.current = null; |
| 80 | }, 50); |
| 81 | }, []); |
| 82 |
| 83 | React.useEffect( |
| 84 | () => () => { |
| 85 | if (resetTimerRef.current !== null) { |
| 86 | window.clearTimeout(resetTimerRef.current); |
| 87 | } |
| 88 | }, |
| 89 | [], |
| 90 | ); |
| 91 |
| 92 | // Per-frame hit-testing, scoped to this menu so it never fights another open |
| 93 | // surface. Torn down with the menu, which is why it is created on mount |
| 94 | // rather than kept alive globally. |
| 95 | React.useEffect(() => { |
| 96 | const content = contentRef.current; |
| 97 | if (!content) return undefined; |
| 98 |
| 99 | const handleEnter = (event: Event) => { |
| 100 | const detail = (event as CustomEvent<SuperHoverEventDetail>).detail; |
| 101 | if (detail.current instanceof HTMLElement) { |
| 102 | reportHover(detail.current); |
| 103 | } |
| 104 | }; |
| 105 |
| 106 | const handleLeave = (event: Event) => { |
| 107 | const detail = (event as CustomEvent<SuperHoverEventDetail>).detail; |
| 108 | // Only clear when the pointer left the menu entirely; moving between two |
| 109 | // rows fires leave-then-enter and would otherwise flicker the pill. |
| 110 | if (!detail.current) { |
| 111 | clear(); |
| 112 | } |
| 113 | }; |
| 114 |
| 115 | content.addEventListener("superhoverenter", handleEnter); |
| 116 | content.addEventListener("superhoverleave", handleLeave); |
| 117 |
| 118 | const controller = createSuperHover({ |
| 119 | root: content, |
| 120 | events: ["enter", "leave"], |
| 121 | }); |
| 122 |
| 123 | return () => { |
| 124 | content.removeEventListener("superhoverenter", handleEnter); |
| 125 | content.removeEventListener("superhoverleave", handleLeave); |
| 126 | controller.destroy(); |
| 127 | }; |
| 128 | }, [clear, contentRef, reportHover]); |
| 129 |
| 130 | const ctx = React.useMemo(() => ({ reportHover }), [reportHover]); |
| 131 |
| 132 | const element = ( |
| 133 | <motion.div |
| 134 | className="pointer-events-none absolute inset-x-1 top-0 z-0 rounded-2xl bg-black/[0.045] transform-gpu will-change-[transform,opacity] dark:bg-white/[0.08]" |
| 135 | initial={false} |
| 136 | animate={{ |
| 137 | y: rect?.top ?? 0, |
| 138 | height: rect?.height ?? 0, |
| 139 | opacity: visible ? 1 : 0, |
| 140 | }} |
| 141 | transition={{ |
| 142 | y: instant |
| 143 | ? { duration: 0 } |
| 144 | : { type: "spring", bounce: 0, duration: 0.15 }, |
| 145 | height: instant |
| 146 | ? { duration: 0 } |
| 147 | : { type: "spring", bounce: 0, duration: 0.15 }, |
| 148 | opacity: { |
| 149 | duration: instant ? 0.05 : 0.1, |
| 150 | ease: "linear", |
| 151 | }, |
| 152 | }} |
| 153 | /> |
| 154 | ); |
| 155 |
| 156 | return { ctx, clear, element }; |
| 157 | } |
| 158 |