import { createContext, useContext, useId, useLayoutEffect, useState, type ButtonHTMLAttributes, type CSSProperties, type ReactNode, } from "react"; import { useListItem, FloatingNode, FloatingList, FloatingPortal, FloatingFocusManager, } from "@floating-ui/react"; import { useFullscreenPortalHost } from "../../lib/fullscreen"; import type { useAnchoredPopover } from "./anchoredPopoverHooks"; // 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; // PageUp/PageDown (a page, no loop) are ours, for every list surface incl. the combobox // - 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. // // The hook itself (`useAnchoredPopover`) and `closeThenAct` live in anchoredPopoverHooks.ts. // Internal: call sites use the inferred hook return, they don't name the type. type AnchoredPopover = ReturnType; // 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). // Exported as ROVING_HIGHLIGHT for a listbox that is not a PopoverItem (the command palette, the Plex // typeahead): the row carries `data-active` and takes this, so the keyboard row looks the same everywhere. export const ROVING_HIGHLIGHT = "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, elementRef, /** 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; /** Hand the item's DOM node to the caller, ON TOP of the list registration this component owns. * The submenu idiom needs it: the nested popover anchors to the very menuitem that opens it, and * aria-haspopup/aria-expanded belong on that button — not on a wrapper div around it. */ elementRef?: (node: HTMLButtonElement | null) => void; 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 }); // Both refs, in that order: the registry first (it owns roving focus), then the caller's. const setNode = (node: HTMLButtonElement | null) => { ref(node); elementRef?.(node); }; // 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. One persistent container div is re-parented (body ↔ fullscreen element) // rather than handed to the portal as a changing container, so the open panel's caret, scroll // offset and animation state survive the 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.) Focus does NOT survive on its own — `appendChild` on an // already-parented node is remove-then-insert, which blurs to ; the hook saves and restores // it. This used to be a second copy of Modal's re-parent that lacked exactly that (round 5). const container = useFullscreenPortalHost(); 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"]; // 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); // Where focus goes back on close. floating-ui returns it to the element focused before the open — // but when that element is not a Tab stop itself, to its first TABBABLE descendant instead, even // when handed the element as a ref (its return runs through `getFirstTabbableElement` either way). // A card the keyboard is on (lib/listNav gives its root tabindex=-1) is exactly that: measured in // Chrome (deep-review E11 round 2), j, Shift+F10, Escape left focus on the card's thumbnail link, the // ring off the card and the next Enter on a control. So for such an element the library's return is // off and this gives the focus back to it as it is — captured at open, before focus moves into the // panel, and only when the focus was still in a floating layer (or nowhere) as the panel closed: // focus the reader moved on to stays where they put it. A menu row's action runs a task later // (`closeThenAct`), so a dialog it opens still captures the card as the element to return to. // // Only for a focus-managed surface, whose return this replaces: a hover card or a combobox never // returned focus, and a hover card closing after the reader clicked a link in it must not pull the // focus back to the card under it (deep-review E11 round 3). const [returnTo] = useState(() => { const active = document.activeElement; return managed && active instanceof HTMLElement && active !== document.body && active.tabIndex < 0 ? active : null; }); useLayoutEffect(() => { if (!returnTo) return; return () => { // "Still in the layer" is read off the portal marker, not the panel: FloatingPortal renders the // panel a commit after this surface mounts, so the panel's ref is still empty when this effect // runs (deep-review E11 round 3). const active = document.activeElement; const stillOurs = !active || active === document.body || !!active.closest("[data-floating-ui-portal]"); queueMicrotask(() => { if (stillOurs && returnTo.isConnected) returnTo.focus({ preventScroll: true }); }); }; }, [returnTo]); const zClass = mode === "hover" ? "z-tooltip" : "z-popover"; 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 )} ); }