diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index db100a70..073b6d5c 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -28,6 +28,7 @@ import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import DataTable, { type Column } from "./DataTable"; import ChannelLayoutGrid from "./ChannelLayoutGrid"; +import Overlay from "./Overlay"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; import Pager from "./Pager"; @@ -830,21 +831,71 @@ function TagsCell({ }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); - // Open the picker upward when there isn't room below (rows near the viewport bottom would - // otherwise clip it against the scroll container), as long as there's room above. - const [openUp, setOpenUp] = 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); - useDismiss(open, () => setOpen(false), ref); + // 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], + ); + + // Open upward when there isn't room below, anchoring the menu's BOTTOM to just above the button so + // the flip is exact whatever the real height turns out to be. + const place = () => { + const r = btnRef.current?.getBoundingClientRect(); + if (!r) return; + const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below + const up = r.bottom + est > window.innerHeight - 8 && r.top - est > 8; + setCoords({ + left: r.left, + ...(up ? { bottom: window.innerHeight - r.top + 4 } : { top: r.bottom + 4 }), + }); + }; const toggle = () => { - if (!open) { - const r = btnRef.current?.getBoundingClientRect(); - const est = Math.min(userTags.length * 30 + 12, 264); // matches the max-height cap below - setOpenUp(!!r && r.bottom + est > window.innerHeight - 8 && r.top - est > 8); - } + if (!open) place(); setOpen((o) => !o); }; + + // Nudge back inside the viewport once the real (rem-scaled) width is known — measuring beats a px + // constant here, since the text-size slider drives the root font and `w-44` grows with it. + useLayoutEffect(() => { + const pop = popRef.current; + if (!open || !pop || !coords) return; + const w = pop.offsetWidth; + if (coords.left + w > window.innerWidth - 8) { + const left = Math.max(8, window.innerWidth - 8 - w); + if (Math.abs(left - coords.left) > 0.5) setCoords((cur) => (cur ? { ...cur, left } : cur)); + } + }, [open, coords]); + + // A fixed menu would strand at a stale anchor once the page scrolls (or the window resizes) — + // close it, exactly as AboutCell's overlay does. Scrolling INSIDE the menu must not close it. + 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]); 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 @@ -871,13 +922,12 @@ function TagsCell({ > - {open && ( + {open && coords && ( +
{userTags.map((tg) => { const on = c.tag_ids.includes(tg.id); @@ -901,6 +951,7 @@ function TagsCell({ ); })}
+
)} );