The palette row was ~1.07:1 on its glass (B3 names bg-card hovers a violation). ROVING_HIGHLIGHT is exported from AnchoredPopover for the listboxes that are not PopoverItems; a contract holds that no file paints the fill itself and every hand-built option row takes the recipe.
420 lines
22 KiB
TypeScript
420 lines
22 KiB
TypeScript
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 <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;
|
|
// 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 <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.
|
|
//
|
|
// 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 <PopoverItem role="option"> 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 <Combobox> 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<typeof useAnchoredPopover>;
|
|
|
|
// 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;
|
|
highlightIndex: number | null;
|
|
setHoverIndex: (index: number | null) => void;
|
|
getItemProps: AnchoredPopover["getItemProps"];
|
|
/** Combobox surface: items are navigated VIRTUALLY (aria-activedescendant), so a <PopoverItem> never
|
|
* takes DOM focus (roving tabindex off) and keeps the caret in the input on click. */
|
|
virtual: boolean;
|
|
}
|
|
const PopoverListContext = createContext<PopoverListContextValue | null>(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<ButtonHTMLAttributes<HTMLButtonElement>, "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<HTMLButtonElement>) => {
|
|
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<HTMLButtonElement>) => {
|
|
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<HTMLButtonElement>) => {
|
|
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 (
|
|
<button
|
|
ref={setNode}
|
|
type="button"
|
|
role={role}
|
|
{...handlers}
|
|
// A virtual option carries its own stable id (the aria-activedescendant target); it wins over any
|
|
// id Floating UI's getItemProps would inject on activation.
|
|
id={virtual ? generatedId : undefined}
|
|
// These win over anything in `handlers` — the roving tabindex must not be a caller override, the
|
|
// checked/selected truth must beat getItemProps' single-select guess, and disabled/className/
|
|
// data-active are the platform's to set. A virtual (combobox) item never holds DOM focus, so its
|
|
// tabindex is always -1; navigation is by aria-activedescendant.
|
|
aria-checked={ariaChecked}
|
|
aria-selected={ariaSelected}
|
|
tabIndex={virtual ? -1 : focusActive ? 0 : -1}
|
|
data-active={visualActive ? "true" : undefined}
|
|
disabled={disabled}
|
|
// touch-target: a menu row is a control primitive, 44 tall on a coarse pointer (A3).
|
|
className={`outline-hidden touch-target ${
|
|
checked || selected
|
|
? keepSelectedPaint
|
|
? HIGHLIGHT_SELECTED_RING_ONLY
|
|
: HIGHLIGHT_SELECTED
|
|
: ROVING_HIGHLIGHT
|
|
}${className ? ` ${className}` : ""}`}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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<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;
|
|
}
|
|
|
|
/** 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 <PopoverSurfaceInner {...props} />;
|
|
}
|
|
|
|
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 <body> 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 <body>; 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<string, unknown>;
|
|
// 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 = (
|
|
<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}>
|
|
{/* 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}
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// Menu/listbox AND combobox surfaces host the item registry (FloatingList) + active-state context so
|
|
// nested <PopoverItem>s can self-register and rove/aria-activedescendant.
|
|
const positioned = roves ? (
|
|
<PopoverListContext.Provider
|
|
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps, virtual: isCombobox }}
|
|
>
|
|
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
|
|
{surface}
|
|
</FloatingList>
|
|
</PopoverListContext.Provider>
|
|
) : (
|
|
surface
|
|
);
|
|
|
|
return (
|
|
<FloatingNode id={nodeId}>
|
|
<FloatingPortal root={container ?? undefined}>
|
|
{managed ? (
|
|
<FloatingFocusManager
|
|
context={context}
|
|
modal={false}
|
|
// Only when a `selectedIndex` is set does useListNavigation focus its item SYNCHRONOUSLY
|
|
// (in a layout effect) and race the focus manager's own initial focus — so ONLY then do we
|
|
// suppress the manager's initial focus with -1. For a plain nav menu we let the manager
|
|
// focus normally: it lands on the first tabbable child, which is the first roving item, or
|
|
// — in an item-less menu (e.g. a first-run tag editor with only a create input) — the
|
|
// input, so such a menu still has a keyboard entry point instead of stranding focus.
|
|
initialFocus={initialFocus ?? (isNav && selectedIndex != null ? -1 : undefined)}
|
|
returnFocus={!returnTo}
|
|
>
|
|
{positioned}
|
|
</FloatingFocusManager>
|
|
) : (
|
|
positioned
|
|
)}
|
|
</FloatingPortal>
|
|
</FloatingNode>
|
|
);
|
|
}
|