diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:42:00 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 16:42:00 +0200 |
| commit | e2fedc92a789db4c1fe5d6575108cc43265a10a4 (patch) | |
| tree | f3b41d258efa609faec0187e7c77832ef601f4b8 /packages/meshbay-hub/src | |
| parent | 17ddd4de9087a98c87bec28a6b773572b1c58b50 (diff) | |
| download | meshbay-e2fedc92a789db4c1fe5d6575108cc43265a10a4.tar.gz | |
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/menu.js | 67 | ||||
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/style.css | 8 |
2 files changed, 56 insertions, 19 deletions
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`<div class="ctx-menu-empty" style=${`padding-left: ${28 + depth * 14}px`}><span class="spinner"></span></div>` : (rows && rows.length - ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />` + ? html`<${MenuItems} items=${rows} depth=${depth + 1} + onClose=${onClose} onResize=${onResize} />` : html`<div class="ctx-menu-empty" style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`))} `; @@ -123,26 +133,46 @@ function MenuItems({ items, depth, onClose }) { function Menu({ x, y, items, onClose }) { const ref = useRef(null); const [place, setPlace] = useState(null); + // 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 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. + // 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` <div class="ctx-menu" ref=${ref} role="menu" style=${place - ? `left: ${place.left}px; top: ${place.top}px` + ? `left: ${place.left}px; top: ${place.top}px; max-height: ${place.maxHeight}px` // Drawn where asked but not yet shown, for the one frame it takes to // find out how big it is — otherwise it visibly jumps into place. : `left: ${x}px; top: ${y}px; visibility: hidden`}> - <${MenuItems} items=${items} depth=${0} onClose=${onClose} /> + <${MenuItems} items=${items} depth=${0} onClose=${onClose} + onResize=${remeasure} /> </div> `; } diff --git a/packages/meshbay-hub/src/meshbay_hub/static/style.css b/packages/meshbay-hub/src/meshbay_hub/static/style.css index 5ef04b0..2d07fd1 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/style.css +++ b/packages/meshbay-hub/src/meshbay_hub/static/style.css @@ -5040,7 +5040,13 @@ h2 .gn-owner, h3 .gn-owner { font-size: 0.55em; } max-width: calc(100vw - 16px); /* A playlist's tracklist expands inside this panel rather than flying out sideways, so it can be far taller than the window. Same answer the - account menu's language list already has. */ + account menu's language list already has. + + A floor, not the real value: this says how tall the panel may be and + nothing about where its bottom lands, so a panel opened partway down the + window overruns it and its last rows end up in a region of itself that is + off-screen. `menu.js` measures where it put the panel and overrides this + with the room actually left below that point. */ max-height: calc(100vh - 16px); overflow-x: hidden; overflow-y: auto; |