import { useEffect, useMemo, useRef, useState } from "react"; import { useFloating, autoUpdate, offset as offsetMiddleware, flip, shift, size, hide, useClick, useHover, useFocus, useDismiss, useRole, useListNavigation, useTypeahead, useInteractions, useFloatingNodeId, safePolygon, type Placement, type UseRoleProps, } from "@floating-ui/react"; import { useLayer } from "../../lib/layerStack"; import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging"; // The non-component half of the R8 popover platform: the `useAnchoredPopover` engine hook and the // `closeThenAct` row helper. The surfaces it drives (PopoverSurface / PopoverItem) and the platform's // design notes live in AnchoredPopover.tsx; the split keeps that file components-only (Fast Refresh). // Internal to the platform; call sites pass the string literal via `mode`, they don't name the type. type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox"; export interface AnchoredPopoverOptions { open: boolean; onOpenChange: (open: boolean, event?: Event) => void; /** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards); * "combobox" is a typeable input + listbox where focus stays in the field (use via ). */ mode?: PopoverMode; placement?: Placement; /** Gap between trigger and panel, px. Default 4. */ offset?: number; /** Hover mode only: open/close intent delays, ms. */ hoverDelay?: number | { open?: number; close?: number }; /** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */ role?: UseRoleProps["role"]; /** Cap the panel height to the available space (adds the `size` middleware). Off by default so a * caller's own max-height class wins; turn on for long lists that should shrink to fit. */ fitHeight?: boolean; /** Type-to-focus a matching item by label (menu/listbox only). Default on; turn OFF for menus that * contain a free-text input, where character keys belong to the text field. */ typeahead?: boolean; /** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND * focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals * focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */ hostsInput?: boolean; /** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom — * a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and * must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a * hair before the click handler toggles it shut again. */ hoverAlso?: boolean; /** Hover mode only: a MOUSE affordance and nothing else — no focus-open, no touch. A media card's * preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop, * and a tap is the card's play. */ pointerOnly?: boolean; /** Index of the item that is currently CHOSEN, for a single-choice menu/listbox. Opening then * lands the roving focus on it instead of the first item — the behaviour a "pick one of these * modes" menu needs. Leave unset for action menus and multi-select toggle lists. */ selectedIndex?: number | null; /** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored * to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point * rather than an element — a right-click menu, which otherwise stays parked over whatever row * scrolls under it while its actions still target the row you opened it on (E10 review round 1). * This is also what a native context menu does. */ dismissOnScroll?: boolean; } export function useAnchoredPopover(opts: AnchoredPopoverOptions) { const mode = opts.mode ?? "menu"; const isHover = mode === "hover"; const isCombobox = mode === "combobox"; // Roving-keyboard list surfaces: a menu of actions or a listbox of options. A "dialog" popover holds // arbitrary focusable content (its own Tab order), and "hover" is a passive tooltip — neither roves. const isNav = mode === "menu" || mode === "listbox"; // Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND // the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept // out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here. const roves = isNav || isCombobox; // null when not inside a — the hook still works, just without nested-layer awareness. const nodeId = useFloatingNodeId(); // Item registry for list navigation + typeahead, populated by via useListItem. const elementsRef = useRef>([]); const labelsRef = useRef>([]); const [activeIndex, setActiveIndex] = useState(null); // Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation // syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input // (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never // follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that // tracks the pointer when hovering and the keyboard otherwise. const [hoverIndex, setHoverIndex] = useState(null); const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({ nodeId, open: opts.open, onOpenChange: opts.onOpenChange, placement: opts.placement ?? "bottom-start", whileElementsMounted: autoUpdate, middleware: [ offsetMiddleware(opts.offset ?? 4), flip({ padding: 8 }), shift({ padding: 8 }), ...(opts.fitHeight ? [ size({ padding: 8, apply({ availableHeight, elements }) { // Cap the positioned element, AND publish the available space as a custom property so // the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on // the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))` // keeps a caller's design cap while still shrinking to fit at high zoom / short viewports. // Capping only the outer element would leave the inner overflow-y-auto box at its fixed // height, spilling its top off-screen (the whole point of fitHeight). elements.floating.style.maxHeight = `${availableHeight}px`; elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`); }, }), ] : []), // A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner // scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input // field box (the caller mounts it via setPositionReference), not the bare input. ...(isCombobox ? [ size({ padding: 8, apply({ rects, elements }) { elements.floating.style.width = `${Math.max(rects.reference.width, 192)}px`; }, }), ] : []), hide({ padding: 8 }), ], }); // A stale active item would highlight (and steal the next open's initial focus) the second time a // menu opens; clear it whenever the surface closes. useEffect(() => { if (!opts.open) { // eslint-disable-next-line react-hooks/set-state-in-effect -- both indices are also floating-ui's list-navigation state (passed to useListNavigation below), so they cannot be derived from `open` without lying to the library about where the cursor is. Clearing on close is what stops a stale row stealing the next open's initial focus. setActiveIndex(null); setHoverIndex(null); } }, [opts.open]); // A combobox opens from the field's own focus/typing (wired by ), never a click-to-toggle — // a click on the input must place the caret, not close the just-opened list. const click = useClick(context, { enabled: !isHover && !isCombobox }); const hover = useHover(context, { enabled: isHover || !!opts.hoverAlso, move: false, // A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the // synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut). mouseOnly: !isHover || !!opts.pointerOnly, // A submenu wants a shorter intent delay than a hover card: the pointer is already inside a // menu, so the "did they mean it" question is mostly answered. delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }), handleClose: safePolygon(), }); const focus = useFocus(context, { enabled: isHover && !opts.pointerOnly }); // Escape for EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it // is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape // off entirely. (Leaving it on for hover made an open tooltip's document-level handler either // stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside- // press stays with floating-ui (tree-aware). const dismiss = useDismiss(context, { escapeKey: false, ancestorScroll: opts.dismissOnScroll ?? false, }); // `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input // → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav), // the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the // `listbox` aria-role internally.) So sets NO aria-* by hand. const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"), }); const listNav = useListNavigation(context, { enabled: roves, // Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an // `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the // input). A menu/listbox uses real roving focus instead. virtual: isCombobox, listRef: elementsRef, activeIndex, // Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from // a stale pointer-hover position. onNavigate: (i) => { setActiveIndex(i); setHoverIndex(null); }, selectedIndex: opts.selectedIndex ?? null, // Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads // each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a // pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27 // `isListIndexDisabled`.) No explicit index list needed. loop: true, // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, // which strands focus on the menu container — arrows then can't enter the roving list at all). A // combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first // option; auto-highlighting item 0 on open would fight the typed query. focusItemOnOpen: isCombobox ? false : true, // A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when // the pointer merely passes over it (the library default) — that blurs the input mid-typing and // mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex // instead (see PopoverItem), so the pointer still highlights without stealing the caret. focusItemOnHover: isCombobox ? false : !opts.hostsInput, }); const typeahead = useTypeahead(context, { enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, listRef: labelsRef, activeIndex, onMatch: (i) => { setActiveIndex(i); setHoverIndex(null); }, }); // PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's // list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's // channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a // menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has // already switched to keyboard modality — which is what makes it scroll the new row into view. A // nested menu's surface bubbles through its parent's in the React tree; the inner one handles the // key first and marks it, so the outer one stands down. const onPageKey = (e: React.KeyboardEvent) => { if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return; const rows = elementsRef.current; const live = rows.filter((el): el is HTMLElement => el != null); // No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning. if (!live.length) return; e.preventDefault(); const step = pageStep(scrollParent(live[0]!), live); const to = pageTarget( activeIndex ?? 0, e.key === "PageDown" ? step : -step, rows.length, (i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"), ); if (to === null) return; setActiveIndex(to); setHoverIndex(null); }; const pageKeyRef = useRef(onPageKey); pageKeyRef.current = onPageKey; const paging = useMemo(() => { const onKeyDown = (e: React.KeyboardEvent) => pageKeyRef.current(e); return { reference: { onKeyDown }, floating: { onKeyDown } }; }, []); const interactions = useInteractions([ click, hover, focus, dismiss, role, listNav, typeahead, paging, ]); // `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is // then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back. const referenceHidden = middlewareData.hide?.referenceHidden ?? false; // Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal // underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take // Escape before the surface beneath it — and, when it is a player's own overlay card, keep the // player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat // an Escape the user aimed at whatever is actually on screen. A hover surface is a PASSIVE layer: // it does not own the keyboard, so the page's own keys keep working under a resting pointer. useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover }); return { nodeId, mode, isNav, isCombobox, roves, open: opts.open, selectedIndex: opts.selectedIndex ?? null, refs, floatingStyles, context, isPositioned, referenceHidden, elementsRef, labelsRef, activeIndex, highlightIndex: hoverIndex ?? activeIndex, setHoverIndex, ...interactions, }; } /** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it * has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as * the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in * Chrome (E11 S2), a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it * 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that * microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element * to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */ export function closeThenAct(close: () => void, act: () => void): void { close(); setTimeout(act, 0); }