aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/src/meshbay_hub/static/menu.js
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 11:01:02 +0200
commit20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch)
tree126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/src/meshbay_hub/static/menu.js
parenta79a38a22a6145c475f50eeadb79b451aee31c11 (diff)
downloadmeshbay-20706fb9a4ec646816b44a10842aa8f58ea0fd75.tar.gz
music: play, play next, add to queue
The player's queue could only be replaced: every onPlayQueue reset tracks/order/pos together. It becomes one reducer (queue-ops.js) with an `op`, because two appends batched into one tick cannot both read the track count out of separate useStates. A shared pop-up menu (menu.js) carries the three verbs, on right-click and on a dots button. A track row is now a div holding two buttons: a button cannot contain a button. Found by the browser probe: both music wrappers took two arguments and forwarded two, so every "add to queue" arrived as a plain play. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/menu.js')
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/menu.js171
1 files changed, 171 insertions, 0 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js
new file mode 100644
index 0000000..4aa08ca
--- /dev/null
+++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js
@@ -0,0 +1,171 @@
+import {
+ html, useState, useEffect, useRef, useCallback,
+} from './vendor/htm-preact.js';
+import { Icon } from './icon.js';
+
+/**
+ * A pop-up menu, opened at a point — the shared component the media views use
+ * for "what do you want to do with this album" (docs/playlists.md §10.1).
+ *
+ * Two affordances, both always present, because neither covers everyone:
+ * right-click is what a desktop reader reaches for and leaves no chrome on a
+ * grid of covers, and a dots button is the only thing that exists on a
+ * touchscreen. `openAt` takes either event and needs to know which.
+ *
+ * **Submenus expand in place** rather than flying out sideways. The account
+ * menu's language list already does this (`app.js`), and a flyout is the one
+ * shape that cannot work here: the longest submenu is a playlist's tracklist,
+ * and at a phone's 400px there is nowhere for a second panel to go. Expanding
+ * downward needs no flipping, no hover intent, and no separate mobile design.
+ *
+ * `items` is a flat list, each entry one of:
+ * { label, icon?, onSelect } an action
+ * { label, icon?, items, empty? } a submenu, expanded in place
+ * { divider: true }
+ */
+
+// Kept away from the viewport edges; `.ctx-menu` sets the width this assumes.
+const MARGIN = 8;
+
+function useMenu() {
+ const [menu, setMenu] = useState(null); // { x, y, items }
+
+ const close = useCallback(() => setMenu(null), []);
+
+ /**
+ * Open at `e`. A `contextmenu` event carries the pointer; a click on a dots
+ * button carries only the button, so the menu hangs off its bottom-right —
+ * read synchronously, because `currentTarget` is null once the handler has
+ * returned.
+ */
+ const openAt = useCallback((e, items) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (e.type === 'contextmenu') {
+ setMenu({ x: e.clientX, y: e.clientY, items });
+ return;
+ }
+ const r = e.currentTarget.getBoundingClientRect();
+ setMenu({ x: r.right, y: r.bottom + 4, items });
+ }, []);
+
+ return { menu, openAt, close };
+}
+
+function MenuItems({ items, depth, onClose }) {
+ const [expanded, setExpanded] = useState(null);
+
+ return html`
+ ${items.map((it, i) => {
+ if (it.divider) return html`<div class="ctx-menu-divider" key=${`d${i}`}></div>`;
+
+ if (it.items) {
+ const open = expanded === i;
+ return html`
+ <button class="ctx-menu-item" key=${it.key || it.label}
+ style=${depth ? `padding-left: ${14 + depth * 14}px` : null}
+ onClick=${(e) => { e.stopPropagation(); setExpanded(open ? null : i); }}>
+ ${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`}
+ <span class="ctx-menu-label">${it.label}</span>
+ <${Icon} name="chevron" cls="ctx-menu-caret ${open ? 'flip' : ''}" />
+ </button>
+ ${open && (it.items.length
+ ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />`
+ : html`<div class="ctx-menu-empty"
+ style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`)}
+ `;
+ }
+
+ return html`
+ <button class="ctx-menu-item ${it.danger ? 'danger' : ''}" key=${it.key || it.label}
+ style=${depth ? `padding-left: ${14 + depth * 14}px` : null}
+ disabled=${!!it.disabled}
+ onClick=${() => {
+ // Closed first: an action that opens a dialog of its own must not
+ // leave this hanging over it.
+ onClose();
+ if (it.onSelect) it.onSelect();
+ }}>
+ ${it.icon && html`<${Icon} name=${it.icon} cls="ctx-menu-icon" />`}
+ <span class="ctx-menu-label">${it.label}</span>
+ ${it.hint && html`<span class="ctx-menu-hint">${it.hint}</span>`}
+ </button>
+ `;
+ })}
+ `;
+}
+
+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.
+ //
+ // 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.
+ 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 });
+ // eslint-disable-next-line
+ }, [x, y, items]);
+
+ useEffect(() => {
+ const onKey = (e) => { if (e.key === 'Escape') onClose(); };
+ // `mousedown`, not `click`: the click that follows the right-click which
+ // opened this would otherwise close it again before anything is drawn.
+ const onDown = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) onClose();
+ };
+ // Scroll closes it, and this one is not optional. The media toolbars are
+ // sticky bands and the grid scrolls underneath them, so a menu that
+ // survives a scroll is a menu still pointing at the album it was opened
+ // on while sitting over a completely different one.
+ document.addEventListener('keydown', onKey);
+ document.addEventListener('mousedown', onDown);
+ window.addEventListener('scroll', onClose, true);
+ window.addEventListener('resize', onClose);
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.removeEventListener('mousedown', onDown);
+ window.removeEventListener('scroll', onClose, true);
+ window.removeEventListener('resize', onClose);
+ };
+ }, [onClose]);
+
+ return html`
+ <div class="ctx-menu" ref=${ref} role="menu"
+ style=${place
+ ? `left: ${place.left}px; top: ${place.top}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} />
+ </div>
+ `;
+}
+
+/**
+ * The dots button. A separate export because every call site needs the same
+ * one and because it must be a real `<button>` sibling of the row's own
+ * action, never nested inside it — see `music-app.js`.
+ */
+function MenuDots({ onOpen, title, cls = '' }) {
+ return html`
+ <button class="ctx-dots ${cls}" title=${title || ''} aria-haspopup="menu"
+ onClick=${onOpen}>
+ <${Icon} name="dots" />
+ </button>
+ `;
+}
+
+export { Menu, MenuDots, useMenu };