馃悕馃悕馃悕
at dev 242 lines 6.1 kB view raw
1 2$css(` 3 .context-backdrop { 4 position: absolute; 5 margin: 0; 6 padding: 0; 7 border: none; 8 width: 100%; 9 height: 100%; 10 display: none; 11 } 12 13 .context-menu { 14 position: fixed; 15 background-color: var(--main-background); 16 border: 1px solid var(--main-faded); 17 border-radius: 2px; 18 min-width: 8rem; 19 font-size: 0.875rem; 20 user-select: none; 21 z-index: 10; 22 } 23 24 .context-menu[centered] { 25 position: absolute; 26 left: 50%; 27 top: 50%; 28 transform: translate(-50%, -50%); 29 } 30 31 .context-menu-item { 32 padding: 0.2rem 0.5rem; 33 cursor: pointer; 34 white-space: nowrap; 35 color: var(--main-solid); 36 background-color: var(--main-background); 37 display: block; 38 width: 100%; 39 border-radius: 0; 40 text-align: left; 41 height: auto; 42 } 43 44 .context-menu-item:focus { 45 outline: none; 46 background-color: var(--main-faded); 47 } 48 49 .context-menu-item:hover { 50 background-color: var(--main-faded); 51 } 52 53 .context-menu-item.disabled { 54 opacity: 0.5; 55 cursor: default; 56 } 57 58 .context-menu-item.disabled:hover { 59 background-color: transparent; 60 } 61 62 .context-menu-separator { 63 height: 1px; 64 background-color: var(--main-faded); 65 margin: 0.25rem 0; 66 } 67`); 68 69function collectItems(element) { 70 const items = []; 71 72 for (let node = element; node; node = node.parentNode) { 73 if (node.$contextMenu !== null && node.$contextMenu !== undefined) { 74 const nodeItems = $actualize(node.$contextMenu.items); 75 for (const item of nodeItems.map($actualize)) { 76 if (Array.isArray(item) && item[0] && Array.isArray(item[0])) { 77 items.push(...item); 78 } 79 else { 80 items.push(item); 81 } 82 } 83 84 if (node.$contextMenu.override) { 85 break; 86 } 87 } 88 } 89 90 return items; 91} 92 93 94const backdrop = document.createElement("div"); 95backdrop.className = "context-backdrop"; 96 97const menu = document.createElement("div"); 98menu.$ = {}; 99menu.className = "context-menu"; 100menu.setAttribute("role", "menu"); 101menu.setAttribute("aria-orientation", "vertical"); 102 103menu.addEventListener("mouseenter", () => { 104 //menu.firstChild?.blur(); 105 menu.focus(); 106}); 107 108const onBackdropClick = (e) => { 109 if (e.target !== backdrop) return; 110 e.preventDefault(); 111 112 backdrop.style.display = "none"; 113 menu.$.previousFocus?.focus(); 114 115 // don't make user click twice when clicking away from the context menu 116 const clickTarget = document.elementFromPoint(e.clientX, e.clientY); 117 if (clickTarget) { 118 clickTarget.focus(); 119 clickTarget.dispatchEvent(new MouseEvent(e.type, { 120 bubbles: true, 121 cancelable: true, 122 clientX: e.clientX, 123 clientY: e.clientY 124 })); 125 } 126}; 127 128backdrop.addEventListener("click", onBackdropClick); 129backdrop.addEventListener("contextmenu", onBackdropClick); 130 131menu.addEventListener("keydown", (e) => { 132 if (!["ArrowDown", "ArrowUp", "j", "k", "Escape"].includes(e.key)) return; 133 134 e.preventDefault(); 135 e.stopPropagation(); 136 137 if (e.key === "Escape") { 138 backdrop.style.display = "none"; 139 menu.$.previousFocus?.focus(); 140 return; 141 } 142 143 const currentItem = document.activeElement; 144 if (!menu.contains(currentItem)) { 145 menu.firstElementChild?.focus(); 146 return; 147 } 148 149 let nextItem; 150 if (e.key === "ArrowDown" || e.key === "j") { 151 nextItem = currentItem.nextElementSibling || menu.firstElementChild; 152 } else { 153 nextItem = currentItem.previousElementSibling || menu.lastElementChild; 154 } 155 156 nextItem.focus(); 157}); 158 159backdrop.appendChild(menu); 160 161const showMenu = (target, position = null) => { 162 document.body.appendChild(backdrop); 163 backdrop.style.display = "block"; 164 menu.$.previousFocus = document.activeElement; 165 menu.firstChild?.focus(); 166 167 const bounds = target.getBoundingClientRect(); 168 169 if (!position) { 170 menu.setAttribute("centered", ""); 171 menu.style.left = ""; 172 menu.style.top = ""; 173 return; 174 } 175 176 const {x,y} = position; 177 178 menu.removeAttribute("centered"); 179 menu.style.left = x + "px"; 180 menu.style.top = y + "px"; 181 182 const rect = menu.getBoundingClientRect(); 183 184 if (rect.right > bounds.right) { 185 menu.style.left = (x - rect.width) + "px"; 186 } 187 if (rect.left < bounds.left) { 188 menu.style.left = bounds.left + "px"; 189 } 190 if (rect.bottom > bounds.bottom) { 191 menu.style.top = (y - rect.height) + "px"; 192 } 193 if (rect.top < bounds.top) { 194 menu.style.top = bounds.top + "px"; 195 } 196}; 197 198document.addEventListener("contextmenu", (e) => { 199 menu.replaceChildren(); 200 201 const items = collectItems(e.target); 202 203 if (items.length === 0) return; 204 205 items.forEach(item => { 206 if (!item) return; 207 if (item === "separator") { // TODO improve this 208 const separator = document.createElement("div"); 209 separator.className = "context-menu-separator"; 210 menu.appendChild(separator); 211 return; 212 } 213 214 const menuItem = document.createElement("button"); 215 menuItem.className = "context-menu-item"; 216 menu.setAttribute("role", "menuItem"); 217 menu.setAttribute("tabIndex", "-1"); 218 219 menuItem.textContent = item[0]; 220 221 const select = async () => { 222 backdrop.style.display = "none"; 223 menu.$.previousFocus?.focus(); 224 await item[1](); 225 }; 226 227 menuItem.onclick = select; 228 menuItem.addEventListener("keydown", (e) => { 229 if (e.key === "o" || e.key === "Enter") { 230 select(); 231 e.stopPropagation(); 232 } 233 }); 234 235 menu.appendChild(menuItem); 236 }); 237 238 e.preventDefault(); 239 240 showMenu(e.target, {x: e.clientX, y: e.clientY}); 241}); 242