fix(managers): address the E4 S3 code-review round 2 findings

- The portalled tag picker now FOLLOWS its trigger on scroll (rAF-coalesced)
  instead of closing. Closing on every wheel tick was a regression for the table,
  where the menu used to travel with its row; it still closes once the trigger
  leaves the viewport (nothing left to anchor to) or on resize.
- Place the menu by MEASUREMENT, not a px estimate: it opens below, then the
  layout effect flips it above only if the real box overflows. The old
  `rows * 30px` guess read short at font-scale 1.3 (the rows are rem), which
  could leave a fixed menu hanging off the bottom with no way to reach it.
- `toggle` only opens once a position exists, so the `open && coords` gate can
  never show a stale anchor.
- One owner for "is there anything to page": `hasPagerContent()` lives in Pager
  and DataTable calls it, instead of both re-deriving the rule — that drift is
  what left empty control bands behind in the first place.
- Reset the card page on the row COUNT too, not just the filter inputs, so an
  unsubscribe or a shrinking refetch can't leave a stale page to spring back.
- `parseCardSize` clamps to the sizes the pager actually offers (like clampView /
  clampStatus), so the select can't display one value while the grid pages by another.
- VirtualGrid's docblock now carries the stacking-context constraint: its rows are
  transformed, so anything floating an item renders must portal out.
This commit is contained in:
2026-07-20 00:56:47 +02:00
parent 260de48f41
commit c630d29ff6
6 changed files with 78 additions and 43 deletions
+47 -29
View File
@@ -221,12 +221,6 @@ export default function Channels() {
// the page is transient.
const [cardSize, setCardSize] = useState(() => parseCardSize(readAccount(LS.channelCardSize, null)));
const [cardPage, setCardPage] = useState(0);
// A narrowed or re-sorted list invalidates the page you were on, so go back to the first — the way
// DataTable resets its own page on a filter/sort change. Without this the clamp only hides a stale
// page: clearing the filter again would silently return you to page 10 of the old list.
useEffect(() => {
setCardPage(0);
}, [statusFilter, search, tagFilter, sort]);
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
useEffect(() => {
if (focusChannelName) setSearch(focusChannelName);
@@ -259,6 +253,13 @@ export default function Channels() {
if (tagFilter.length && !tagFilter.some((id) => c.tag_ids.includes(id))) return false;
return true;
});
// A re-sorted or RESIZED list invalidates the page you were on, so go back to the first — the way
// DataTable resets its own page on a filter/sort change. Keyed on the resulting row count, not just
// the filter inputs, so an unsubscribe or a refetch that shrinks the list counts too: otherwise the
// clamp merely HIDES a stale page, which returns the moment the list grows again.
useEffect(() => {
setCardPage(0);
}, [statusFilter, search, tagFilter, sort, visibleChannels.length]);
const s = statusQuery.data;
const onView = (c: ManagedChannel) =>
@@ -851,47 +852,64 @@ function TagsCell({
[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 = () => {
// Anchor below the trigger; the layout effect below corrects it against the menu's REAL box. Only
// opens once a position exists, so the render gate can never show a stale anchor.
const place = useCallback(() => {
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 }),
});
};
if (!r) return false;
setCoords({ left: r.left, top: r.bottom + 4 });
return true;
}, []);
const toggle = () => {
if (!open) place();
setOpen((o) => !o);
if (open) setOpen(false);
else if (place()) setOpen(true);
};
// 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.
// Correct the placement against the MEASURED box: flip above when it would run off the bottom,
// nudge back inside the right edge. Measured, not estimated — the menu's rows and its `w-44` are
// rem, so both grow with the text-size slider and any px guess reads short at 1.3.
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));
const r = btnRef.current?.getBoundingClientRect();
if (!open || !pop || !coords || !r) return;
const { offsetHeight: h, offsetWidth: w } = pop;
const next = { ...coords };
// Flip up only when it actually overflows AND there is room above (else stay put and clip).
if (coords.top !== undefined && coords.top + h > window.innerHeight - 8 && r.top - h - 4 > 8) {
delete next.top;
next.bottom = window.innerHeight - r.top + 4;
}
if (coords.left + w > window.innerWidth - 8) next.left = Math.max(8, window.innerWidth - 8 - w);
if (next.left !== coords.left || next.top !== coords.top || next.bottom !== coords.bottom) {
setCoords(next);
}
}, [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.
// 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 viewport
// (nothing left to anchor to) or on resize. Scrolling INSIDE the menu must not move it.
useEffect(() => {
if (!open) return;
let raf = 0;
const reposition = () => {
raf = 0;
const r = btnRef.current?.getBoundingClientRect();
if (!r || r.bottom < 0 || r.top > window.innerHeight) {
setOpen(false);
return;
}
setCoords({ left: r.left, top: r.bottom + 4 });
};
const onScroll = (e: Event) => {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
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);
};