On an iPad a tap's synthetic mouseenter opened the Tooltip; WebKit then took the tap as a hover and swallowed its click, so every rail button needed two taps, and with no mouseleave from a finger the caption stayed up. Tooltip now opens for a mouse and a key's focus only (owner: no captions on touch).
324 lines
17 KiB
TypeScript
324 lines
17 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
useFloating,
|
|
autoUpdate,
|
|
offset as offsetMiddleware,
|
|
flip,
|
|
shift,
|
|
size,
|
|
hide,
|
|
useClick,
|
|
useHover,
|
|
useFocus,
|
|
useDismiss,
|
|
useRole,
|
|
useListNavigation,
|
|
useTypeahead,
|
|
useInteractions,
|
|
useFloatingNodeId,
|
|
safePolygon,
|
|
type Placement,
|
|
type UseRoleProps,
|
|
} from "@floating-ui/react";
|
|
import { useLayer } from "../../lib/layerStack";
|
|
import { pageStep, pageTarget, scrollParent } from "../../lib/listPaging";
|
|
|
|
// The non-component half of the popover platform: the `useAnchoredPopover` engine hook and the
|
|
// `closeThenAct` row helper. The surfaces it drives (PopoverSurface / PopoverItem) and the platform's
|
|
// design notes live in AnchoredPopover.tsx; the split keeps that file components-only (Fast Refresh).
|
|
|
|
// 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;
|
|
/** Menu mode only: ALSO open on mouse hover, keeping the click/tap toggle. The submenu idiom —
|
|
* a mouse expects a submenu to fall open under the pointer, a finger has no hover at all and
|
|
* must tap. `mouseOnly` is forced on so a tap's synthetic mouseenter can't open the panel a
|
|
* hair before the click handler toggles it shut again. */
|
|
hoverAlso?: boolean;
|
|
/** Hover mode only: a MOUSE affordance and nothing else — no focus-open, no touch. A media card's
|
|
* preview (ui/MediaPreview): a keyboard walk down a grid would otherwise pop a panel at every stop,
|
|
* and a tap is the card's play. */
|
|
pointerOnly?: boolean;
|
|
/** Hover mode only: never opened by a finger, still by a mouse and a key's focus. A caption over a
|
|
* control (ui/Tooltip): on an iPad the tap's synthetic mouseenter showed it, WebKit then took that
|
|
* first tap as a hover and swallowed its click (every rail button needed two taps), and with no
|
|
* mouseleave from a finger the caption stayed up. */
|
|
mouseHoverOnly?: 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;
|
|
/** Close when a scroll container under the panel scrolls. Off by default, because a panel anchored
|
|
* to a real trigger element simply MOVES with it. Turn it on when the anchor is a fixed point
|
|
* rather than an element — a right-click menu, which otherwise stays parked over whatever row
|
|
* scrolls under it while its actions still target the row you opened it on.
|
|
* This is also what a native context menu does. */
|
|
dismissOnScroll?: boolean;
|
|
/** Menu/listbox only: ↑/↓ on the CLOSED trigger open the panel (the APG menu-button default, on).
|
|
* Turn it off where those keys already mean something on that trigger — a media player's row,
|
|
* where ↑/↓ are the volume from anywhere, and a trigger that swallowed them killed it.
|
|
* Enter/Space/click still open it, and the arrows still rove once it is open. */
|
|
openOnArrowKey?: boolean;
|
|
}
|
|
|
|
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) {
|
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- both indices are also floating-ui's list-navigation state (passed to useListNavigation below), so they cannot be derived from `open` without lying to the library about where the cursor is. Clearing on close is what stops a stale row stealing the next open's initial focus.
|
|
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 || !!opts.hoverAlso,
|
|
move: false,
|
|
// A hover-card is a pointer affordance either way; a hover-ALSO menu must ignore touch, or the
|
|
// synthetic mouseenter a tap emits would race the click toggle (open, then immediately shut).
|
|
mouseOnly: !isHover || !!opts.pointerOnly || !!opts.mouseHoverOnly,
|
|
// A submenu wants a shorter intent delay than a hover card: the pointer is already inside a
|
|
// menu, so the "did they mean it" question is mostly answered.
|
|
delay: opts.hoverDelay ?? (isHover ? { open: 120, close: 140 } : { open: 90, close: 120 }),
|
|
handleClose: safePolygon(),
|
|
});
|
|
const focus = useFocus(context, { enabled: isHover && !opts.pointerOnly });
|
|
// 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,
|
|
ancestorScroll: opts.dismissOnScroll ?? 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,
|
|
openOnArrowKeyDown: opts.openOnArrowKey ?? true,
|
|
// 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);
|
|
},
|
|
});
|
|
|
|
// PageUp/PageDown: a page of rows at a time (lib/listPaging, shared with the palette). floating-ui's
|
|
// list navigation knows only the arrows and Home/End, and the arrows alone crawl through the sidebar's
|
|
// channel filter or a Plex studio list. On BOTH elements: a combobox's keys arrive on its input, a
|
|
// menu's on the focused row inside the surface. Last in the list, so floating-ui's own keydown has
|
|
// already switched to keyboard modality — which is what makes it scroll the new row into view. A
|
|
// nested menu's surface bubbles through its parent's in the React tree; the inner one handles the
|
|
// key first and marks it, so the outer one stands down.
|
|
const onPageKey = (e: React.KeyboardEvent) => {
|
|
if ((e.key !== "PageDown" && e.key !== "PageUp") || e.defaultPrevented) return;
|
|
const rows = elementsRef.current;
|
|
const live = rows.filter((el): el is HTMLElement => el != null);
|
|
// No mounted rows — a closed surface, a hover card, a dialog — and the key keeps its own meaning.
|
|
if (!live.length) return;
|
|
e.preventDefault();
|
|
const step = pageStep(scrollParent(live[0]!), live);
|
|
const to = pageTarget(
|
|
activeIndex ?? 0,
|
|
e.key === "PageDown" ? step : -step,
|
|
rows.length,
|
|
(i) => !!rows[i] && !rows[i]!.hasAttribute("disabled"),
|
|
);
|
|
if (to === null) return;
|
|
setActiveIndex(to);
|
|
setHoverIndex(null);
|
|
};
|
|
// The compiler memoizes this on what onPageKey reads (the active row), so the prop getters below
|
|
// change only when that does.
|
|
const paging = { reference: { onKeyDown: onPageKey }, floating: { onKeyDown: onPageKey } };
|
|
|
|
const interactions = useInteractions([
|
|
click,
|
|
hover,
|
|
focus,
|
|
dismiss,
|
|
role,
|
|
listNav,
|
|
typeahead,
|
|
paging,
|
|
]);
|
|
|
|
// `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. A hover surface is a PASSIVE layer:
|
|
// it does not own the keyboard, so the page's own keys keep working under a resting pointer.
|
|
useLayer(opts.open && !referenceHidden, () => opts.onOpenChange(false), { passive: isHover });
|
|
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** A menu row whose action opens a dialog or moves the focus: close the menu FIRST and act once it
|
|
* has handed the focus back. floating-ui returns the focus to the trigger from a microtask queued as
|
|
* the menu unmounts, so an action run in the same batch had its focus taken back by it: measured in
|
|
* Chrome, a confirm dialog opened from the rail's menu landed its focus on Cancel and lost it
|
|
* 7 ms later to the row BEHIND its scrim, where Enter activated the row. A fresh task runs after that
|
|
* microtask — and a dialog opened there captures the trigger, not a dying menu item, as the element
|
|
* to restore the focus to. Every such row goes through here (`ActionMenu`, "Manage views…"). */
|
|
export function closeThenAct(close: () => void, act: () => void): void {
|
|
close();
|
|
setTimeout(act, 0);
|
|
}
|