feat(popover): add the Floating UI anchored-popover platform + migrate TagsCell pilot (R8 S1)
Introduce useAnchoredPopover + PopoverSurface over @floating-ui/react — one primitive replacing the hand-rolled measured engines: flip() (two-way flip), shift() (width clamp), hide() (hide-and-follow when the trigger leaves its scroller), autoUpdate (scroll follow), FloatingPortal (escape ancestor stacking contexts). Supports menu + hover modes; focus management lands in S2. Wrap the app and the public watch page in <FloatingTree> so Escape / outside-press dispatch is topmost-only across nested layers (S3 folds in the Modal + players). Migrate the richest engine, TagsCell, as the pilot — ~140 lines of bespoke positioning (coords state, flip layout-effect, scroll rAF loop, useDismiss, Overlay) collapse into one hook call. Verified in-app: click opens, portals to <body>, positions below the trigger, role=menu + aria-expanded/haspopup, Escape and outside-click close, no console errors.
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { type CSSProperties, type ReactNode, type Ref } from "react";
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset as offsetMiddleware,
|
||||
flip,
|
||||
shift,
|
||||
size,
|
||||
hide,
|
||||
useClick,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
useMergeRefs,
|
||||
useFloatingNodeId,
|
||||
safePolygon,
|
||||
FloatingNode,
|
||||
FloatingPortal,
|
||||
FloatingFocusManager,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
|
||||
// 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).
|
||||
// Focus management (trap + return-to-trigger, roving nav) arrives in S2 via `focusManaged`.
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
const { refs, floatingStyles, context, middlewareData } = 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 }),
|
||||
],
|
||||
});
|
||||
|
||||
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 });
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
|
||||
|
||||
const interactions = useInteractions([click, hover, focus, dismiss, role]);
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
mode,
|
||||
open: opts.open,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
referenceHidden: middlewareData.hide?.referenceHidden ?? false,
|
||||
...interactions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
|
||||
* library's floating ref/props with the caller's className/style (and an optional extra ref, e.g. a
|
||||
* scroll-fade callback ref). `focusManaged` wraps it in a FloatingFocusManager (S2). */
|
||||
export function PopoverSurface({
|
||||
popover,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
floatingRef,
|
||||
focusManaged = false,
|
||||
initialFocus,
|
||||
}: {
|
||||
popover: AnchoredPopover;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
/** Extra ref merged onto the floating node (e.g. a useScrollFade callback ref). */
|
||||
floatingRef?: Ref<HTMLDivElement>;
|
||||
focusManaged?: boolean;
|
||||
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { context, refs, floatingStyles, getFloatingProps, nodeId, referenceHidden, open } =
|
||||
popover;
|
||||
const mergedRef = useMergeRefs([refs.setFloating, floatingRef ?? null]);
|
||||
const floatingProps = getFloatingProps();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
ref={mergedRef}
|
||||
{...floatingProps}
|
||||
className={className}
|
||||
style={{
|
||||
...(floatingProps.style as CSSProperties | undefined),
|
||||
...floatingStyles,
|
||||
// Trigger scrolled out of its clipping scroller → hide-and-follow rather than float over chrome.
|
||||
...(referenceHidden ? { visibility: "hidden", pointerEvents: "none" } : null),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<FloatingNode id={nodeId}>
|
||||
<FloatingPortal>
|
||||
{focusManaged ? (
|
||||
<FloatingFocusManager context={context} modal={false} initialFocus={initialFocus}>
|
||||
{surface}
|
||||
</FloatingFocusManager>
|
||||
) : (
|
||||
surface
|
||||
)}
|
||||
</FloatingPortal>
|
||||
</FloatingNode>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user