import { GLASS_LANDED } from "./morph.js"; import { spring, STEP, stepSpring, type Spring, type SpringConfig } from "./spring.js"; /* A hover pill for the rows of a glass menu or list: a well in the glass that slides to the row under the pointer or with focus. 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. */ export type LiquidGlassHighlightOptions = { /** Selector for the rows. Defaults to menu items and options. */ items?: string | undefined; }; export type LiquidGlassHighlight = { /** Places the pill again, for rows that moved without the pointer or focus moving. */ update(): void; destroy(): void; }; // Framer Motion's stiffness 700, damping 44 and stiffness 320, damping 30. const LEAD: SpringConfig = { omega: 26.46, zeta: 0.83 }; const TRAIL: SpringConfig = { omega: 17.89, zeta: 0.84 }; // Leaving and coming back within this keeps the glide, so a brief overshoot off the // menu doesn't make the pill jump when the pointer comes back. const REAPPEAR_MS = 50; type Edges = { left: number; top: number; right: number; bottom: number }; /** * Gives a glass surface's rows a sliding hover pill. The surface is the positioned * element the glass is in. * * ```ts * attachLiquidGlassHighlight(menu, { items: '[role="menuitem"]' }); * ``` */ export function attachLiquidGlassHighlight(surface: HTMLElement, options: LiquidGlassHighlightOptions = {}): LiquidGlassHighlight { const selector = options.items ?? '[role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"]'; const reduced = matchMedia("(prefers-reduced-motion: reduce)"); const pill = document.createElement("span"); pill.className = "liquid-glass-well liquid-glass-highlight"; pill.setAttribute("aria-hidden", "true"); // Over the glass, under the rows. const glass = surface.querySelector(":scope > .liquid-glass"); if (glass) glass.after(pill); else surface.prepend(pill); const edges = { left: spring(), top: spring(), right: spring(), bottom: spring() }; let target: Edges | null = null; let current: HTMLElement | null = null; let hovered: HTMLElement | null = null; let shown = false; let hiddenAt = -Infinity; let frame = 0; let last = 0; let carry = 0; // Which edges lead, by the way the pill last moved. let down = true; let right = true; const place = (item: HTMLElement): Edges => { const frameBox = surface.getBoundingClientRect(); const box = item.getBoundingClientRect(); const left = box.left - frameBox.left - surface.clientLeft + surface.scrollLeft; const top = box.top - frameBox.top - surface.clientTop + surface.scrollTop; return { left, top, right: left + box.width, bottom: top + box.height }; }; const write = () => { const { left, top, right: r, bottom } = edges; pill.style.transform = `translate(${left.value.toFixed(2)}px, ${top.value.toFixed(2)}px)`; pill.style.width = `${Math.max(0, r.value - left.value).toFixed(2)}px`; pill.style.height = `${Math.max(0, bottom.value - top.value).toFixed(2)}px`; }; const jump = (to: Edges) => { for (const side of ["left", "top", "right", "bottom"] as const) { edges[side].value = to[side]; edges[side].velocity = 0; } write(); }; const near = (edge: Spring, to: number) => Math.abs(edge.value - to) < 0.05 && Math.abs(edge.velocity) < 1; const tick = (now: number) => { carry += Math.min(Math.max(now - last, 0) / 1000, 0.05); last = now; const to = target; if (!to) { frame = 0; return; } for (; carry >= STEP; carry -= STEP) { stepSpring(edges.top, to.top, down ? TRAIL : LEAD, STEP); stepSpring(edges.bottom, to.bottom, down ? LEAD : TRAIL, STEP); stepSpring(edges.left, to.left, right ? TRAIL : LEAD, STEP); stepSpring(edges.right, to.right, right ? LEAD : TRAIL, STEP); } write(); if (near(edges.top, to.top) && near(edges.bottom, to.bottom) && near(edges.left, to.left) && near(edges.right, to.right)) { jump(to); frame = 0; return; } frame = requestAnimationFrame(tick); }; const show = (item: HTMLElement) => { const to = place(item); current = item; pill.style.borderRadius = getComputedStyle(item).borderRadius; // Appearing from hidden snaps; moving between rows glides. const glide = shown || performance.now() - hiddenAt < REAPPEAR_MS; if (!glide || reduced.matches || !target) { if (frame) cancelAnimationFrame(frame); frame = 0; target = to; jump(to); } else { down = to.top >= target.top; right = to.left >= target.left; target = to; if (!frame) { last = performance.now(); carry = 0; frame = requestAnimationFrame(tick); } } shown = true; pill.toggleAttribute("data-shown", true); }; const hide = () => { if (!shown) return; shown = false; current = null; hiddenAt = performance.now(); pill.removeAttribute("data-shown"); }; const itemOf = (node: EventTarget | null) => { const item = node instanceof Element ? node.closest(selector) : null; if (!item || !surface.contains(item)) return null; if (item.hasAttribute("data-disabled") || item.getAttribute("aria-disabled") === "true" || item.hasAttribute("disabled")) return null; return item; }; const onPointerMove = (event: PointerEvent) => { const item = itemOf(event.target); if (item === hovered) return; hovered = item; if (item) show(item); }; const onPointerLeave = () => { hovered = null; // Keyboard focus keeps its row. const focused = itemOf(document.activeElement); if (focused && focused.matches(":focus-visible")) show(focused); else hide(); }; const onFocusIn = (event: FocusEvent) => { const item = itemOf(event.target); if (item && item !== current) show(item); }; const onFocusOut = (event: FocusEvent) => { if (!hovered && !itemOf(event.relatedTarget)) hide(); }; // Rows measured while a menu poured out were scaled and off their place. const onLanded = (event: Event) => { if (event.target !== surface || !current?.isConnected || !target) return; target = place(current); jump(target); }; surface.addEventListener("pointermove", onPointerMove); surface.addEventListener("pointerleave", onPointerLeave); surface.addEventListener("focusin", onFocusIn); surface.addEventListener("focusout", onFocusOut); surface.addEventListener(GLASS_LANDED, onLanded); return { update() { if (current?.isConnected) show(current); else hide(); }, destroy() { if (frame) cancelAnimationFrame(frame); surface.removeEventListener("pointermove", onPointerMove); surface.removeEventListener("pointerleave", onPointerLeave); surface.removeEventListener("focusin", onFocusIn); surface.removeEventListener("focusout", onFocusOut); surface.removeEventListener(GLASS_LANDED, onLanded); pill.remove(); }, }; }