From 2756c21dbd698096c752e351e937107df79aaf1b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 30 Jul 2026 23:49:29 +0200 Subject: [PATCH] feat(popover): fullscreen-aware portal + migrate the hover surfaces (R8 S4a) The platform's FloatingPortal targeted , 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. --- frontend/src/components/AboutCell.tsx | 148 ++++++-------------- frontend/src/components/AnchoredPopover.tsx | 20 ++- frontend/src/components/TagManager.tsx | 81 +++++------ frontend/src/components/Tooltip.tsx | 100 ++++--------- frontend/src/lib/fullscreen.ts | 15 ++ 5 files changed, 137 insertions(+), 227 deletions(-) diff --git a/frontend/src/components/AboutCell.tsx b/frontend/src/components/AboutCell.tsx index e8891785..68ca37ea 100644 --- a/frontend/src/components/AboutCell.tsx +++ b/frontend/src/components/AboutCell.tsx @@ -1,28 +1,34 @@ -import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { createPortal } from "react-dom"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api } from "../lib/api"; import ChannelAboutContent from "./ChannelAbout"; - -const MARGIN = 8; -const POP_W = 480; // the overlay's fixed width (px) +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; /** * 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, - * keywords) in a portalled glass overlay. The detail is fetched ON DEMAND on hover/focus, sharing - * the ["channel", id] react-query cache with the channel page, so the list stays light. The overlay - * is interactive (scrollable, links clickable) via a small hover-bridge; it closes on page scroll - * (but not when scrolling inside itself) or resize. Shared by the subscribed + discovery tables. + * keywords) in a glass hover card. The detail is fetched ON DEMAND on hover/focus, sharing the + * ["channel", id] react-query cache with the channel page, so the list stays light. The card is + * interactive (scrollable, links clickable) — the platform's safePolygon lets the cursor travel the + * 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 }) { const preview = (text ?? "").trim(); - const ref = useRef(null); - const popRef = useRef(null); - const hideTimer = useRef | null>(null); 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({ queryKey: qk.channel(channelId), @@ -31,105 +37,29 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch 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 —; return ( - - {preview} - {open && - coords && - createPortal( -
- {/* Full About once the detail loads; until then the list's truncated description. */} - {ch ? ( - - ) : ( -

{preview}

- )} -
, - document.body, + <> + + {preview} + + + {/* Full About once the detail loads; until then the list's truncated description. */} + {ch ? ( + + ) : ( +

{preview}

)} -
+ + ); } diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx index bd4255f1..d0ca9d77 100644 --- a/frontend/src/components/AnchoredPopover.tsx +++ b/frontend/src/components/AnchoredPopover.tsx @@ -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; + /** 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 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; @@ -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 ( - + {/* `root` must be undefined (NOT null) to mean "default to " — 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. */} + {managed ? ( 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({ tag, channels, @@ -29,25 +30,20 @@ function TagRow({ const { t } = useTranslation(); const [name, setName] = useState(tag.name); const [open, setOpen] = useState(false); - const [pos, setPos] = useState({ left: 0, top: 0 }); const popFade = useScrollFade(); - const countRef = useRef(null); - const closeTimer = useRef(undefined); const commit = () => { const v = name.trim(); if (v && v !== tag.name) onRename(v); else setName(tag.name); }; - const openPop = () => { - window.clearTimeout(closeTimer.current); - if (channels.length === 0) return; - const r = countRef.current?.getBoundingClientRect(); - if (r) setPos({ left: r.right + 8, top: r.top }); - setOpen(true); - }; - const closeSoon = () => { - closeTimer.current = window.setTimeout(() => setOpen(false), 150); - }; + const pop = useAnchoredPopover({ + open: open && channels.length > 0, + onOpenChange: setOpen, + mode: "hover", + placement: "right-start", + offset: 8, + hoverDelay: { open: 0, close: 150 }, + }); return (
- {open && - createPortal( -
-
- {t("tagManager.onChannels")} -
- {channels.map((ch) => ( - - ))} -
, - document.body, - )} + +
+ {t("tagManager.onChannels")} +
+ {/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */} +
+ {channels.map((ch) => ( + + ))} +
+
); } diff --git a/frontend/src/components/Tooltip.tsx b/frontend/src/components/Tooltip.tsx index 7c2d94eb..2695d474 100644 --- a/frontend/src/components/Tooltip.tsx +++ b/frontend/src/components/Tooltip.tsx @@ -1,16 +1,13 @@ -import { useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; -import { createPortal } from "react-dom"; +import { useState, useSyncExternalStore } from "react"; +import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover"; import { hintsEnabled, subscribeHints } from "../lib/hints"; 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 - * app-wide hints toggle (Settings → Appearance) is on. Rendered in a portal with - * fixed positioning so it is never clipped by an overflow/stacking ancestor. */ + * app-wide hints toggle (Settings → Appearance) is on. Anchored by the R8 popover platform, so it + * 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({ hint, side = "top", @@ -21,74 +18,31 @@ export default function Tooltip({ children: React.ReactNode; }) { const enabled = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled); - const ref = useRef(null); - const tipRef = useRef(null); - const [coords, setCoords] = useState(null); - - function show() { - const el = ref.current; - if (!el) return; - 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]); + const [open, setOpen] = useState(false); + const pop = useAnchoredPopover({ + open: open && enabled && !!hint, + onOpenChange: setOpen, + mode: "hover", + placement: side, + offset: 8, + }); if (!enabled || !hint) return <>{children}; return ( - - {children} - {coords && - createPortal( -
- {hint} -
, - document.body, - )} -
+ <> + + {children} + + {/* Passive caption: it must not swallow a click aimed at a neighbouring control that it + overlaps while the hover-close delay runs out. */} + + {hint} + + ); } diff --git a/frontend/src/lib/fullscreen.ts b/frontend/src/lib/fullscreen.ts index 9dae2cfc..7b907665 100644 --- a/frontend/src/lib/fullscreen.ts +++ b/frontend/src/lib/fullscreen.ts @@ -1,3 +1,5 @@ +import { useSyncExternalStore } from "react"; + // The browser's Fullscreen API, WebKit included. // // 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); }; } + +// `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 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); +}