diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static')
| -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; |