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`
`; if (it.items) { const open = expanded === i; return html` ${open && (it.items.length ? html`<${MenuItems} items=${it.items} depth=${depth + 1} onClose=${onClose} />` : html``)} `; } return html` `; })} `; } 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` `; } /** * The dots button. A separate export because every call site needs the same * one and because it must be a real `