diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-09-16 11:01:02 +0200 |
| commit | 20706fb9a4ec646816b44a10842aa8f58ea0fd75 (patch) | |
| tree | 126be97d794491196c6033ae76d4680f24188595 /packages/meshbay-hub/src/meshbay_hub/static/menu.js | |
| parent | a79a38a22a6145c475f50eeadb79b451aee31c11 (diff) | |
| download | meshbay-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.js | 171 |
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 }; |