feat(popover): fullscreen-aware portal + migrate the hover surfaces (R8 S4a)

The platform's FloatingPortal targeted <body>, which is painted UNDERNEATH whatever
holds the fullscreen lock. That is why the player's in-bar menus were either
non-portaled (and clipped) or, for add-to-playlist / download, disabled outright in
fullscreen. PopoverSurface now portals into `document.fullscreenElement` while one
exists, via a new reactive `useFullscreenElement()` on the existing WebKit-aware
fullscreen lib.

Two floating-ui details this had to work around: a null `root` means "the container
does not exist yet" (no portal node is created at all), so the no-fullscreen case must
pass undefined; and the portal node is built once and ignores a later root change, so
the portal is keyed on the fullscreen state to remount on a transition.

Migrated the three hand-rolled hover engines onto the platform:
- Tooltip: kept as a passive caption via a new `passthrough` prop on PopoverSurface,
  so it cannot swallow a click aimed at a control it overlaps during the close delay.
  Gains aria-describedby wiring it never had.
- AboutCell: the 160 ms hide-timer hover bridge is now safePolygon; the close-on-page-
  scroll is now hide-and-follow, so the card reappears when the row scrolls back.
- TagManager: same bridge removal; the scroll-fade moves to an inner scroller so the
  mask rides the scrolling box rather than the glass panel.

Verified in-browser: tooltip flip + centering + pointer-transparency, About card flip-
above with the measured height, tag popover right-start anchoring, and the portal
round-trip body -> fullscreen element -> body on a live fullscreenchange.
This commit is contained in:
2026-07-30 23:49:29 +02:00
parent 7ed98956cc
commit 2756c21dbd
5 changed files with 137 additions and 227 deletions
+18 -2
View File
@@ -35,6 +35,7 @@ import {
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
@@ -271,6 +272,7 @@ export function PopoverSurface({
children,
focusManaged,
initialFocus,
passthrough,
}: {
popover: AnchoredPopover;
className?: string;
@@ -278,6 +280,10 @@ export function PopoverSurface({
children: ReactNode;
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,
@@ -295,6 +301,11 @@ export function PopoverSurface({
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>;
@@ -317,8 +328,9 @@ export function PopoverSurface({
// 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", pointerEvents: "none" }
? { 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
@@ -345,7 +357,11 @@ export function PopoverSurface({
return (
<FloatingNode id={nodeId}>
<FloatingPortal>
{/* `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}