fix(managers): the tag picker drew under the next card
In the card layout every VirtualGrid row carries a `transform`, which makes it a stacking context — so the tag menu's z-index was scoped to its own row and the NEXT card (a later DOM sibling) painted over it. Same hazard that buried the players in E3, so the same blessed fix: render the menu through <Overlay> (i.e. at <body>) with fixed coords instead of `absolute` inside the cell. The flip-up case anchors the menu's BOTTOM to just above the button so it lands exactly whatever its real height is, and the horizontal clamp measures the rendered width rather than assuming px — `w-44` grows with the text-size slider. Closes on outside click (dismiss now watches the cell AND the portalled menu), and on page scroll/resize, where a fixed menu would otherwise strand at a stale anchor. Verified in both layouts by hit-testing the menu's own pixels.
This commit is contained in:
@@ -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 <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);
|
||||
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({
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
{open && (
|
||||
{open && coords && (
|
||||
<Overlay>
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
className={`glass-menu absolute left-0 z-chrome w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
|
||||
openUp ? "bottom-full mb-1" : "top-full mt-1"
|
||||
}`}
|
||||
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);
|
||||
@@ -901,6 +951,7 @@ function TagsCell({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Overlay>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user