diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 301f023b..18c044c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index f29efae6..dfa964a5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/components/AnchoredPopover.tsx b/frontend/src/components/AnchoredPopover.tsx new file mode 100644 index 00000000..3422f9f7 --- /dev/null +++ b/frontend/src/components/AnchoredPopover.tsx @@ -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 did for TagsCell). Every popover is a node in the app's 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; + +export function useAnchoredPopover(opts: AnchoredPopoverOptions) { + const mode = opts.mode ?? "menu"; + const isHover = mode === "hover"; + // null when not inside a — 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; + focusManaged?: boolean; + initialFocus?: number | React.MutableRefObject; +}) { + 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 = ( +
+ {children} +
+ ); + + return ( + + + {focusManaged ? ( + + {surface} + + ) : ( + surface + )} + + + ); +} diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index b8afee54..9bb41e8d 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -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 (i.e. at ) 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(null); - const popRef = useRef(null); - const btnRef = useRef(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 ( -
+
{attached.map((tg) => ( - {open && coords && ( - -
- {userTags.map((tg) => { - const on = c.tag_ids.includes(tg.id); - return ( - - ); - })} -
-
- )} + + {userTags.map((tg) => { + const on = c.tag_ids.includes(tg.id); + return ( + + ); + })} +
); } diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 9fe4bca4..328b17bb 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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" ? ( ) : path.startsWith("/watch/") ? ( - + // FloatingTree so the public player's own popovers dispatch Escape/outside-press topmost-only. + + + ) : ( - - - - - - - - - - - - - - - - - - - + {/* 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). */} + + + + + + + + + + + + + + + + + + + + + );