feat(popover): add the Floating UI anchored-popover platform + migrate TagsCell pilot (R8 S1)
Introduce useAnchoredPopover + PopoverSurface over @floating-ui/react — one primitive replacing the hand-rolled measured engines: flip() (two-way flip), shift() (width clamp), hide() (hide-and-follow when the trigger leaves its scroller), autoUpdate (scroll follow), FloatingPortal (escape ancestor stacking contexts). Supports menu + hover modes; focus management lands in S2. Wrap the app and the public watch page in <FloatingTree> so Escape / outside-press dispatch is topmost-only across nested layers (S3 folds in the Modal + players). Migrate the richest engine, TagsCell, as the pilot — ~140 lines of bespoke positioning (coords state, flip layout-effect, scroll rAF loop, useDismiss, Overlay) collapse into one hook call. Verified in-app: click opens, portals to <body>, positions below the trigger, role=menu + aria-expanded/haspopup, Escape and outside-click close, no console errors.
This commit is contained in:
Generated
+60
@@ -9,6 +9,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.20",
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -995,6 +996,59 @@
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/core": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
|
||||
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/dom": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
|
||||
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/core": "^1.8.0",
|
||||
"@floating-ui/utils": "^0.2.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react": {
|
||||
"version": "0.27.20",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.20.tgz",
|
||||
"integrity": "sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.1.9",
|
||||
"@floating-ui/utils": "^0.2.12",
|
||||
"tabbable": "^6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17.0.0",
|
||||
"react-dom": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/react-dom": {
|
||||
"version": "2.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.9.tgz",
|
||||
"integrity": "sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.8.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@floating-ui/utils": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
|
||||
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
@@ -5294,6 +5348,12 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/tabbable": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.5.0.tgz",
|
||||
"integrity": "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.20",
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { type CSSProperties, type ReactNode, type Ref } from "react";
|
||||
import {
|
||||
useFloating,
|
||||
autoUpdate,
|
||||
offset as offsetMiddleware,
|
||||
flip,
|
||||
shift,
|
||||
size,
|
||||
hide,
|
||||
useClick,
|
||||
useHover,
|
||||
useFocus,
|
||||
useDismiss,
|
||||
useRole,
|
||||
useInteractions,
|
||||
useMergeRefs,
|
||||
useFloatingNodeId,
|
||||
safePolygon,
|
||||
FloatingNode,
|
||||
FloatingPortal,
|
||||
FloatingFocusManager,
|
||||
type Placement,
|
||||
type UseRoleProps,
|
||||
} from "@floating-ui/react";
|
||||
|
||||
// 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
|
||||
// visibility) that had each re-derived the same maths. Floating UI's middleware does all four:
|
||||
// - flip() → measured two-way flip (top/bottom) when a side runs out of room
|
||||
// - shift() → slides back into view horizontally (the old MEASURED-width clamp)
|
||||
// - hide() → sets referenceHidden when the trigger is scrolled out of its clipping scroller, so we
|
||||
// hide-and-follow instead of the old "close on leave scroller" (auto-reappears on scroll back)
|
||||
// - autoUpdate → follows the trigger on scroll/resize (the old rAF loop), coalesced by the library
|
||||
// Positioning is portalled through FloatingPortal (escapes ancestor stacking contexts — same job the
|
||||
// house <Overlay> did for TagsCell). Every popover is a node in the app's <FloatingTree> so Escape /
|
||||
// outside-press dispatch is topmost-only across nested layers (S3 folds the Modal + players in too).
|
||||
// Focus management (trap + return-to-trigger, roving nav) arrives in S2 via `focusManaged`.
|
||||
|
||||
// Internal to the platform; call sites pass the string literal via `mode`, they don't name the type.
|
||||
type PopoverMode = "menu" | "listbox" | "hover" | "dialog";
|
||||
|
||||
export interface AnchoredPopoverOptions {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean, event?: Event) => void;
|
||||
/** "menu"/"listbox"/"dialog" open on click; "hover" opens on hover+focus (tooltips, hover cards). */
|
||||
mode?: PopoverMode;
|
||||
placement?: Placement;
|
||||
/** Gap between trigger and panel, px. Default 4. */
|
||||
offset?: number;
|
||||
/** Hover mode only: open/close intent delays, ms. */
|
||||
hoverDelay?: number | { open?: number; close?: number };
|
||||
/** ARIA role for the panel; defaults to "tooltip" in hover mode, else "menu". */
|
||||
role?: UseRoleProps["role"];
|
||||
/** Cap the panel height to the available space (adds the `size` middleware). Off by default so a
|
||||
* caller's own max-height class wins; turn on for long lists that should shrink to fit. */
|
||||
fitHeight?: boolean;
|
||||
}
|
||||
|
||||
export type AnchoredPopover = ReturnType<typeof useAnchoredPopover>;
|
||||
|
||||
export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
|
||||
const mode = opts.mode ?? "menu";
|
||||
const isHover = mode === "hover";
|
||||
// null when not inside a <FloatingTree> — the hook still works, just without nested-layer awareness.
|
||||
const nodeId = useFloatingNodeId();
|
||||
|
||||
const { refs, floatingStyles, context, middlewareData } = useFloating({
|
||||
nodeId,
|
||||
open: opts.open,
|
||||
onOpenChange: opts.onOpenChange,
|
||||
placement: opts.placement ?? "bottom-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
middleware: [
|
||||
offsetMiddleware(opts.offset ?? 4),
|
||||
flip({ padding: 8 }),
|
||||
shift({ padding: 8 }),
|
||||
...(opts.fitHeight
|
||||
? [
|
||||
size({
|
||||
padding: 8,
|
||||
apply({ availableHeight, elements }) {
|
||||
elements.floating.style.maxHeight = `${availableHeight}px`;
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
hide({ padding: 8 }),
|
||||
],
|
||||
});
|
||||
|
||||
const click = useClick(context, { enabled: !isHover });
|
||||
const hover = useHover(context, {
|
||||
enabled: isHover,
|
||||
move: false,
|
||||
delay: opts.hoverDelay ?? { open: 120, close: 140 },
|
||||
handleClose: safePolygon(),
|
||||
});
|
||||
const focus = useFocus(context, { enabled: isHover });
|
||||
const dismiss = useDismiss(context);
|
||||
const role = useRole(context, { role: opts.role ?? (isHover ? "tooltip" : "menu") });
|
||||
|
||||
const interactions = useInteractions([click, hover, focus, dismiss, role]);
|
||||
|
||||
return {
|
||||
nodeId,
|
||||
mode,
|
||||
open: opts.open,
|
||||
refs,
|
||||
floatingStyles,
|
||||
context,
|
||||
referenceHidden: middlewareData.hide?.referenceHidden ?? false,
|
||||
...interactions,
|
||||
};
|
||||
}
|
||||
|
||||
/** Renders the popover panel: the FloatingTree node + portal + the positioned surface. Merges the
|
||||
* library's floating ref/props with the caller's className/style (and an optional extra ref, e.g. a
|
||||
* scroll-fade callback ref). `focusManaged` wraps it in a FloatingFocusManager (S2). */
|
||||
export function PopoverSurface({
|
||||
popover,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
floatingRef,
|
||||
focusManaged = false,
|
||||
initialFocus,
|
||||
}: {
|
||||
popover: AnchoredPopover;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
children: ReactNode;
|
||||
/** Extra ref merged onto the floating node (e.g. a useScrollFade callback ref). */
|
||||
floatingRef?: Ref<HTMLDivElement>;
|
||||
focusManaged?: boolean;
|
||||
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
|
||||
}) {
|
||||
const { context, refs, floatingStyles, getFloatingProps, nodeId, referenceHidden, open } =
|
||||
popover;
|
||||
const mergedRef = useMergeRefs([refs.setFloating, floatingRef ?? null]);
|
||||
const floatingProps = getFloatingProps();
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const surface = (
|
||||
<div
|
||||
ref={mergedRef}
|
||||
{...floatingProps}
|
||||
className={className}
|
||||
style={{
|
||||
...(floatingProps.style as CSSProperties | undefined),
|
||||
...floatingStyles,
|
||||
// Trigger scrolled out of its clipping scroller → hide-and-follow rather than float over chrome.
|
||||
...(referenceHidden ? { visibility: "hidden", pointerEvents: "none" } : null),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<FloatingNode id={nodeId}>
|
||||
<FloatingPortal>
|
||||
{focusManaged ? (
|
||||
<FloatingFocusManager context={context} modal={false} initialFocus={initialFocus}>
|
||||
{surface}
|
||||
</FloatingFocusManager>
|
||||
) : (
|
||||
surface
|
||||
)}
|
||||
</FloatingPortal>
|
||||
</FloatingNode>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
||||
import { subsColumn } from "./channelColumns";
|
||||
@@ -30,7 +29,7 @@ import Tooltip from "./Tooltip";
|
||||
import DataTable, { type Column } from "./DataTable";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import Overlay from "./Overlay";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import Pager from "./Pager";
|
||||
@@ -1037,114 +1036,26 @@ function TagsCell({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
// Anchor for the portalled menu. It renders through <Overlay> (i.e. at <body>) rather than
|
||||
// `absolute` inside the cell, because in the CARD layout every VirtualGrid row carries a
|
||||
// `transform` — which makes it a stacking context and traps a z-indexed child, so the menu drew
|
||||
// UNDER the next card. Same hazard, same blessed fix as the players in E3.
|
||||
const [coords, setCoords] = useState<{ left: number; top?: number; bottom?: number } | null>(
|
||||
null,
|
||||
);
|
||||
const fade = useScrollFade();
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
|
||||
useDismiss(open, () => setOpen(false), [ref, popRef]);
|
||||
// Keep the scroll-fade hook's callback ref working alongside our own.
|
||||
const setPopRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
fade.ref(node);
|
||||
},
|
||||
[fade.ref],
|
||||
);
|
||||
// R8 anchored-popover platform: positioning (measured flip via flip(), horizontal clamp via shift(),
|
||||
// scroll-follow via autoUpdate, hide-on-leave-scroller via hide()), body portal, dismiss and
|
||||
// topmost Escape all come from the shared hook — replacing this cell's former hand-rolled engine.
|
||||
// The portal is what lets the CARD layout's per-row `transform` (a stacking context) NOT trap the
|
||||
// menu under the next card. Focus management (roving nav / return-to-trigger) arrives in S2.
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
// ONE placement rule, so the position the menu opens at and the one it keeps while scrolling can't
|
||||
// drift apart. `flip` keeps it anchored above the trigger once it has been flipped; the horizontal
|
||||
// clamp uses the MEASURED width (`w-44` is rem — it grows with the text-size slider, so a px guess
|
||||
// reads short at 1.3). Before first paint the menu isn't measurable yet; the layout effect below
|
||||
// re-runs this once it is.
|
||||
const coordsFrom = useCallback((r: DOMRect, flip: boolean) => {
|
||||
const w = popRef.current?.offsetWidth ?? 0;
|
||||
const left =
|
||||
w && r.left + w > window.innerWidth - 8 ? Math.max(8, window.innerWidth - 8 - w) : r.left;
|
||||
return flip ? { left, bottom: window.innerHeight - r.top + 4 } : { left, top: r.bottom + 4 };
|
||||
}, []);
|
||||
|
||||
const place = useCallback(() => {
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!r) return false;
|
||||
setCoords(coordsFrom(r, false));
|
||||
return true;
|
||||
}, [coordsFrom]);
|
||||
const toggle = () => {
|
||||
if (open) setOpen(false);
|
||||
else if (place()) setOpen(true);
|
||||
};
|
||||
|
||||
// Correct the placement against the MEASURED box: flip above when it would run off the bottom (and
|
||||
// there is room above), and apply the width clamp now that the menu can actually be measured.
|
||||
useLayoutEffect(() => {
|
||||
const pop = popRef.current;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
if (!open || !pop || !coords || !r) return;
|
||||
const h = pop.offsetHeight; // the width is read inside coordsFrom, off the same node
|
||||
const fitsBelow = r.bottom + 4 + h <= window.innerHeight - 8;
|
||||
const fitsAbove = r.top - 4 - h >= 8;
|
||||
// Decide BOTH ways, every time. Prefer the side it is already on (no flapping while scrolling),
|
||||
// but leave it as soon as that side stops fitting and the other one does — a one-way flip would
|
||||
// keep an above-anchored menu above a row scrolling toward the top and push it off the screen.
|
||||
const above = coords.bottom !== undefined;
|
||||
const flip = above ? fitsAbove || !fitsBelow : !fitsBelow && fitsAbove;
|
||||
const next = coordsFrom(r, flip);
|
||||
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
|
||||
setCoords(next);
|
||||
}
|
||||
}, [open, coords, coordsFrom]);
|
||||
|
||||
// A fixed menu doesn't travel with its row, so FOLLOW the trigger on scroll rather than closing:
|
||||
// in the table the menu used to scroll with the row, and closing on every wheel tick would make
|
||||
// attaching several tags a reopen-per-tag chore. Closes only once the trigger leaves the SCROLLER
|
||||
// (not the window — the page scroller starts below ~230px of fixed chrome, so a row hidden behind
|
||||
// the toolbar is still "on screen" by window coords and the menu would float over the chrome).
|
||||
// Scrolling INSIDE the menu must not move it.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let raf = 0;
|
||||
const reposition = () => {
|
||||
raf = 0;
|
||||
const r = btnRef.current?.getBoundingClientRect();
|
||||
const scroller = document.querySelector("main")?.getBoundingClientRect();
|
||||
const topEdge = scroller ? scroller.top : 0;
|
||||
const bottomEdge = scroller ? scroller.bottom : window.innerHeight;
|
||||
if (!r || r.bottom < topEdge || r.top > bottomEdge) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
// Keep whichever side it is anchored to, so the layout effect has nothing left to correct —
|
||||
// resetting to "below" every frame would make it flip back and forth, two renders per frame.
|
||||
setCoords((cur) => (cur ? coordsFrom(r, cur.bottom !== undefined) : cur));
|
||||
};
|
||||
const onScroll = (e: Event) => {
|
||||
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
|
||||
if (!raf) raf = requestAnimationFrame(reposition); // coalesce a scroll burst into one move
|
||||
};
|
||||
const onResize = () => setOpen(false);
|
||||
window.addEventListener("scroll", onScroll, true);
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
window.removeEventListener("scroll", onScroll, true);
|
||||
window.removeEventListener("resize", onResize);
|
||||
};
|
||||
}, [open, coordsFrom]);
|
||||
if (userTags.length === 0) return null;
|
||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
||||
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
|
||||
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
|
||||
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
|
||||
return (
|
||||
<div ref={ref} className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||
<div className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
|
||||
{attached.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
@@ -1156,51 +1067,42 @@ function TagsCell({
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
ref={btnRef}
|
||||
onClick={toggle}
|
||||
ref={pop.refs.setReference}
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
{open && coords && (
|
||||
<Overlay>
|
||||
<div
|
||||
ref={setPopRef}
|
||||
style={{
|
||||
...fade.style,
|
||||
position: "fixed",
|
||||
left: coords.left,
|
||||
top: coords.top,
|
||||
bottom: coords.bottom,
|
||||
}}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onToggleTag(tg.id)}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
floatingRef={fade.ref}
|
||||
style={fade.style}
|
||||
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{userTags.map((tg) => {
|
||||
const on = c.tag_ids.includes(tg.id);
|
||||
return (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => onToggleTag(tg.id)}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||
on ? "text-fg" : "text-muted hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
{tg.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+28
-20
@@ -1,6 +1,7 @@
|
||||
import React, { lazy, Suspense } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import ErrorBoundary from "./components/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||
import { NavigationProvider } from "./components/NavigationProvider";
|
||||
@@ -43,29 +44,36 @@ const root =
|
||||
) : path === "/terms" ? (
|
||||
<Terms />
|
||||
) : path.startsWith("/watch/") ? (
|
||||
<WatchPage />
|
||||
// FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
|
||||
<FloatingTree>
|
||||
<WatchPage />
|
||||
</FloatingTree>
|
||||
) : (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorBoundary>
|
||||
<ConfirmProvider>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
<App />
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</ConfirmProvider>
|
||||
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
|
||||
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||
<FloatingTree>
|
||||
<ConfirmProvider>
|
||||
<NavigationProvider>
|
||||
<FeedFiltersProvider>
|
||||
<PlaylistsProvider>
|
||||
<PlexProvider>
|
||||
<ChannelsProvider>
|
||||
<FeedViewProvider>
|
||||
<PrefsProvider>
|
||||
<WizardProvider>
|
||||
<App />
|
||||
</WizardProvider>
|
||||
</PrefsProvider>
|
||||
</FeedViewProvider>
|
||||
</ChannelsProvider>
|
||||
</PlexProvider>
|
||||
</PlaylistsProvider>
|
||||
</FeedFiltersProvider>
|
||||
</NavigationProvider>
|
||||
</ConfirmProvider>
|
||||
</FloatingTree>
|
||||
</ErrorBoundary>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user