PageSourceSearch

https://smrtbld.ai/js/utils/desktop-actions.js

js smrtbld.ai collected 2026-10-05 15:08:06 UTC 4,878 bytes, 134 lines download raw bytes

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.