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:
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user