import { createContext, useContext, useEffect, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode, } from "react"; import { useFloating, autoUpdate, offset as offsetMiddleware, flip, shift, size, hide, useClick, useHover, useFocus, useDismiss, useRole, useListNavigation, useTypeahead, useInteractions, useFloatingNodeId, useListItem, safePolygon, FloatingNode, FloatingList, FloatingPortal, FloatingFocusManager, type Placement, type UseRoleProps, } from "@floating-ui/react"; import { useLayer } from "../lib/layerStack"; import { useFullscreenElement } from "../lib/fullscreen"; // R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9 // hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative // visibility) that had each re-derived the same maths. Floating UI's middleware does all four: // - flip() → measured two-way flip (top/bottom) when a side runs out of room // - shift() → slides back into view horizontally (the old MEASURED-width clamp) // - hide() → sets referenceHidden when the trigger is scrolled out of its clipping scroller, so we // hide-and-follow instead of the old "close on leave scroller" (auto-reappears on scroll back) // - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library // Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the // house did for TagsCell). Every popover is a node in the app's so Escape / // outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too). // // S2 (this layer) adds the keyboard/ARIA model the roles promise, mirroring `ViewSwitcher` (the house's // textbook menu) but via Floating UI's idioms rather than a second hand-rolled roving engine: // - useListNavigation → roving focus over the registered items (↑/↓/Home/End, loop), menu/listbox only // - useTypeahead → type-to-focus a matching item by its label // - FloatingFocusManager → focus moves into the surface on open and RETURNS to the trigger on close // (a body-portaled popover sits at the end of the document; without this, Tab can't reach it and // Escape strands focus on ). Non-modal so the rest of the page stays interactive. // Call sites render their options as — it self-registers (useListItem) and carries the // right role (menuitem / menuitemradio+aria-checked / option+aria-selected). A menu that also holds a // free-text input (the tag creator) must stopPropagation non-Escape keys on that input so ↑/↓ and // typeahead act on the text, not the list — see ChannelTags. // // S? adds a "combobox" mode — the ONE right way to build a typeable combobox (a search/filter input // with a listbox of options below), so a hand-rolled floating-ui engine (the old MultiSelectChips / // PlexSearchBox pattern) is no longer needed. It differs from menu/listbox in exactly the focus model: // - the trigger IS a text input, so open is driven by the caller (focus/typing), NOT useClick; // - focus STAYS on the input — the surface is NOT focus-trapped and list items are never DOM-focused; // arrow-nav is VIRTUAL (useListNavigation `virtual:true` → the ↑/↓/Home/End handler rides on the // input's getReferenceProps and moves an `aria-activedescendant` pointer, not real focus). // Because `role:"combobox"` is passed to useRole, Floating UI wires the whole ARIA tree for free: the // reference input gets role=combobox + aria-expanded/controls/autocomplete/activedescendant, the surface // gets role=listbox, and each gets its option id (so activedescendant has a // target). The caller mounts the INPUT as `refs.setReference` (makes it a "typeable combobox reference") // and the field box as `refs.setPositionReference` (so the panel anchors under the whole chips+input // field). The universal component wraps all of this — call sites use it, not this mode raw. // 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; /** 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; } // Internal: call sites use the inferred hook return, they don't name the type. type AnchoredPopover = ReturnType; 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) { 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, move: false, delay: opts.hoverDelay ?? { open: 120, close: 140 }, handleClose: safePolygon(), }); const focus = useFocus(context, { enabled: isHover }); // 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 }); // `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); }, }); const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]); // `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. useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false)); 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, }; } // Carries the active index + item-props getter down to . FloatingList (in PopoverSurface) // separately owns the elementsRef/labelsRef registration; this context is the active-state channel. interface PopoverListContextValue { activeIndex: number | null; highlightIndex: number | null; setHoverIndex: (index: number | null) => void; getItemProps: AnchoredPopover["getItemProps"]; /** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a never * takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */ virtual: boolean; } const PopoverListContext = createContext(null); type ItemRole = "menuitem" | "menuitemradio" | "menuitemcheckbox" | "option"; /** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving * focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` when the items are * one exclusive choice, `menuitemcheckbox`+`checked` when several can be on at once, * `option`+`selected` for a listbox choice, plain `menuitem` for an action. * * The active (roving) item carries `data-active`, and the highlight is applied HERE for every item * (see ACTIVE_HIGHLIGHT) — consumers don't add it. It's driven off `data-active`, not `:focus-visible`: * a click-opened menu focuses the first item programmatically, which does NOT reliably trip the * focus-visible heuristic, so a `:focus-visible` highlight can stay invisible until the first arrow * key. (Since the Tailwind v4 migration `bg-accent/NN` works — v4 does the opacity modifier via * color-mix — so the recipe no longer needs a hand-written color-mix arbitrary value.) */ // The roving-focus highlight. A hovered/roving row gets the SAME strong accent fill whether or not it // is selected — a checked row no longer reads as a weak ring-only afterthought (R10 weak-ring fix). The // selected state stays legible ON the fill because these menus mark "selected" with a CHILD element (a // checkmark / checkbox) that paints over the row tint; the selected row just gets a stronger ring // (ring-2, accent/80) than an unselected one (ring-1, accent/60) to keep the two apart. // EXCEPTION: a menu whose SELECTED item paints its OWN row background (a solid bg-accent — the // table-filter options) passes `keepSelectedPaint`, so the highlight yields to ring-only there; a fill // would downgrade that solid paint to a 35% tint (the R9 concern, now scoped to just that idiom). const HIGHLIGHT_UNSELECTED = "data-active:bg-accent/35 data-active:text-fg data-active:ring-1 data-active:ring-inset data-active:ring-accent/60"; const HIGHLIGHT_SELECTED = "data-active:bg-accent/35 data-active:text-fg data-active:ring-2 data-active:ring-inset data-active:ring-accent/80"; const HIGHLIGHT_SELECTED_RING_ONLY = "data-active:ring-2 data-active:ring-inset data-active:ring-accent/70"; export function PopoverItem({ children, onSelect, role = "menuitem", checked, selected, keepSelectedPaint, disabled, className, /** Typeahead match string; defaults to the text child. Set explicitly when children aren't plain text. */ label, ...rest }: { children: ReactNode; onSelect?: () => void; role?: ItemRole; checked?: boolean; selected?: boolean; /** Set when the SELECTED item paints its OWN row background (a solid bg-accent, not a child checkmark): * the active highlight then yields to ring-only so the fill doesn't downgrade that solid paint. */ keepSelectedPaint?: boolean; disabled?: boolean; className?: string; label?: string; } & Omit, "onSelect" | "role" | "children" | "className">) { const ctx = useContext(PopoverListContext); const virtual = ctx?.virtual ?? false; // A virtual (combobox) option needs a STABLE DOM id from first render: the input's aria-activedescendant // is set to the active option's `.id` when the arrow handler reads it, so the id must already be there // (Floating UI's own on-activation id assignment races the re-render and lands empty through our // indirection). A menu/listbox item uses roving DOM focus instead and needs no id. const generatedId = useId(); const textLabel = label ?? (typeof children === "string" ? children : undefined); const { ref, index } = useListItem({ label: textLabel }); // Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight // follows the pointer when hovering, else the keyboard (highlightIndex = hoverIndex ?? activeIndex). const focusActive = ctx?.activeIndex === index; const visualActive = ctx?.highlightIndex === index; // The checked/selected state is rendered as an EXPLICIT attribute after {...handlers} below (so it // wins): for a combobox `role:"combobox"` makes getItemProps emit its own `aria-selected` off the // single selectedIndex, which is always false for a MULTI-select — our per-value truth must override. const ariaChecked = role === "menuitemradio" || role === "menuitemcheckbox" ? !!checked : undefined; const ariaSelected = role === "option" ? !!selected : undefined; // Route the caller's own props (onClick, onFocus, …) THROUGH floating-ui's item-prop getter, which // composes handlers rather than dropping them — spreading `{...rest}` separately would let the // getter's own onClick/onFocus/onMouseMove silently clobber the caller's. `onClick` here runs the // caller's first (e.g. a stopPropagation), then the select. const mergedClick = (e: React.MouseEvent) => { rest.onClick?.(e); if (!disabled) onSelect?.(); }; // Move the single visual highlight to the hovered item via hoverIndex — NOT floating-ui's activeIndex, // which would drag DOM focus onto the item and blur a menu's free-text input (the create-tag field) // mid-typing. This is what makes one highlight follow the pointer while the input keeps its caret. // (The guard keeps it a no-op when the pointer hasn't crossed into a new row.) const mergedMouseMove = (e: React.MouseEvent) => { rest.onMouseMove?.(e); if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index); }; // In a combobox, clicking an option must NOT move DOM focus off the input (that would close the list // and drop the caret) — preventDefault the mousedown so focus stays put; the onClick still selects. const mergedMouseDown = (e: React.MouseEvent) => { rest.onMouseDown?.(e); if (virtual) e.preventDefault(); }; const handlers = ctx ? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove, onMouseDown: mergedMouseDown, }) : { ...rest, onClick: mergedClick, onMouseDown: mergedMouseDown }; return ( ); } interface PopoverSurfaceProps { popover: AnchoredPopover; className?: string; style?: CSSProperties; children: ReactNode; /** Accessible name for the panel itself — a menu whose trigger is icon-only has nothing else to * take its name from. */ ariaLabel?: string; focusManaged?: boolean; initialFocus?: number | React.MutableRefObject; /** The surface never receives pointer events — for a passive caption that must not swallow a click * aimed at whatever it happens to cover during its close delay. Interactive hover cards (scrollable * content, links) must NOT set this. */ passthrough?: boolean; } /** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the * library's floating ref/props with the caller's className/style. Menu/listbox surfaces trap+return * focus and host the list-navigation registry by default; pass `focusManaged={false}` to opt a * surface out (e.g. a hover card). * * A thin wrapper so the closed case runs NO hooks: a mounted-but-closed popover (there can be ~10 * per table row) must not subscribe fullscreen listeners or build a portal container. */ export function PopoverSurface(props: PopoverSurfaceProps) { if (!props.popover.open) return null; return ; } function PopoverSurfaceInner({ popover, className, style, children, focusManaged, initialFocus, passthrough, ariaLabel, }: PopoverSurfaceProps) { const { context, refs, floatingStyles, getFloatingProps, getItemProps, nodeId, referenceHidden, isPositioned, mode, isNav, isCombobox, roves, selectedIndex, activeIndex, highlightIndex, setHoverIndex, elementsRef, labelsRef, } = popover; // A node portaled to is painted UNDERNEATH whatever holds the fullscreen lock, so a popover // opened from a player's control bar in fullscreen would be invisible; it must portal INTO the // fullscreen element. We own one persistent container div and re-parent it (body ↔ fullscreen // element) with appendChild, which MOVES the node atomically — no detach, so the open panel's // focus, caret, scroll offset and animation state survive a fullscreen transition, and an element // SWAP (A→B without exiting) is handled by the element-identity dependency. (Keying the portal to // remount instead would rebuild the subtree and lose all of that.) const fsRoot = useFullscreenElement(); const [container] = useState(() => typeof document === "undefined" ? null : document.createElement("div"), ); useLayoutEffect(() => { if (container) (fsRoot ?? document.body).appendChild(container); }, [container, fsRoot]); useEffect(() => () => container?.remove(), [container]); const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as { style?: CSSProperties; } & Record; // useRole stamps `aria-labelledby` (the trigger) on a menu-role panel, and it WINS over aria-label // in the accessible-name computation — so an explicit ariaLabel is dead unless we drop the ref. if (ariaLabel) delete floatingProps["aria-labelledby"]; const zClass = mode === "hover" ? "z-tooltip" : "z-popover"; // A menu/listbox/dialog traps focus into the surface (and returns it on close). A hover card is // passive, and a COMBOBOX must leave focus in the input (its list is navigated virtually) — neither // is focus-managed. const managed = focusManaged ?? (mode !== "hover" && !isCombobox); const surface = (
{/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the positioned element is essential: the popIn keyframe animates `transform`, which would otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0) for the animation's duration — the "flash in the top-left corner" bug. */}
{/* Intentionally NO onMouseLeave hover-reset: when the pointer leaves, the last-hovered row stays highlighted (feels natural, and a menu with a free-text input like ChannelTags — where the field swallows arrow keys, so there is no list-nav — has nothing to reclaim it otherwise, so it used to snap back to the first item). Keyboard nav / typeahead take the highlight back by nulling hoverIndex in onNavigate/onMatch above. */} {children}
); // Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so // nested s can self-register and rove/aria-activedescendant. const positioned = roves ? ( {surface} ) : ( surface ); return ( {managed ? ( {positioned} ) : ( positioned )} ); }