// shared/context-menu.js — generic context-menu framework exposed on // window.zddc.menu. Built so every ZDDC tool can drop a right-click // menu (or any programmatically-opened menu) onto its UI without // shipping its own implementation. // // API: // window.zddc.menu.open({ x, y, items, context }) // window.zddc.menu.close() // // `items` is an array (or a function returning an array, evaluated // against `context` at open-time). Each entry is one of: // { label, action, icon?, accel?, disabled?, visible?, danger?, tooltip? } // — a normal menu item; `action(ctx)` fires on click/Enter. // `tooltip` (string or fn(ctx)) sets the row's title attribute — // useful for explaining WHY a disabled item is unavailable // ("You don't have write access here", etc.). // { label, checked, action, ... } // — toggle item; `checked` may be a bool or a fn(ctx). Renders // a ✓ in the gutter when truthy. // { label, items, ... } // — submenu; `items` may itself be an array or fn(ctx). // { separator: true } // — horizontal divider. Leading/trailing/duplicate separators // are collapsed automatically so callers can build items // conditionally without managing dividers. // // Any of `label`, `checked`, `visible`, `disabled`, `tooltip`, and // `items` may be a function — each is invoked with the context object // so callers can render fully context-aware menus from a single // declarative config. // // Keyboard: ArrowUp/Down move within a menu, ArrowRight opens a // submenu, ArrowLeft / Escape backs up one level (or closes if // already at the root), Enter / Space activates. Click-outside, // window blur, scroll, and resize all dismiss. (function () { 'use strict'; if (!window.zddc) window.zddc = {}; if (window.zddc.menu) return; var SUBMENU_HOVER_MS = 180; // Open menu stack — index 0 is the root, deeper entries are // nested submenus. Each frame: { el, depth, parentRow? }. var stack = []; var rootContext = null; var submenuTimer = null; function resolve(val, ctx) { return typeof val === 'function' ? val(ctx) : val; } function close() { if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; } for (var i = 0; i < stack.length; i++) { var fr = stack[i]; if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el); } stack = []; rootContext = null; document.removeEventListener('mousedown', onDocMouseDown, true); document.removeEventListener('keydown', onDocKeyDown, true); // blur is bound WITHOUT capture so we only react to the window // itself losing focus — capturing would also fire when any // inner element blurs (which happens every time the user moves // the mouse between menu rows, since hover focuses the row). window.removeEventListener('blur', close); window.removeEventListener('resize', close, true); window.removeEventListener('scroll', onDocScroll, true); } function open(opts) { opts = opts || {}; close(); rootContext = opts.context || {}; var items = resolve(opts.items, rootContext) || []; var el = buildMenu(items, rootContext, 0); document.body.appendChild(el); position(el, opts.x || 0, opts.y || 0, null); stack.push({ el: el, depth: 0 }); document.addEventListener('mousedown', onDocMouseDown, true); document.addEventListener('keydown', onDocKeyDown, true); window.addEventListener('blur', close); window.addEventListener('resize', close, true); window.addEventListener('scroll', onDocScroll, true); focusFirst(el); } // ── Building ───────────────────────────────────────────────────────── function collapseSeparators(items) { var out = []; for (var i = 0; i < items.length; i++) { var it = items[i]; if (it && it.separator) { if (out.length === 0) continue; if (out[out.length - 1].separator) continue; out.push(it); } else if (it) { out.push(it); } } while (out.length && out[out.length - 1].separator) out.pop(); return out; } function buildMenu(items, ctx, depth) { var menu = document.createElement('div'); menu.className = 'zddc-menu'; menu.setAttribute('role', 'menu'); menu.dataset.depth = String(depth); // Suppress the native context menu over our own menu. menu.addEventListener('contextmenu', function (e) { e.preventDefault(); }); var filtered = items.filter(function (it) { if (!it) return false; if (it.separator) return true; if ('visible' in it && !resolve(it.visible, ctx)) return false; return true; }); var pruned = collapseSeparators(filtered); for (var i = 0; i < pruned.length; i++) { menu.appendChild(buildRow(pruned[i], ctx, depth)); } return menu; } function buildRow(item, ctx, depth) { if (item.separator) { var sep = document.createElement('div'); sep.className = 'zddc-menu__sep'; sep.setAttribute('role', 'separator'); return sep; } var hasSub = !!item.items; var isToggle = ('checked' in item); var disabled = 'disabled' in item ? !!resolve(item.disabled, ctx) : false; var row = document.createElement('div'); row.className = 'zddc-menu__item'; if (item.danger) row.classList.add('zddc-menu__item--danger'); if (hasSub) row.classList.add('zddc-menu__item--has-sub'); if (disabled) { row.classList.add('is-disabled'); row.setAttribute('aria-disabled', 'true'); } if ('tooltip' in item) { var tip = resolve(item.tooltip, ctx); if (tip) row.title = String(tip); } row.setAttribute('role', hasSub ? 'menuitem' : (isToggle ? 'menuitemcheckbox' : 'menuitem')); row.tabIndex = -1; // Check gutter — present on every row so columns align. var check = document.createElement('span'); check.className = 'zddc-menu__check'; if (isToggle) { var on = !!resolve(item.checked, ctx); if (on) { check.textContent = '✓'; row.classList.add('is-checked'); row.setAttribute('aria-checked', 'true'); } else { row.setAttribute('aria-checked', 'false'); } } row.appendChild(check); // Icon column. var icon = document.createElement('span'); icon.className = 'zddc-menu__icon'; if (item.icon) icon.textContent = item.icon; row.appendChild(icon); // Label. var label = document.createElement('span'); label.className = 'zddc-menu__label'; label.textContent = String(resolve(item.label, ctx) || ''); row.appendChild(label); // Accelerator hint (visual only; no binding). var accel = document.createElement('span'); accel.className = 'zddc-menu__accel'; if (item.accel) accel.textContent = item.accel; row.appendChild(accel); // Submenu arrow. var arrow = document.createElement('span'); arrow.className = 'zddc-menu__arrow'; if (hasSub) arrow.textContent = '▸'; row.appendChild(arrow); if (!disabled) { row.addEventListener('mouseenter', function () { // Hovering any row in a menu collapses deeper menus // (so traversing siblings closes a previously-opened // submenu) and re-focuses this row for keyboard nav. closeBelow(depth); if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; } if (hasSub) { submenuTimer = setTimeout(function () { openSubmenu(row, item, ctx, depth + 1, false); }, SUBMENU_HOVER_MS); } try { row.focus({ preventScroll: true }); } catch (_e) { row.focus(); } }); row.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); if (submenuTimer) { clearTimeout(submenuTimer); submenuTimer = null; } if (hasSub) { openSubmenu(row, item, ctx, depth + 1, true); return; } activate(item, ctx); }); } return row; } function activate(item, ctx) { try { if (typeof item.action === 'function') item.action(ctx); } finally { close(); } } function openSubmenu(parentRow, parentItem, ctx, depth, takeFocus) { closeBelow(depth - 1); var items = resolve(parentItem.items, ctx) || []; var el = buildMenu(items, ctx, depth); document.body.appendChild(el); var rect = parentRow.getBoundingClientRect(); // Slight overlap so pointer-cross feels continuous. position(el, rect.right - 2, rect.top - 4, parentRow); stack.push({ el: el, depth: depth, parentRow: parentRow }); if (takeFocus) focusFirst(el); } function closeBelow(depth) { while (stack.length && stack[stack.length - 1].depth > depth) { var fr = stack.pop(); if (fr.el && fr.el.parentNode) fr.el.parentNode.removeChild(fr.el); } } // ── Positioning ────────────────────────────────────────────────────── function position(el, x, y, parentRow) { // Fixed so we ignore document scroll; measure after layout. el.style.position = 'fixed'; el.style.left = '0px'; el.style.top = '0px'; el.style.visibility = 'hidden'; var rect = el.getBoundingClientRect(); var w = rect.width; var h = rect.height; var vw = window.innerWidth; var vh = window.innerHeight; var leftX = x; if (leftX + w > vw - 4) { if (parentRow) { var pr = parentRow.getBoundingClientRect(); leftX = pr.left - w + 2; // flip submenu to the left } else { leftX = Math.max(4, x - w); // flip root menu left of cursor } } if (leftX < 4) leftX = 4; var topY = y; if (topY + h > vh - 4) topY = Math.max(4, vh - h - 4); if (topY < 4) topY = 4; el.style.left = leftX + 'px'; el.style.top = topY + 'px'; el.style.visibility = ''; } // ── Focus + keyboard ───────────────────────────────────────────────── function focusable(menuEl) { return Array.prototype.slice.call( menuEl.querySelectorAll('.zddc-menu__item:not(.is-disabled)')); } function focusFirst(menuEl) { var items = focusable(menuEl); if (items.length) { try { items[0].focus({ preventScroll: true }); } catch (_e) { items[0].focus(); } } } function onDocMouseDown(e) { for (var i = 0; i < stack.length; i++) { if (stack[i].el.contains(e.target)) return; } close(); } // Scroll listener uses capture so scrolls inside any element (the // tree pane, the document, etc.) dismiss the menu — its position // is fixed and would otherwise hang over stale content. Scrolls // that originate inside the menu itself (a future tall submenu) // are ignored. function onDocScroll(e) { var t = e.target; for (var i = 0; i < stack.length; i++) { if (stack[i].el === t || (t && t.nodeType === 1 && stack[i].el.contains(t))) { return; } } close(); } function onDocKeyDown(e) { if (!stack.length) return; var top = stack[stack.length - 1]; var items = focusable(top.el); var active = document.activeElement; var idx = items.indexOf(active); switch (e.key) { case 'Escape': e.preventDefault(); if (stack.length > 1) { var fr = stack.pop(); if (fr.el.parentNode) fr.el.parentNode.removeChild(fr.el); if (fr.parentRow) fr.parentRow.focus(); } else { close(); } return; case 'ArrowDown': e.preventDefault(); if (!items.length) return; items[idx < 0 ? 0 : (idx + 1) % items.length].focus(); return; case 'ArrowUp': e.preventDefault(); if (!items.length) return; items[idx < 0 ? items.length - 1 : (idx - 1 + items.length) % items.length].focus(); return; case 'Home': e.preventDefault(); if (items.length) items[0].focus(); return; case 'End': e.preventDefault(); if (items.length) items[items.length - 1].focus(); return; case 'ArrowRight': if (active && active.classList.contains('zddc-menu__item--has-sub')) { e.preventDefault(); active.click(); } return; case 'ArrowLeft': if (stack.length > 1) { e.preventDefault(); var fr2 = stack.pop(); if (fr2.el.parentNode) fr2.el.parentNode.removeChild(fr2.el); if (fr2.parentRow) fr2.parentRow.focus(); } return; case 'Enter': case ' ': if (active) { e.preventDefault(); active.click(); } return; } } window.zddc.menu = { open: open, close: close }; })();