github.com/derat/nup@v0.0.0-20230418113745-15592ba7c620/web/menu.ts (about) 1 // Copyright 2022 Daniel Erat. 2 // All rights reserved. 3 4 import { 5 commonStyles, 6 createElement, 7 createShadow, 8 createTemplate, 9 } from './common.js'; 10 11 const menuStyle = createTemplate(` 12 <style> 13 .item { 14 cursor: default; 15 padding: 6px 12px; 16 user-select: none; 17 } 18 .item:hover { 19 background-color: var(--menu-hover-color); 20 } 21 .item:first-child { 22 padding-top: 8px; 23 } 24 .item:last-child { 25 padding-bottom: 8px; 26 } 27 .item .hotkey { 28 color: var(--text-label-color); 29 display: inline-block; 30 float: right; 31 margin-left: var(--margin); 32 text-align: right; 33 min-width: 50px; 34 } 35 hr { 36 background-color: var(--border-color); 37 border: 0; 38 height: 1px; 39 margin: 4px 0; 40 } 41 </style> 42 `); 43 44 // Number of open menus. 45 let numMenus = 0; 46 47 // Creates and displays a simple context menu at the specified location. 48 // Returns a <dialog> element containing a <span> acting as a shadow root. 49 export function createMenu( 50 x: number, 51 y: number, 52 items: MenuItem[], 53 alignRight = false 54 ) { 55 const menu = createElement( 56 'dialog', 57 'menu', 58 document.body 59 ) as HTMLDialogElement; 60 menu.addEventListener('close', () => { 61 document.body.removeChild(menu); 62 numMenus--; 63 }); 64 menu.addEventListener('click', (e) => { 65 const rect = menu.getBoundingClientRect(); 66 if ( 67 e.clientX < rect.left || 68 e.clientX > rect.right || 69 e.clientY < rect.top || 70 e.clientY > rect.bottom 71 ) { 72 menu.close(); 73 } 74 }); 75 76 // It seems like it isn't possible to attach a shadow root directly to 77 // <dialog>, so add a wrapper element first. 78 const wrapper = createElement('span', null, menu); 79 const shadow = createShadow(wrapper, menuStyle); 80 shadow.adoptedStyleSheets = [commonStyles]; 81 82 const hotkeys = items.some((it) => it.hotkey); 83 for (const item of items) { 84 if (item.text === '-') { 85 createElement('hr', null, shadow); 86 } else { 87 const el = createElement('div', 'item', shadow, item.text); 88 if (item.id) el.id = item.id; 89 if (hotkeys) createElement('span', 'hotkey', el, item.hotkey ?? ''); 90 el.addEventListener('click', (e) => { 91 e.stopPropagation(); 92 menu.close(); 93 if (item.cb) item.cb(); 94 }); 95 } 96 } 97 98 // For some reason, the menu's clientWidth and clientHeight seem to initially 99 // be 0 after switching to <dialog>. Deferring the calculation of the menu's 100 // position seems to work around this. 101 window.setTimeout(() => { 102 if (alignRight) { 103 menu.style.right = `${x}px`; 104 } else { 105 // Keep the menu onscreen. 106 menu.style.left = 107 x + menu.clientWidth <= window.innerWidth 108 ? `${x}px` 109 : `${x - menu.clientWidth}px`; 110 } 111 menu.style.top = 112 y + menu.clientHeight <= window.innerHeight 113 ? `${y}px` 114 : `${y - menu.clientHeight}px`; 115 116 // Only show the menu after it's been positioned. 117 menu.classList.add('ready'); 118 }); 119 120 numMenus++; 121 menu.showModal(); 122 return menu; 123 } 124 125 interface MenuItem { 126 text: string; // menu item text, or '-' to insert separator instead 127 cb?: () => void; // callback to run when clicked 128 id?: string; // ID for element (used in tests) 129 hotkey?: string; // text describing menu's accelerator 130 } 131 132 // Returns true if a menu is currently shown. 133 export const isMenuShown = () => numMenus > 0;