1/** 2 * Desktop Action Menu â the desktop half of the actions Menu (md+). 3 * 4 * Discovers the same buttons mobile-actions.js discovers (shared discoverActions / 5 * resolveActionScope), renders them through #tpl-desktop-action-menu / 6 * #tpl-bottom-sheet-item (components/sub-templates.html) and portals the panel 7 * with createPortalPopover, right-aligned to the trigger. 8 * 9 * Stage-8 standard (Dan, 2026-07-10; trigger amended 2026-09-29): EVERY detail-header's 10 * actions live in this menu. The buttons stay in the DOM (hidden) so per-module 11 * affordance flips keep working; discovery re-reads them on every open. 12 * 13 * Template pattern: 14 * <div class="detail-actions-desktop hidden"> 15 * <button id="btn-edit-xxx">...</button> 16 * <button id="btn-archive-xxx">...</button> 17 * </div> 18 * <button class="btn-st btn-st-outline btn-st-sm desktop-action-trigger hidden md:inline-flex" 19 * aria-haspopup="true" aria-expanded="false"> 20 * <i data-lucide="square-menu" class="w-4 h-4"></i> 21 * <span>Menu</span> 22 * <i data-lucide="chevron-down" class="w-3.5 h-3.5 opacity-60"></i> 23 * </button> 24 * 25 * Zero per-module JS needed â works via event delegation on document. 26 */ 27 28import { createPortalPopover } from './popover-position.js'; 29import { resolveActionScope, discoverActions } from './mobile-actions.js'; 30import { afterRender } from './dom.js'; 31 32let initialized = false; 33let menu = null; // { trigger, panel, items, cleanup } 34 35export function initDesktopActions() { 36 if (initialized) return; 37 document.addEventListener('click', handleTriggerClick); 38 initialized = true; 39} 40 41export function destroyDesktopActions() { 42 closeDesktopActionMenu(); 43 document.removeEventListener('click', handleTriggerClick); 44 initialized = false; 45} 46 47/** Dismiss any open menu without disarming the module (router calls this on every route). */ 48export function closeDesktopActionMenu() { 49 const live = menu; 50 if (!live) return; 51 menu = null; 52 document.removeEventListener('click', handleOutsideClick); 53 document.removeEventListener('keydown', handleKeydown); 54 live.cleanup(); 55 // Synchronous: a panel left on screen would paint over whatever the action opens next. 56 live.panel.remove(); 57 live.trigger.setAttribute('aria-expanded', 'false'); 58} 59 60function handleTriggerClick(e) { 61 const trigger = e.target.closest('.desktop-action-trigger'); 62 if (!trigger) return; 63 e.preventDefault(); 64 e.stopPropagation(); 65 66 const wasOpenHere = menu?.trigger === trigger; 67 closeDesktopActionMenu(); 68 if (wasOpenHere) return; 69 70 const scope = resolveActionScope(trigger); 71 const desktopActions = scope?.querySelector('.detail-actions-desktop'); 72 if (!desktopActions) return; 73 74 const items = discoverActions({ trigger, scope, desktopActions }); 75 if (!items.length) return; 76 77 const panel = renderPanel(items); 78 if (!panel) return; 79 80 panel.addEventListener('click', handlePanelClick); 81 const cleanup = createPortalPopover(trigger, panel, { align: 'end', offset: 6 }); 82 menu = { trigger, panel, items, cleanup }; 83 afterRender(panel); 84 cleanup.reposition?.(); 85 86 trigger.setAttribute('aria-expanded', 'true'); 87 document.addEventListener('click', handleOutsideClick); 88 document.addEventListener('keydown', handleKeydown); 89 // The portal puts the panel last in <body>; without this the actions are mouse-only. 90 panel.querySelector('[data-action]')?.focus(); 91} 92 93function renderPanel(items) { 94 const template = $.templates('#tpl-desktop-action-menu'); 95 if (!template) return null; 96 const container = document.createElement('div'); 97 container.innerHTML = template.render({ items: items.map(toViewItem) }); 98 return container.firstElementChild; 99} 100 101function handlePanelClick(e) { 102 const btn = e.target.closest('[data-action]'); 103 if (!btn || !menu) return; 104 const item = menu.items.find(candidate => candidate.id === btn.dataset.action); 105 // Close BEFORE invoking: the popover tier paints above the modal tier. 106 closeDesktopActionMenu(); 107 item?.action?.(); 108} 109 110function handleOutsideClick(e) { 111 if (!menu || menu.panel.contains(e.target) || menu.trigger.contains(e.target)) return; 112 closeDesktopActionMenu(); 113} 114 115function handleKeydown(e) { 116 if (e.key !== 'Escape' || !menu) return; 117 const { trigger } = menu; 118 closeDesktopActionMenu(); 119 trigger.focus(); 120} 121 122/** Same view model BottomSheet builds, so both surfaces feed #tpl-bottom-sheet-item alike. */ 123function toViewItem(item) { 124 return { 125 ...item, 126 classes: [ 127 'bottom-sheet-item', 128 item.active ? 'bottom-sheet-item-active' : '', 129 item.variant ? `bottom-sheet-item-${item.variant}` : ''
130 ].filter(Boolean).join(' '), 131 actionId: item.id || '', 132 badge: item.badge ? String(item.badge) : '' 133 }; 134}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.