aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/menu.js
diff options
context:
space:
mode:
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/menu.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/menu.js67
1 files changed, 49 insertions, 18 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>
`;
}