refactor(e10): move the platform components into components/ui/ and enforce the boundary
The app HAS a platform layer -- AnchoredPopover (22 importers, with arrows/typeahead/Escape/anchor-flip), QueryState (21), Modal (17), PageShell, DataTable, Tabs, Tooltip and ~26 more -- but it sat flat among ~160 feature components with nothing marking it as one. Several of those files describe themselves as 'the one place' or 'canonical' and were still bypassed by hand-rolled markup, because a layer nobody can see is a layer nobody uses; three separate inventory passes of this codebase misread it the same way. Moves the 33 domain-free modules into components/ui/, rewrites the imports in 89 files, and makes the boundary a lint rule rather than a convention: inside ui/ a one-level-up import (a feature component) and any lib/api slice are errors. The rule was verified to fire on both and to leave generic lib/ helpers alone. README.md states which side of the line a component belongs on and why. No behaviour change: 3x tsc, eslint, knip, prettier and 687 vitest tests all green; no backend file touched.
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog" | "combobox";
|
||||
|
||||
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);
|
||||
* "combobox" is a typeable input + listbox where focus stays in the field (use via <Combobox>). */
|
||||
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;
|
||||
/** This menu hosts a free-text input (an inline "create" field). Turns off BOTH typeahead AND
|
||||
* focus-item-on-hover, so neither typed characters nor a pointer passing over a list item steals
|
||||
* focus/keystrokes from the field. Set it instead of `typeahead:false` for such menus. */
|
||||
hostsInput?: 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;
|
||||
}
|
||||
|
||||
// Internal: call sites use the inferred hook return, they don't name the type.
|
||||
type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
const isCombobox = mode === "combobox";
|
||||
// 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";
|
||||
// Surfaces that host a navigable item list (registry + list-navigation): the roving menu/listbox AND
|
||||
// the virtual-nav combobox. The combobox navigates WITHOUT moving DOM focus (see below), so it is kept
|
||||
// out of `isNav` (which also gates the focus trap + typeahead + roving tabindex) and folded in here.
|
||||
const roves = isNav || isCombobox;
|
||||
// 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);
|
||||
// Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation
|
||||
// syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input
|
||||
// (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never
|
||||
// follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that
|
||||
// tracks the pointer when hovering and the keyboard otherwise.
|
||||
const [hoverIndex, setHoverIndex] = 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 }) {
|
||||
// Cap the positioned element, AND publish the available space as a custom property so
|
||||
// the INNER styled/scroll wrapper (PopoverSurface renders `className` on a child, not on
|
||||
// the floating element) can cap itself too — e.g. `maxHeight: min(28rem, var(--fit-max-h))`
|
||||
// keeps a caller's design cap while still shrinking to fit at high zoom / short viewports.
|
||||
// Capping only the outer element would leave the inner overflow-y-auto box at its fixed
|
||||
// height, spilling its top off-screen (the whole point of fitHeight).
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
elements.floating.style.setProperty("--fit-max-h", `${availableHeight}px`);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// A combobox panel matches the field's width (with a sane floor for a very narrow rail); its inner
|
||||
// scroll box owns the height cap. `rects.reference` is the POSITION reference — the whole chips+input
|
||||
// field box (the caller mounts it via setPositionReference), not the bare input.
|
||||
...(isCombobox
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ rects, elements }) {
|
||||
elements.floating.style.width = `${Math.max(rects.reference.width, 192)}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);
|
||||
setHoverIndex(null);
|
||||
}
|
||||
}, [opts.open]);
|
||||
|
||||
// A combobox opens from the field's own focus/typing (wired by <Combobox>), never a click-to-toggle —
|
||||
// a click on the input must place the caret, not close the just-opened list.
|
||||
const click = useClick(context, { enabled: !isHover && !isCombobox });
|
||||
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 EVERY mode (hover included) is dispatched through the shared `layerStack` below, so it
|
||||
// is strictly topmost-only across popovers, Modals and players — so turn floating-ui's own Escape
|
||||
// off entirely. (Leaving it on for hover made an open tooltip's document-level handler either
|
||||
// stopPropagation the Escape away from a Modal/player underneath, or double-close both.) Outside-
|
||||
// press stays with floating-ui (tree-aware).
|
||||
const dismiss = useDismiss(context, { escapeKey: false });
|
||||
// `role:"combobox"` makes Floating UI wire the whole typeable-combobox ARIA tree: the reference input
|
||||
// → role=combobox + aria-expanded/controls/autocomplete + aria-activedescendant (from the virtual nav),
|
||||
// the surface → role=listbox, the items → role=option + their option ids. (`combobox` maps to the
|
||||
// `listbox` aria-role internally.) So <Combobox> sets NO aria-* by hand.
|
||||
const role = useRole(context, {
|
||||
role: opts.role ?? (isHover ? "tooltip" : isCombobox ? "combobox" : "menu"),
|
||||
});
|
||||
const listNav = useListNavigation(context, {
|
||||
enabled: roves,
|
||||
// Virtual navigation for a combobox: DOM focus stays on the text input while ↑/↓ move an
|
||||
// `aria-activedescendant` pointer over the options (the handler rides on getReferenceProps → the
|
||||
// input). A menu/listbox uses real roving focus instead.
|
||||
virtual: isCombobox,
|
||||
listRef: elementsRef,
|
||||
activeIndex,
|
||||
// Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from
|
||||
// a stale pointer-hover position.
|
||||
onNavigate: (i) => {
|
||||
setActiveIndex(i);
|
||||
setHoverIndex(null);
|
||||
},
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
// Disabled items are skipped automatically: with no `disabledIndices`, useListNavigation reads
|
||||
// each item's DOM `disabled`/`aria-disabled` (PopoverItem always renders native `disabled`), so a
|
||||
// pending/inert item is never a roving target. (Verified against @floating-ui/react 0.27
|
||||
// `isListIndexDisabled`.) No explicit index list needed.
|
||||
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). A
|
||||
// combobox opens with NO active item — the caret is in the input, and the first ↓ lands on the first
|
||||
// option; auto-highlighting item 0 on open would fight the typed query.
|
||||
focusItemOnOpen: isCombobox ? false : true,
|
||||
// A menu that hosts a free-text input, and every combobox, must NOT pull focus onto a list item when
|
||||
// the pointer merely passes over it (the library default) — that blurs the input mid-typing and
|
||||
// mis-routes the next Enter to the hovered item. The visual hover highlight is driven off hoverIndex
|
||||
// instead (see PopoverItem), so the pointer still highlights without stealing the caret.
|
||||
focusItemOnHover: isCombobox ? false : !opts.hostsInput,
|
||||
});
|
||||
const typeahead = useTypeahead(context, {
|
||||
enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput,
|
||||
listRef: labelsRef,
|
||||
activeIndex,
|
||||
onMatch: (i) => {
|
||||
setActiveIndex(i);
|
||||
setHoverIndex(null);
|
||||
},
|
||||
});
|
||||
|
||||
const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]);
|
||||
|
||||
// `hide()` flips this true once the trigger is scrolled out of its clipping scroller; the surface is
|
||||
// then painted invisible (PopoverSurface) but stays open so it can reappear on scroll-back.
|
||||
const referenceHidden = middlewareData.hide?.referenceHidden ?? false;
|
||||
|
||||
// Register the open popover as a dismissable layer so Escape is topmost-only and a player/modal
|
||||
// underneath defers to it. Hover popovers register too (an open tooltip/hover-card should take
|
||||
// Escape before the surface beneath it — and, when it is a player's own overlay card, keep the
|
||||
// player alive). An invisible (referenceHidden) surface is NOT a layer: it must not silently eat
|
||||
// an Escape the user aimed at whatever is actually on screen.
|
||||
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false));
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
mode,
|
||||
isNav,
|
||||
isCombobox,
|
||||
roves,
|
||||
open: opts.open,
|
||||
selectedIndex: opts.selectedIndex ?? null,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
isPositioned,
|
||||
referenceHidden,
|
||||
elementsRef,
|
||||
labelsRef,
|
||||
activeIndex,
|
||||
highlightIndex: hoverIndex ?? activeIndex,
|
||||
setHoverIndex,
|
||||
...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;
|
||||
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).
|
||||
const HIGHLIGHT_UNSELECTED =
|
||||
"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,
|
||||
/** 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;
|
||||
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 });
|
||||
// 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={ref}
|
||||
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}
|
||||
className={`outline-hidden ${
|
||||
checked || selected
|
||||
? keepSelectedPaint
|
||||
? HIGHLIGHT_SELECTED_RING_ONLY
|
||||
: HIGHLIGHT_SELECTED
|
||||
: HIGHLIGHT_UNSELECTED
|
||||
}${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. We own one persistent container div and re-parent it (body ↔ fullscreen
|
||||
// element) with appendChild, which MOVES the node atomically — no detach, so the open panel's
|
||||
// focus, caret, scroll offset and animation state survive a fullscreen 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.)
|
||||
const fsRoot = useFullscreenElement();
|
||||
const [container] = useState<HTMLDivElement | null>(() =>
|
||||
typeof document === "undefined" ? null : document.createElement("div"),
|
||||
);
|
||||
useLayoutEffect(() => {
|
||||
if (container) (fsRoot ?? document.body).appendChild(container);
|
||||
}, [container, fsRoot]);
|
||||
useEffect(() => () => container?.remove(), [container]);
|
||||
|
||||
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"];
|
||||
|
||||
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||
// 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);
|
||||
|
||||
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
|
||||
>
|
||||
{positioned}
|
||||
</FloatingFocusManager>
|
||||
) : (
|
||||
positioned
|
||||
)}
|
||||
</FloatingPortal>
|
||||
</FloatingNode>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user