diff options
Diffstat (limited to 'packages/meshbay-hub/src/meshbay_hub/static/menu.js')
| -rw-r--r-- | packages/meshbay-hub/src/meshbay_hub/static/menu.js | 41 |
1 files changed, 33 insertions, 8 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/menu.js b/packages/meshbay-hub/src/meshbay_hub/static/menu.js index 4aa08ca..1c6a96a 100644 --- a/packages/meshbay-hub/src/meshbay_hub/static/menu.js +++ b/packages/meshbay-hub/src/meshbay_hub/static/menu.js @@ -19,9 +19,15 @@ import { Icon } from './icon.js'; * 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?, 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. @@ -54,25 +60,44 @@ function useMenu() { 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`<div class="ctx-menu-divider" key=${`d${i}`}></div>`; - if (it.items) { + 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` <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); }}> + onClick=${(e) => { e.stopPropagation(); toggle(i, it); }}> ${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>`)} + ${open && (pending + ? html`<div class="ctx-menu-empty" + style=${`padding-left: ${28 + depth * 14}px`}><span class="spinner"></span></div>` + : (rows && rows.length + ? html`<${MenuItems} items=${rows} depth=${depth + 1} onClose=${onClose} />` + : html`<div class="ctx-menu-empty" + style=${`padding-left: ${28 + depth * 14}px`}>${it.empty || ''}</div>`))} `; } |