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
+39 -109
View File
@@ -1,28 +1,34 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { api } from "../lib/api"; import { api } from "../lib/api";
import ChannelAboutContent from "./ChannelAbout"; import ChannelAboutContent from "./ChannelAbout";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
const MARGIN = 8;
const POP_W = 480; // the overlay's fixed width (px)
/** /**
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About" * A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
* (the same content as the detail page's About tab — description, links, country, language, topics, * (the same content as the detail page's About tab — description, links, country, language, topics,
* keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing * keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the
* the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay * ["channel", id] react-query cache with the channel page, so the list stays light. The card is
* is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll * interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the
* (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables. * gap, replacing the hand-rolled 160 ms hide-timer bridge. Shared by the subscribed + discovery
* tables.
*
* The card keeps the platform's default hover role (`tooltip`); making this rich, interactive panel
* properly keyboard-reachable belongs to R9 (focus/a11y), not to the mechanical migration.
*/ */
export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) { export default function AboutCell({ text, channelId }: { text: string | null; channelId: string }) {
const preview = (text ?? "").trim(); const preview = (text ?? "").trim();
const ref = useRef<HTMLSpanElement>(null);
const popRef = useRef<HTMLDivElement>(null);
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [coords, setCoords] = useState<{ left: number; top: number } | null>(null);
const pop = useAnchoredPopover({
open: open && !!preview,
onOpenChange: setOpen,
mode: "hover",
offset: 6,
// Pop instantly like the old cell did; the close grace lets the cursor reach the card.
hoverDelay: { open: 0, close: 160 },
});
const { data: ch } = useQuery({ const { data: ch } = useQuery({
queryKey: qk.channel(channelId), queryKey: qk.channel(channelId),
@@ -31,105 +37,29 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
staleTime: 5 * 60_000, staleTime: 5 * 60_000,
}); });
function place() {
const el = ref.current;
if (!el) return;
const r = el.getBoundingClientRect();
setCoords({
left: Math.min(Math.max(r.left, MARGIN), window.innerWidth - POP_W - MARGIN),
top: r.bottom + 6,
});
}
function show() {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
place();
setOpen(true);
}
// Small grace period so the cursor can travel the gap from the cell to the overlay without it
// vanishing — the overlay's own mouseenter cancels it.
function scheduleHide() {
if (hideTimer.current) clearTimeout(hideTimer.current);
hideTimer.current = setTimeout(() => setOpen(false), 160);
}
function cancelHide() {
if (hideTimer.current) {
clearTimeout(hideTimer.current);
hideTimer.current = null;
}
}
useEffect(
() => () => {
if (hideTimer.current) clearTimeout(hideTimer.current);
},
[],
);
// Close on page scroll (the fixed overlay would strand at a stale anchor) or resize — but NOT
// when the scroll happens inside the overlay itself (it is scrollable).
useEffect(() => {
if (!open) return;
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
};
const onResize = () => setOpen(false);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
};
}, [open]);
// Flip above the anchor if the overlay would run off the bottom of the viewport.
useLayoutEffect(() => {
const pop = popRef.current;
const el = ref.current;
if (!open || !coords || !pop || !el) return;
const r = el.getBoundingClientRect();
const h = pop.offsetHeight;
if (r.bottom + 6 + h > window.innerHeight - MARGIN && r.top - 6 - h > MARGIN) {
const top = r.top - 6 - h;
if (Math.abs(top - coords.top) > 0.5) setCoords((c) => (c ? { ...c, top } : c));
}
}, [open, coords, ch]);
if (!preview) return <span className="text-muted">—</span>; if (!preview) return <span className="text-muted">—</span>;
return ( return (
<span <>
ref={ref} <span
onMouseEnter={show} ref={pop.refs.setReference}
onMouseLeave={scheduleHide} {...pop.getReferenceProps()}
onFocus={show} tabIndex={0}
onBlur={scheduleHide} className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
tabIndex={0} >
className="block max-w-[22rem] truncate text-muted cursor-help outline-none" {preview}
> </span>
{preview} <PopoverSurface
{open && popover={pop}
coords && className="glass w-[480px] max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
createPortal( >
<div {/* Full About once the detail loads; until then the list's truncated description. */}
ref={popRef} {ch ? (
onMouseEnter={cancelHide} <ChannelAboutContent ch={ch} />
onMouseLeave={scheduleHide} ) : (
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }} <p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
className="glass z-tooltip max-h-[28rem] overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
>
{/* Full About once the detail loads; until then the list's truncated description. */}
{ch ? (
<ChannelAboutContent ch={ch} />
) : (
<p className="text-sm whitespace-pre-wrap break-words leading-relaxed">{preview}</p>
)}
</div>,
document.body,
)} )}
</span> </PopoverSurface>
</>
); );
} }
+18 -2
View File
@@ -35,6 +35,7 @@ import {
type UseRoleProps, type UseRoleProps,
} from "@floating-ui/react"; } from "@floating-ui/react";
import { useLayer } from "../lib/layerStack"; import { useLayer } from "../lib/layerStack";
import { useFullscreenElement } from "../lib/fullscreen";
// R8 popover platform. ONE anchored-popover primitive over @floating-ui/react, replacing the ~9 // 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 // hand-rolled positioning engines (measured flip / width clamp / rAF scroll-follow / scroller-relative
@@ -271,6 +272,7 @@ export function PopoverSurface({
children, children,
focusManaged, focusManaged,
initialFocus, initialFocus,
passthrough,
}: { }: {
popover: AnchoredPopover; popover: AnchoredPopover;
className?: string; className?: string;
@@ -278,6 +280,10 @@ export function PopoverSurface({
children: ReactNode; children: ReactNode;
focusManaged?: boolean; focusManaged?: boolean;
initialFocus?: number | React.MutableRefObject<HTMLElement | null>; 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 { const {
context, context,
@@ -295,6 +301,11 @@ export function PopoverSurface({
elementsRef, elementsRef,
labelsRef, labelsRef,
} = popover; } = 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 { const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
style?: CSSProperties; style?: CSSProperties;
} & Record<string, unknown>; } & 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 // 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). // lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
...(!isPositioned || referenceHidden ...(!isPositioned || referenceHidden
? { visibility: "hidden", pointerEvents: "none" } ? { visibility: "hidden" as const, pointerEvents: "none" as const }
: null), : null),
...(passthrough ? { pointerEvents: "none" as const } : null),
}} }}
> >
{/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the {/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the
@@ -345,7 +357,11 @@ export function PopoverSurface({
return ( return (
<FloatingNode id={nodeId}> <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 ? ( {managed ? (
<FloatingFocusManager <FloatingFocusManager
context={context} context={context}
+38 -43
View File
@@ -1,5 +1,4 @@
import { useRef, useState } from "react"; import { useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
@@ -9,10 +8,12 @@ import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal"; import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider"; import { useConfirm } from "./ConfirmProvider";
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a // One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
// hover popover on the count listing the tagged channels (each a link that focuses it in the // hover popover on the count listing the tagged channels (each a link that focuses it in the
// Channel manager). The popover is portaled to <body> so the list's own scroll can't clip it. // Channel manager). The popover rides the R8 platform, so the list's own scroll can't clip it and
// the cursor can travel the gap into it (safePolygon) without the old hand-rolled 150 ms timer.
function TagRow({ function TagRow({
tag, tag,
channels, channels,
@@ -29,25 +30,20 @@ function TagRow({
const { t } = useTranslation(); const { t } = useTranslation();
const [name, setName] = useState(tag.name); const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const popFade = useScrollFade(); const popFade = useScrollFade();
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => { const commit = () => {
const v = name.trim(); const v = name.trim();
if (v && v !== tag.name) onRename(v); if (v && v !== tag.name) onRename(v);
else setName(tag.name); else setName(tag.name);
}; };
const openPop = () => { const pop = useAnchoredPopover({
window.clearTimeout(closeTimer.current); open: open && channels.length > 0,
if (channels.length === 0) return; onOpenChange: setOpen,
const r = countRef.current?.getBoundingClientRect(); mode: "hover",
if (r) setPos({ left: r.right + 8, top: r.top }); placement: "right-start",
setOpen(true); offset: 8,
}; hoverDelay: { open: 0, close: 150 },
const closeSoon = () => { });
closeTimer.current = window.setTimeout(() => setOpen(false), 150);
};
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
@@ -58,9 +54,8 @@ function TagRow({
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent" className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1.5 text-sm outline-none focus:border-accent"
/> />
<span <span
ref={countRef} ref={pop.refs.setReference}
onMouseEnter={openPop} {...pop.getReferenceProps()}
onMouseLeave={closeSoon}
className={`text-xs text-muted tabular-nums shrink-0 ${ className={`text-xs text-muted tabular-nums shrink-0 ${
channels.length channels.length
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2" ? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
@@ -77,30 +72,30 @@ function TagRow({
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
{open && <PopoverSurface
createPortal( popover={pop}
<div className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
ref={popFade.ref} >
onMouseEnter={openPop} <div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
onMouseLeave={closeSoon} {t("tagManager.onChannels")}
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }} </div>
className="z-popover glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]" {/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
> <div
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1"> ref={popFade.ref}
{t("tagManager.onChannels")} style={popFade.style}
</div> className="max-h-72 overflow-y-auto no-scrollbar"
{channels.map((ch) => ( >
<button {channels.map((ch) => (
key={ch.id} <button
onClick={() => onPickChannel(ch.title)} key={ch.id}
className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition" onClick={() => onPickChannel(ch.title)}
> className="w-full text-left text-sm px-2 py-1.5 rounded-md text-muted hover:text-fg hover:bg-card truncate transition"
{ch.title} >
</button> {ch.title}
))} </button>
</div>, ))}
document.body, </div>
)} </PopoverSurface>
</div> </div>
); );
} }
+27 -73
View File
@@ -1,16 +1,13 @@
import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom"; import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { hintsEnabled, subscribeHints } from "../lib/hints"; import { hintsEnabled, subscribeHints } from "../lib/hints";
type Side = "top" | "bottom"; type Side = "top" | "bottom";
type Coords = { left: number; top: number; placement: Side };
const MARGIN = 8;
const MAX_HALF = 120; // half of the tooltip's max-w-[240px] — the widest it can get
/** Wrap any element to show a short glass hint caption on hover — but only while the /** Wrap any element to show a short glass hint caption on hover — but only while the
* app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with * app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it
* fixed positioning so it is never clipped by an overflow/stacking ancestor. */ * is never clipped by an overflow/stacking ancestor, flips when the preferred side runs out of room
* and slides back inside the viewport near an edge. */
export default function Tooltip({ export default function Tooltip({
hint, hint,
side = "top", side = "top",
@@ -21,74 +18,31 @@ export default function Tooltip({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
const ref = useRef<HTMLSpanElement>(null); const [open, setOpen] = useState(false);
const tipRef = useRef<HTMLDivElement>(null); const pop = useAnchoredPopover({
const [coords, setCoords] = useState<Coords | null>(null); open: open && enabled && !!hint,
onOpenChange: setOpen,
function show() { mode: "hover",
const el = ref.current; placement: side,
if (!el) return; offset: 8,
const r = el.getBoundingClientRect(); });
// Prefer the requested side; flip to bottom if there's no room above.
const placement: Side = side === "bottom" || r.top < 90 ? "bottom" : "top";
const center = r.left + r.width / 2;
// Clamp using the MAX half-width so the caption can never overflow the viewport, even on the
// first paint (before we know its real width). A left-edge anchor near x=0 would otherwise push
// the centered box off-screen. The layout effect below refines this to the actual width.
setCoords({
left: Math.min(Math.max(center, MAX_HALF + MARGIN), window.innerWidth - MAX_HALF - MARGIN),
top: placement === "top" ? r.top - 8 : r.bottom + 8,
placement,
});
}
function hide() {
setCoords(null);
}
// Once rendered, re-center on the anchor using the caption's ACTUAL width (a short hint doesn't
// need the full 240px reservation), still clamped inside the viewport.
useLayoutEffect(() => {
const tip = tipRef.current;
const el = ref.current;
if (!coords || !tip || !el) return;
const r = el.getBoundingClientRect();
const half = tip.offsetWidth / 2;
const left = Math.min(
Math.max(r.left + r.width / 2, half + MARGIN),
window.innerWidth - half - MARGIN,
);
if (Math.abs(left - coords.left) > 0.5) setCoords((c) => (c ? { ...c, left } : c));
}, [coords]);
if (!enabled || !hint) return <>{children}</>; if (!enabled || !hint) return <>{children}</>;
return ( return (
<span <>
ref={ref} <span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
onMouseEnter={show} {children}
onMouseLeave={hide} </span>
onFocus={show} {/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
onBlur={hide} overlaps while the hover-close delay runs out. */}
className="inline-flex" <PopoverSurface
> popover={pop}
{children} passthrough
{coords && className="glass w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
createPortal( >
<div {hint}
ref={tipRef} </PopoverSurface>
role="tooltip" </>
style={{
position: "fixed",
left: coords.left,
top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}}
className="glass pointer-events-none z-tooltip w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
>
{hint}
</div>,
document.body,
)}
</span>
); );
} }
+15
View File
@@ -1,3 +1,5 @@
import { useSyncExternalStore } from "react";
// The browser's Fullscreen API, WebKit included. // The browser's Fullscreen API, WebKit included.
// //
// Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no // Safari and iPadOS still ship only the prefixed spelling: no `fullscreenchange`, no
@@ -47,3 +49,16 @@ export function onFullscreenChange(handler: () => void): () => void {
document.removeEventListener("webkitfullscreenchange", handler); document.removeEventListener("webkitfullscreenchange", handler);
}; };
} }
// `fullscreenElement()` returns the live DOM node, so the same element is the same reference across
// calls — a valid useSyncExternalStore snapshot without any caching of our own.
const fsSnapshot = () => fullscreenElement() as HTMLElement | null;
const fsServerSnapshot = () => null;
/** The fullscreen element as reactive state, for code that must RENDER differently while something
* holds the lock — chiefly the popover platform, which has to portal INTO that element (a node
* portaled to <body> is painted underneath the fullscreen element, i.e. invisible). Components that
* merely react to the transition should keep using `onFullscreenChange` directly. */
export function useFullscreenElement(): HTMLElement | null {
return useSyncExternalStore(onFullscreenChange, fsSnapshot, fsServerSnapshot);
}