From e2fedc92a789db4c1fe5d6575108cc43265a10a4 Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 16 Sep 2026 16:42:00 +0200 Subject: menu: give the panel the room that is actually below it `max-height: calc(100vh - 16px)` says how tall the menu may be and nothing about where its bottom lands, so one opened partway down the window ran past it and its last rows scrolled out of reach. Measure from where the panel was placed, and re-measure when a submenu opens. Co-Authored-By: Claude Opus 5 --- .../meshbay-hub/src/meshbay_hub/static/menu.js | 69 ++++++++++++++++------ 1 file changed, 50 insertions(+), 19 deletions(-) (limited to 'packages/meshbay-hub/src/meshbay_hub/static/menu.js') diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js index 16aecd8..12ca584 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/menu.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js @@ -58,11 +58,20 @@ function useMenu() { return { menu, openAt, close }; } -function MenuItems({ items, depth, onClose }) { +function MenuItems({ items, depth, onClose, onResize }) { const [expanded, setExpanded] = useState(null); // index -> the items that came back, or 'loading'. const [loaded, setLoaded] = useState({}); + // A submenu opening, and a lazily-read one arriving, are the only two things + // that change how tall the panel is — and `Menu` can see neither, because + // both are state in here. So they are reported rather than watched for: an + // observer on the panel would be an observer on the very box the callback + // resizes, which is a loop the browser breaks by raising an error. + useEffect(() => { + if (onResize) onResize(); + }, [expanded, loaded, onResize]); + const toggle = useCallback((i, it) => { if (expanded === i) { setExpanded(null); return; } setExpanded(i); @@ -95,7 +104,8 @@ function MenuItems({ items, depth, onClose }) { ? html`
` : (rows && rows.length - ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />` + ? html`<${MenuItems} items=${rows} depth=${depth + 1} + onClose=${onClose} onResize=${onResize} />` : html`
${it.empty || ''}
`))} `; @@ -123,26 +133,46 @@ function MenuItems({ items, depth, onClose }) { function Menu({ x, y, items, onClose }) { const ref = useRef(null); const [place, setPlace] = useState(null); - - // Measure, then place. A menu opened near the right or bottom edge has to - // flip rather than be clipped, and how tall it is depends on its own items — - // so the number cannot be written down, it has to be read. + // Bumped by the tree below when it changes height; a dependency of the + // measurement, and nothing else reads it. + const [grew, setGrew] = useState(0); + const remeasure = useCallback(() => setGrew((n) => n + 1), []); + + // Measure, then place. A menu opened near an edge has to flip rather than be + // clipped, and how tall it is depends on its own items — so the numbers + // cannot be written down, they have to be read. // - // This is not the mutate-then-measure loop CLAUDE.md records: the effect - // writes `place`, which moves the panel, and `place` is not in its - // dependencies. Position does not change the size being measured, so it - // cannot wake itself. + // This is not the mutate-then-measure loop CLAUDE.md records. The height read + // is `scrollHeight`, the content's, which the `max-height` written back does + // not move; and `place` is not one of the dependencies. So the effect cannot + // wake itself. useEffect(() => { const el = ref.current; if (!el) return; - const r = el.getBoundingClientRect(); - const left = Math.max(MARGIN, Math.min(x, window.innerWidth - r.width - MARGIN)); - const top = (y + r.height > window.innerHeight - MARGIN) - ? Math.max(MARGIN, y - r.height) - : y; - setPlace({ left, top }); + // Content plus the panel's own borders — `box-sizing` is `border-box`, so + // those count against the `max-height` about to be set. + const h = el.scrollHeight + (el.offsetHeight - el.clientHeight); + const left = Math.max(MARGIN, + Math.min(x, window.innerWidth - el.offsetWidth - MARGIN)); + const below = window.innerHeight - MARGIN - y; + const above = y - MARGIN; + // Fits below, or below is simply the roomier side: stay at the pointer and + // scroll. Otherwise flip above it. + const top = (h <= below || below >= above) ? y : Math.max(MARGIN, y - h); + // And the panel may take exactly the room left below where it was just + // put. The stylesheet's `max-height` is a floor: it says how tall the panel + // may be and nothing about where its bottom lands, so a panel opened 300px + // down the window ran 300px past the bottom of it — it scrolled, but its + // last rows scrolled into a part of itself that is off the screen, which no + // further scrolling brings back. That is "the last track cannot be reached". + const maxHeight = Math.max(0, window.innerHeight - MARGIN - top); + setPlace((prev) => ((prev && prev.left === left && prev.top === top + && prev.maxHeight === maxHeight) ? prev : { left, top, maxHeight })); + // `grew` is a submenu reporting that it opened. Without it the panel keeps + // the placement measured for its collapsed self, and a two-row menu opened + // low threads sixty tracks through the room that was free beneath it. // eslint-disable-next-line - }, [x, y, items]); + }, [x, y, items, grew]); useEffect(() => { const onKey = (e) => { if (e.key === 'Escape') onClose(); }; @@ -183,11 +213,12 @@ function Menu({ x, y, items, onClose }) { return html` `; } -- cgit v1.2.3