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/tests/harness/menu_scroll_probe.py | 76 +++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) (limited to 'packages/meshbay-hub/tests/harness/menu_scroll_probe.py') diff --git a/packages/meshbay-hub/tests/harness/menu_scroll_probe.py b/packages/meshbay-hub/tests/harness/menu_scroll_probe.py index 809d1b1..5fb9579 100755 --- a/packages/meshbay-hub/tests/harness/menu_scroll_probe.py +++ b/packages/meshbay-hub/tests/harness/menu_scroll_probe.py @@ -119,7 +119,81 @@ let closed = false; } cases.push({ case: 'pressed outside the menu', closed }); - // 4. And so does a scroll of the page. Not optional: the menu is fixed, so + // 4. Opened partway down the window, the panel must end *inside* it. + // Scrolling a panel whose own bottom is off-screen moves its last rows + // into a region of itself that no scroll can bring back, which is what + // "the last track cannot be reached" was. + closed = false; + render(html`<${Menu} x=${300} y=${300} items=${ITEMS} onClose=${onClose} />`, + document.getElementById('root')); + await frame(); + const low = document.querySelector('.ctx-menu'); + low.scrollTop = low.scrollHeight; + await frame(); + const lowRows = low.querySelectorAll('.ctx-menu-item'); + const lowLast = lowRows[lowRows.length - 1]; + cases.push({ case: 'a long menu opened partway down the window', + openedAt: 300, viewport: window.innerHeight, + panelTop: Math.round(low.getBoundingClientRect().top), + panelBottom: Math.round(low.getBoundingClientRect().bottom), + lastRowBottom: Math.round(lowLast.getBoundingClientRect().bottom), + lastRowLabel: lowLast.textContent.trim(), + overflows: low.scrollHeight > low.clientHeight + 1 }); + + // 5. And it must still end inside it after a submenu has expanded, which + // is a size change happening in `MenuItems`' own state — the placement + // effect's dependencies cannot see it. + // + // Opened low on purpose. A two-row menu 600px down has 192px of room + // beneath it, and that is the height the panel is given; expanding a + // sixty-track list into it must re-place the panel rather than thread + // the list through the porthole the collapsed one was measured for. + const NESTED = [ + { key: 'load', label: 'Load a playlist', items: ITEMS.slice(0, 3) }, + { key: 'remove', label: 'Remove a track', items: ITEMS }, + ]; + render(html`<${Menu} x=${300} y=${600} items=${NESTED} onClose=${onClose} />`, + document.getElementById('root')); + await frame(); + const nested = document.querySelector('.ctx-menu'); + const collapsedBottom = Math.round(nested.getBoundingClientRect().bottom); + const toggle = Array.from(nested.querySelectorAll('.ctx-menu-item')) + .find((b) => b.textContent.indexOf('Remove a track') >= 0); + if (!toggle) return fail('no submenu row to expand'); + toggle.click(); + await frame(); + await sleep(60); + await frame(); + const open = document.querySelector('.ctx-menu'); + open.scrollTop = open.scrollHeight; + await frame(); + const openRows = open.querySelectorAll('.ctx-menu-item'); + const openLast = openRows[openRows.length - 1]; + cases.push({ case: 'a submenu expanded inside it', + openedAt: 600, viewport: window.innerHeight, + collapsedBottom, + panelHeight: Math.round(open.getBoundingClientRect().height), + panelTop: Math.round(open.getBoundingClientRect().top), + panelBottom: Math.round(open.getBoundingClientRect().bottom), + lastRowBottom: Math.round(openLast.getBoundingClientRect().bottom), + lastRowLabel: openLast.textContent.trim(), + overflows: open.scrollHeight > open.clientHeight + 1 }); + + // 6. A short menu opened at the very bottom still flips above the pointer + // rather than being pinned there and scrolled — the condition deciding + // that was rewritten, so it is measured rather than assumed. + const SHORT = ITEMS.slice(0, 4); + render(html`<${Menu} x=${300} y=${780} items=${SHORT} onClose=${onClose} />`, + document.getElementById('root')); + await frame(); + const short = document.querySelector('.ctx-menu'); + cases.push({ case: 'a short menu opened at the bottom edge', + openedAt: 780, viewport: window.innerHeight, + panelTop: Math.round(short.getBoundingClientRect().top), + panelBottom: Math.round(short.getBoundingClientRect().bottom), + overflows: short.scrollHeight > short.clientHeight + 1 }); + + // 7. And so does a scroll of the page. Not optional: the menu is fixed, so // a grid scrolling under it leaves it pointing at the wrong album. render(html`<${Menu} x=${300} y=${40} items=${ITEMS} onClose=${onClose} />`, document.getElementById('root')); -- cgit v1.2.3