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:
@@ -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<HTMLSpanElement>(null);
|
||||
const popRef = useRef<HTMLDivElement>(null);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | 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 <span className="text-muted">—</span>;
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={scheduleHide}
|
||||
onFocus={show}
|
||||
onBlur={scheduleHide}
|
||||
tabIndex={0}
|
||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||
>
|
||||
{preview}
|
||||
{open &&
|
||||
coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popRef}
|
||||
onMouseEnter={cancelHide}
|
||||
onMouseLeave={scheduleHide}
|
||||
style={{ position: "fixed", left: coords.left, top: coords.top, width: POP_W }}
|
||||
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
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
tabIndex={0}
|
||||
className="block max-w-[22rem] truncate text-muted cursor-help outline-none"
|
||||
>
|
||||
{preview}
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-[480px] 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>
|
||||
)}
|
||||
</span>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -9,10 +8,12 @@ import { notify } from "../lib/notifications";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
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
|
||||
// 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({
|
||||
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<HTMLSpanElement | null>(null);
|
||||
const closeTimer = useRef<number | undefined>(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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
/>
|
||||
<span
|
||||
ref={countRef}
|
||||
onMouseEnter={openPop}
|
||||
onMouseLeave={closeSoon}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className={`text-xs text-muted tabular-nums shrink-0 ${
|
||||
channels.length
|
||||
? "cursor-help underline decoration-dotted decoration-muted/40 underline-offset-2"
|
||||
@@ -77,30 +72,30 @@ function TagRow({
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={popFade.ref}
|
||||
onMouseEnter={openPop}
|
||||
onMouseLeave={closeSoon}
|
||||
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }}
|
||||
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]"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
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>
|
||||
))}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
|
||||
{t("tagManager.onChannels")}
|
||||
</div>
|
||||
{/* Inner scroller so the scroll-fade mask rides the scrolling box, not the glass panel. */}
|
||||
<div
|
||||
ref={popFade.ref}
|
||||
style={popFade.style}
|
||||
className="max-h-72 overflow-y-auto no-scrollbar"
|
||||
>
|
||||
{channels.map((ch) => (
|
||||
<button
|
||||
key={ch.id}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLSpanElement>(null);
|
||||
const tipRef = useRef<HTMLDivElement>(null);
|
||||
const [coords, setCoords] = useState<Coords | null>(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 (
|
||||
<span
|
||||
ref={ref}
|
||||
onMouseEnter={show}
|
||||
onMouseLeave={hide}
|
||||
onFocus={show}
|
||||
onBlur={hide}
|
||||
className="inline-flex"
|
||||
>
|
||||
{children}
|
||||
{coords &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={tipRef}
|
||||
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>
|
||||
<>
|
||||
<span ref={pop.refs.setReference} {...pop.getReferenceProps()} className="inline-flex">
|
||||
{children}
|
||||
</span>
|
||||
{/* Passive caption: it must not swallow a click aimed at a neighbouring control that it
|
||||
overlaps while the hover-close delay runs out. */}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
passthrough
|
||||
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]"
|
||||
>
|
||||
{hint}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user