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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@floating-ui/react": "^0.27.20",
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -995,6 +996,59 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"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": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.2",
|
"version": "0.19.2",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||||
@@ -5294,6 +5348,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
|
"@floating-ui/react": "^0.27.20",
|
||||||
"@tanstack/react-query": "^5.51.0",
|
"@tanstack/react-query": "^5.51.0",
|
||||||
"@tanstack/react-virtual": "^3.14.3",
|
"@tanstack/react-virtual": "^3.14.3",
|
||||||
"clsx": "^2.1.1",
|
"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 { 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";
|
||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
import { api, type ManagedChannel, type Tag } from "../lib/api";
|
||||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||||
import { accountKey, LS } from "../lib/storage";
|
import { accountKey, LS } from "../lib/storage";
|
||||||
import { useDismiss } from "../lib/useDismiss";
|
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
|
||||||
import { subsColumn } from "./channelColumns";
|
import { subsColumn } from "./channelColumns";
|
||||||
@@ -30,7 +29,7 @@ import Tooltip from "./Tooltip";
|
|||||||
import DataTable, { type Column } from "./DataTable";
|
import DataTable, { type Column } from "./DataTable";
|
||||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||||
import { LoadingState, StateMessage } from "./QueryState";
|
import { LoadingState, StateMessage } from "./QueryState";
|
||||||
import Overlay from "./Overlay";
|
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import ColumnSortControl from "./ColumnSortControl";
|
import ColumnSortControl from "./ColumnSortControl";
|
||||||
import Pager from "./Pager";
|
import Pager from "./Pager";
|
||||||
@@ -1037,114 +1036,26 @@ function TagsCell({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
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 fade = useScrollFade();
|
||||||
const ref = useRef<HTMLDivElement | null>(null);
|
// R8 anchored-popover platform: positioning (measured flip via flip(), horizontal clamp via shift(),
|
||||||
const popRef = useRef<HTMLDivElement | null>(null);
|
// scroll-follow via autoUpdate, hide-on-leave-scroller via hide()), body portal, dismiss and
|
||||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
// topmost Escape all come from the shared hook — replacing this cell's former hand-rolled engine.
|
||||||
// The menu is no longer a DOM descendant of `ref`, so dismiss has to watch both.
|
// The portal is what lets the CARD layout's per-row `transform` (a stacking context) NOT trap the
|
||||||
useDismiss(open, () => setOpen(false), [ref, popRef]);
|
// menu under the next card. Focus management (roving nav / return-to-trigger) arrives in S2.
|
||||||
// Keep the scroll-fade hook's callback ref working alongside our own.
|
const pop = useAnchoredPopover({
|
||||||
const setPopRef = useCallback(
|
open,
|
||||||
(node: HTMLDivElement | null) => {
|
onOpenChange: setOpen,
|
||||||
popRef.current = node;
|
mode: "menu",
|
||||||
fade.ref(node);
|
placement: "bottom-start",
|
||||||
},
|
});
|
||||||
[fade.ref],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 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;
|
if (userTags.length === 0) return null;
|
||||||
// Show only the tags actually attached to this channel; the “+” opens a per-channel
|
// 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
|
// 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.
|
// 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));
|
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
|
||||||
return (
|
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) => (
|
{attached.map((tg) => (
|
||||||
<button
|
<button
|
||||||
key={tg.id}
|
key={tg.id}
|
||||||
@@ -1156,51 +1067,42 @@ function TagsCell({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<button
|
<button
|
||||||
ref={btnRef}
|
ref={pop.refs.setReference}
|
||||||
onClick={toggle}
|
|
||||||
title={t("channels.row.editTags")}
|
title={t("channels.row.editTags")}
|
||||||
aria-label={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"
|
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" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
{open && coords && (
|
<PopoverSurface
|
||||||
<Overlay>
|
popover={pop}
|
||||||
<div
|
floatingRef={fade.ref}
|
||||||
ref={setPopRef}
|
style={fade.style}
|
||||||
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]"
|
||||||
...fade.style,
|
>
|
||||||
position: "fixed",
|
{userTags.map((tg) => {
|
||||||
left: coords.left,
|
const on = c.tag_ids.includes(tg.id);
|
||||||
top: coords.top,
|
return (
|
||||||
bottom: coords.bottom,
|
<button
|
||||||
}}
|
key={tg.id}
|
||||||
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]"
|
onClick={() => onToggleTag(tg.id)}
|
||||||
>
|
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
|
||||||
{userTags.map((tg) => {
|
on ? "text-fg" : "text-muted hover:bg-card"
|
||||||
const on = c.tag_ids.includes(tg.id);
|
}`}
|
||||||
return (
|
>
|
||||||
<button
|
<span
|
||||||
key={tg.id}
|
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||||
onClick={() => onToggleTag(tg.id)}
|
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||||
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"
|
>
|
||||||
}`}
|
{on && <Check className="w-2.5 h-2.5" />}
|
||||||
>
|
</span>
|
||||||
<span
|
{tg.name}
|
||||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
</button>
|
||||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
);
|
||||||
}`}
|
})}
|
||||||
>
|
</PopoverSurface>
|
||||||
{on && <Check className="w-2.5 h-2.5" />}
|
|
||||||
</span>
|
|
||||||
{tg.name}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-20
@@ -1,6 +1,7 @@
|
|||||||
import React, { lazy, Suspense } from "react";
|
import React, { lazy, Suspense } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { FloatingTree } from "@floating-ui/react";
|
||||||
import ErrorBoundary from "./components/ErrorBoundary";
|
import ErrorBoundary from "./components/ErrorBoundary";
|
||||||
import { ConfirmProvider } from "./components/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ConfirmProvider";
|
||||||
import { NavigationProvider } from "./components/NavigationProvider";
|
import { NavigationProvider } from "./components/NavigationProvider";
|
||||||
@@ -43,29 +44,36 @@ const root =
|
|||||||
) : path === "/terms" ? (
|
) : path === "/terms" ? (
|
||||||
<Terms />
|
<Terms />
|
||||||
) : path.startsWith("/watch/") ? (
|
) : path.startsWith("/watch/") ? (
|
||||||
<WatchPage />
|
// FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only.
|
||||||
|
<FloatingTree>
|
||||||
|
<WatchPage />
|
||||||
|
</FloatingTree>
|
||||||
) : (
|
) : (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<ConfirmProvider>
|
{/* One tree for the whole app: every anchored popover registers as a node so Escape and
|
||||||
<NavigationProvider>
|
outside-press act only on the topmost layer (S3 folds the Modal + players in). */}
|
||||||
<FeedFiltersProvider>
|
<FloatingTree>
|
||||||
<PlaylistsProvider>
|
<ConfirmProvider>
|
||||||
<PlexProvider>
|
<NavigationProvider>
|
||||||
<ChannelsProvider>
|
<FeedFiltersProvider>
|
||||||
<FeedViewProvider>
|
<PlaylistsProvider>
|
||||||
<PrefsProvider>
|
<PlexProvider>
|
||||||
<WizardProvider>
|
<ChannelsProvider>
|
||||||
<App />
|
<FeedViewProvider>
|
||||||
</WizardProvider>
|
<PrefsProvider>
|
||||||
</PrefsProvider>
|
<WizardProvider>
|
||||||
</FeedViewProvider>
|
<App />
|
||||||
</ChannelsProvider>
|
</WizardProvider>
|
||||||
</PlexProvider>
|
</PrefsProvider>
|
||||||
</PlaylistsProvider>
|
</FeedViewProvider>
|
||||||
</FeedFiltersProvider>
|
</ChannelsProvider>
|
||||||
</NavigationProvider>
|
</PlexProvider>
|
||||||
</ConfirmProvider>
|
</PlaylistsProvider>
|
||||||
|
</FeedFiltersProvider>
|
||||||
|
</NavigationProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</FloatingTree>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user