Files
siftlode/frontend/src/components/AnchoredPopover.tsx
T
peter 280de2e592 feat(popover): migrate the click menus onto the platform (R8 S4b)
AddToPlaylist, LanguageSwitcher, the Plex DetailCustomizeMenu and ViewSwitcher each
had their own anchoring, dismissal and (for ViewSwitcher) keyboard engine. All four now
use the platform, so their items are PopoverItems with real menuitemradio/aria-checked,
roving focus, typeahead and flip/shift anchoring.

Two platform additions this needed:
- `selectedIndex`: opening a single-choice menu must land on the choice already in
  effect, which is what ViewSwitcher's hand-rolled engine did by seeding its roving
  index to the current option.
- `ariaLabel` on PopoverSurface: an icon-only trigger leaves the panel with no name.

Fixed while wiring `selectedIndex` up: it stranded focus on the menu container. That
path focuses its item synchronously in a layout effect, and FloatingFocusManager's own
initial focus then ran afterwards and took focus back; the no-selectedIndex path only
escaped this because it defers focus to a microtask while waiting for the item list to
populate. On a roving list the list navigation owns initial focus, so the manager is now
told to take none (initialFocus -1).

Behaviour changes worth naming:
- The header LanguageSwitcher no longer opens on hover. The platform focuses the first
  item on open, so a hover-open would yank focus off whatever the user was doing. Both
  variants are now click-only, like every other menu in the app.
- PrefToggle became a PopoverItem, so the rows callers inject through the menu's `extra`
  prop join the same roving list as the built-in ones.
- AddToPlaylist fetches its list on open, so on the first paint there are no items to
  focus and focus stays on the trigger (as it did before the migration); arrows move
  into the list once it lands.

Verified in-browser: ViewSwitcher opens on the checked mode, arrows rove, Escape closes
and refocuses the trigger; the ChannelTags pilot is unregressed; AddToPlaylist anchors
bottom-start without the click reaching the feed card underneath; the rail language menu
keeps its right-edge, bottom-aligned anchoring.
2026-07-31 00:02:14 +02:00

397 lines
16 KiB
TypeScript

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 <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> 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 <body>). Non-modal so the rest of the page stays interactive.
// Call sites render their options as <PopoverItem> — 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<typeof useAnchoredPopover>;
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 <FloatingTree> — the hook still works, just without nested-layer awareness.
const nodeId = useFloatingNodeId();
// Item registry for list navigation + typeahead, populated by <PopoverItem> via useListItem.
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(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 <PopoverItem>. 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<PopoverListContextValue | null>(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<ButtonHTMLAttributes<HTMLButtonElement>, "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 (
<button
ref={ref}
type="button"
// Roving tabindex: only the active item is in the Tab order; arrows move focus between them.
tabIndex={active ? 0 : -1}
// Highlight hook: reliable across click- vs key-open (see the note above), unlike :focus-visible.
data-active={active ? "true" : undefined}
disabled={disabled}
className={className}
{...ariaProps}
{...rest}
{...handlers}
>
{children}
</button>
);
}
/** 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<HTMLElement | null>;
/** 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 <body> 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<string, unknown>;
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 = (
<div
ref={refs.setFloating}
{...floatingProps}
aria-label={ariaLabel}
className={zClass}
style={{
...floatingPropStyle,
...floatingStyles,
// Hide until the first position is measured (else it flashes at 0,0 before the transform
// lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
...(!isPositioned || referenceHidden
? { visibility: "hidden" as const, pointerEvents: "none" as const }
: null),
...(passthrough ? { pointerEvents: "none" as const } : null),
}}
>
{/* 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. */}
<div className={className} style={style}>
{children}
</div>
</div>
);
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
</FloatingList>
</PopoverListContext.Provider>
) : (
surface
);
return (
<FloatingNode id={nodeId}>
{/* `root` must be undefined (NOT null) to mean "default to <body>" — 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. */}
<FloatingPortal key={fsRoot ? "fullscreen" : "body"} root={fsRoot ?? undefined}>
{managed ? (
<FloatingFocusManager
context={context}
modal={false}
// On a roving list, useListNavigation owns the initial focus — so tell the focus manager
// NOT to also focus the panel itself (-1). Without this, a `selectedIndex` menu ends up
// with focus stranded on the container: that path focuses its item SYNCHRONOUSLY in a
// layout effect, and the manager's own initial focus then runs after it and wins. The
// no-selectedIndex path only escapes the same fate because it defers its focus to a
// microtask/rAF while it waits for the item list to populate.
initialFocus={initialFocus ?? (isNav ? -1 : undefined)}
returnFocus
>
{positioned}
</FloatingFocusManager>
) : (
positioned
)}
</FloatingPortal>
</FloatingNode>
);
}