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 * { label, icon?, loadItems, empty? } the same, fetched when expanded * { divider: true } * * `loadItems` exists for the one submenu whose contents are not already in * hand: a playlist's tracklist, which is read out of IndexedDB. Reading every * playlist's tracks to build a menu nobody may open would mean a ten-thousand * track list read on every click of the button. */ // 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); // index -> the items that came back, or 'loading'. const [loaded, setLoaded] = useState({}); const toggle = useCallback((i, it) => { if (expanded === i) { setExpanded(null); return; } setExpanded(i); if (!it.loadItems || loaded[i] !== undefined) return; setLoaded((prev) => ({ ...prev, [i]: 'loading' })); Promise.resolve(it.loadItems()) .then((rows) => setLoaded((prev) => ({ ...prev, [i]: rows || [] }))) // A submenu that cannot be filled renders as empty rather than as a // spinner nothing will ever replace. .catch(() => setLoaded((prev) => ({ ...prev, [i]: [] }))); }, [expanded, loaded]); return html` ${items.map((it, i) => { if (it.divider) return html`
`; if (it.items || it.loadItems) { const open = expanded === i; const rows = it.items || loaded[i]; const pending = rows === 'loading' || (it.loadItems && rows === undefined); return html` ${open && (pending ? html`
` : (rows && rows.length ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />` : html`
${it.empty || ''}
`))} `; } 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 ` `; } export { Menu, MenuDots, useMenu };