import { createContext, useContext, useEffect, 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. // Internal to the platform; call sites pass the string literal via `mode`, they don't name the type. type PopoverMode = "menu" | "listbox" | "hover" | "dialog"; 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). */ 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; /** 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; } export type AnchoredPopover = ReturnType; export function useAnchoredPopover(opts: AnchoredPopoverOptions) { const mode = opts.mode ?? "menu"; const isHover = mode === "hover"; // 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"; // 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); 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 }) { elements.floating.style.maxHeight = `${availableHeight}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); }, [opts.open]); const click = useClick(context, { enabled: !isHover }); 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 an interactive (non-hover) popover is dispatched through the shared `layerStack` // (below) so it coordinates with Modals and players, not just other floating nodes — so turn // floating-ui's own Escape off there. Outside-press stays with floating-ui (tree-aware). Hover // tooltips keep floating-ui's Escape and are NOT layers (they mustn't make a player defer). const dismiss = useDismiss(context, { escapeKey: isHover }); const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") }); const listNav = useListNavigation(context, { enabled: isNav, listRef: elementsRef, activeIndex, onNavigate: setActiveIndex, selectedIndex: opts.selectedIndex ?? null, 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). focusItemOnOpen: true, }); const typeahead = useTypeahead(context, { enabled: isNav && (opts.typeahead ?? true), listRef: labelsRef, activeIndex, onMatch: setActiveIndex, }); const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]); // Register the open interactive popover as a dismissable layer (topmost-only Escape, and so a // player/modal underneath defers to it). Hover tooltips are excluded on purpose. useLayer(opts.open && !isHover, () => opts.onOpenChange(false)); return { nodeId, mode, isNav, open: opts.open, refs, floatingStyles, context, isPositioned, referenceHidden: middlewareData.hide?.referenceHidden ?? false, elementsRef, labelsRef, activeIndex, ...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; getItemProps: AnchoredPopover["getItemProps"]; } const PopoverListContext = createContext(null); type ItemRole = "menuitem" | "menuitemradio" | "option"; /** One keyboard-navigable option inside a menu/listbox `PopoverSurface`. Self-registers for roving * focus + typeahead and applies the correct ARIA. Use `menuitemradio`+`checked` for a toggle, * `option`+`selected` for a listbox choice, plain `menuitem` for an action. * * The active (roving) item carries `data-active` — style the highlight off THAT, 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. The recommended recipe is an accent tint — and note `bg-accent/NN` does NOT work here (the * theme's `--accent` is a hex var, and Tailwind v3 can't inject alpha into it), so use a color-mix * arbitrary value like the app's own glass idiom: * `data-[active]:bg-[color-mix(in_srgb,var(--accent)_24%,transparent)] data-[active]:text-fg`. */ export function PopoverItem({ children, onSelect, role = "menuitem", checked, selected, 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; disabled?: boolean; className?: string; label?: string; } & Omit, "onSelect" | "role" | "children" | "className">) { const ctx = useContext(PopoverListContext); const textLabel = label ?? (typeof children === "string" ? children : undefined); const { ref, index } = useListItem({ label: textLabel }); const active = ctx?.activeIndex === index; const ariaProps = role === "menuitemradio" ? { role, "aria-checked": !!checked } : role === "option" ? { role, "aria-selected": !!selected } : { role }; const handlers = ctx ? ctx.getItemProps({ onClick: () => !disabled && onSelect?.() }) : { onClick: () => !disabled && onSelect?.() }; return ( ); } /** 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). */ export function PopoverSurface({ popover, className, style, children, focusManaged, initialFocus, passthrough, ariaLabel, }: { 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; }) { const { context, refs, floatingStyles, getFloatingProps, getItemProps, nodeId, referenceHidden, isPositioned, open, mode, isNav, activeIndex, 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. Portal into the fullscreen // element instead while one exists — this is what let the player's in-bar menus stop being either // non-portaled (and clipped) or outright disabled in fullscreen. const fsRoot = useFullscreenElement(); const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as { style?: CSSProperties; } & Record; if (!open) return null; const zClass = mode === "hover" ? "z-tooltip" : "z-popover"; // Everything but a passive hover card manages focus by default (trap into the surface, return to // the trigger on close). const managed = focusManaged ?? mode !== "hover"; 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. */}
{children}
); // Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested // s can self-register and rove. const positioned = isNav ? ( {surface} ) : ( surface ); return ( {/* `root` must be undefined (NOT null) to mean "default to " — floating-ui reads a null root as "the container doesn't exist yet" and creates no portal node at all. And it only builds that node once, so a root that changes mid-open is ignored; the key remounts the portal on a fullscreen transition, which is exactly when the popover must move. */} {managed ? ( {positioned} ) : ( positioned )} ); }