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, 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); 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} onResize=${onResize} />` : html`
${it.empty || ''}
`))} `; } return html` `; })} `; } 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 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 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; // 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, grew]); 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. // // But the panel also scrolls *itself*: a playlist's tracklist expands // inside it and is routinely taller than the window. `scroll` does not // bubble, which is why this listener is on the capture phase — and capture // is equally what makes it hear the panel's own scrolling, on the way // down. So the menu closed the instant it was scrolled, by wheel or by // dragging its own scrollbar, and the track being reached for could not be // reached at all. Ask where the scroll came from, not merely that one // happened. const onScroll = (e) => { if (ref.current && e.target instanceof Node && ref.current.contains(e.target)) return; onClose(); }; document.addEventListener('keydown', onKey); document.addEventListener('mousedown', onDown); window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onClose); return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('mousedown', onDown); window.removeEventListener('scroll', onScroll, 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 };