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 { 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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user