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.
397 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|