1((exports) => { 2 3/** 4 * @template {keyof HTMLElementTagNameMap} K 5 * @param {K} tagName 6 * @param {Record<string, string>} [attrs] 7 * @returns {HTMLElementTagNameMap[K]} 8 */ 9function E(tagName, attrs) { 10 const el = document.createElement(tagName); 11 if (attrs) { 12 for (const key in attrs) { 13 if (key === "class") { 14 el.className = attrs[key]; 15 } else { 16 el.setAttribute(key, attrs[key]); 17 } 18 } 19 } 20 return el; 21} 22 23let uid_counter = 0; 24function uid() { 25 // make id from counter (guaranteeing page-local uniqueness) 26 // and random base 36 number (making it look random, so there's no temptation to use it as a sequence) 27 // Note: Math.random().toString(36).slice(2) can give empty string 28 return (uid_counter++).toString(36) + Math.random().toString(36).slice(2); 29} 30 31// & defines access keys (contextual hotkeys) in menus and buttons and form labels, which get underlined in the UI. 32// & can be escaped by doubling it, e.g. "&Taskbar && Start Menu" for "Taskbar & Start Menu" with T as the access key. 33/** @type {AccessKeys} */ 34const AccessKeys = { 35 escape: function (label) { 36 // Escapes all ampersands in the label, so that they are not treated as access keys. 37 // Useful for dynamic menus like a list of history entries, where page titles shouldn't be spuriously interpreted as having access keys. 38 // Double ampersands are still escaped ("&&" becomes "&&&&"), so that "&&" isn't spuriously interpreted as "&". 39 return label.replace(/&/g, "&&"); 40 }, 41 unescape: function (label) { 42 // Un-escapes all double ampersands in the label. 43 // For rendering, use toHTML() or toFragment() instead. 44 return label.replace(/&&/g, "&"); 45 }, 46 indexOf: function (label) { 47 // Returns the index of the ampersand that defines an access key, or -1 if not present. 48 49 // return label.search(/(?<!&)&(?!&|\s)/); // not enough browser support for negative lookbehind assertions 50 51 // The space here handles beginning-of-string matching and counteracts the offset for the [^&] so it acts like a negative lookbehind 52 return ` ${label}`.search(/[^&]&[^&\s]/); 53 }, 54 has: function (label) { 55 // Returns true if the label has an access key. 56 return this.indexOf(label) >= 0; 57 }, 58 get: function (label) { 59 // Returns the access key for the label, or null if none. 60 // Used by MenuBar and MenuPopup to trigger the menu item with the access key. 61 // Can be used for handling access keys in other UI, for example in form labels. 62 const index = this.indexOf(label); 63 if (index >= 0) { 64 return label.charAt(index + 1).toUpperCase(); 65 } 66 return null; 67 }, 68 remove: function (label) { 69 // Removes the access key from the label. 70 // Like toText() but with a special case to remove parentheticals like " (&N)". 71 const parentheticalRegex = /\s?\(&[^&]\)/; 72 if (parentheticalRegex.test(label)) { 73 return this.unescape(label.replace(parentheticalRegex, "")); 74 } 75 return this.toText(label); 76 }, 77 toText: function (label) { 78 // Removes the access key indicator from the label. 79 // This is like toHTML() but for plain text. 80 // Note: while often access keys are part of a word, like "&New", 81 // in translations they are often indicated separately, like "ìë¡ ë§ë¤ê¸° (&N)", 82 // since the access key stays the same, but the letter is no longer part of the word (or even the alphabet). 83 // This doesn't remove strings like " (&N)", it will just remove the "&" and leave "ìë¡ ë§ë¤ê¸° (N)". 84 const index = this.indexOf(label); 85 if (index >= 0) { 86 return this.unescape(label.substring(0, index)) + this.unescape(label.substring(index + 1)); 87 } 88 return this.unescape(label); 89 // old version, not un-escaping: 90 // return label.replace(/\s?\(&.\)/, "").replace(/([^&]|^)&([^&\s])/, "$1$2"); 91 }, 92 toHTML: function (label) { 93 // Returns the label with the access key underlined (or with whatever .menu-hotkey styling), HTML-escaped. 94 const fragment = this.toFragment(label); 95 const dummy = document.createElement("div"); 96 dummy.appendChild(fragment); 97 return dummy.innerHTML; 98 99 // old version, not escaping HTML: 100 // return label.replace(/([^&]|^)&([^&\s])/, "$1<span class='menu-hotkey'>
100$2</span>").replace(/&&/g, "&"); 101 102 // alternative version, building an HTML string: 103 // const index = this.indexOf(label); 104 // if (index >= 0) { 105 // return escapeHTML(this.unescape(label.substring(0, index))) + "<u>" + escapeHTML(label.charAt(index + 1)) + "</u>" + escapeHTML(this.unescape(label.substring(index + 2))); 106 // } else { 107 // return escapeHTML(this.unescape(label)); 108 // } 109 }, 110 toFragment: function (label) { 111 // Returns a DocumentFragment of the label with the access key underlined (or with whatever .menu-hotkey styling) 112 const fragment = document.createDocumentFragment(); 113 const index = this.indexOf(label); 114 if (index >= 0) { 115 fragment.appendChild(document.createTextNode(this.unescape(label.substring(0, index)))); 116 // TODO: change class to .access-key, because they can appear outside menus, and "hotkey" is too generic 117 // also, I could use the <u> tag, since that gives the default styling (but then it'd have to be reset if you don't want it...) 118 const span = E("span", { class: "menu-hotkey" }); 119 span.appendChild(document.createTextNode(label.charAt(index + 1))); 120 fragment.appendChild(span); 121 fragment.appendChild(document.createTextNode(this.unescape(label.substring(index + 2)))); 122 } else { 123 fragment.appendChild(document.createTextNode(this.unescape(label))); 124 } 125 return fragment; 126 }, 127}; 128 129// @TODO: support dynamic menus (e.g. a list of history entries, contextually shown options, or a contextually named "Undo <action>" label; Explorer has all these things) 130 131const MENU_DIVIDER = "MENU_DIVIDER"; 132 133const MAX_MENU_NESTING = 1000; 134 135/** @type {HTMLElement | null} */ 136let last_focus_outside_menus = null; 137function track_focus() { 138 if ( 139 document.activeElement && 140 document.activeElement.tagName !== "BODY" && 141 document.activeElement.tagName !== "HTML" && 142 !document.activeElement.closest(".menus, .menu-popup") 143 ) { 144 last_focus_outside_menus = /** @type {HTMLElement} */(document.activeElement); 145 } 146} 147if (typeof window !== "undefined") { 148 window.addEventListener("focusin", track_focus); 149 window.addEventListener("focusout", track_focus); 150} 151 152let internal_z_counter = 1; 153function get_new_menu_z_index() { 154 // integrate with the OS window z-indexes, if applicable 155 // but don't depend on $Window existing, the modules should be independent 156 if (typeof $Window !== "undefined") { 157 return ($Window.Z_INDEX++) + MAX_MENU_NESTING; // MAX_MENU_NESTING is needed because the window gets brought to the top 158 } 159 return (++internal_z_counter) + MAX_MENU_NESTING; 160} 161 162/** 163 * @param {OSGUITopLevelMenus} menus 164 * @constructor 165 */ 166function MenuBar(menus) { 167 if (!(this instanceof MenuBar)) { 168 return new MenuBar(menus); 169 } 170 171 const menus_el = E("div", { 172 class: "menus", 173 role: "menubar", 174 "aria-label": "Application Menu", 175 }); 176 menus_el.style.touchAction = "none"; 177 178 /** 179 * @returns {"ltr" | "rtl"} writing/layout direction 180 */ 181 function get_direction() { 182 return window.get_direction ? window.get_direction() : /** @type {"ltr" | "rtl"} */(getComputedStyle(menus_el).direction); 183 } 184 185 let selecting_menus = false; // state where you can glide between menus without clicking 186 187 /** 188 * @type {{ 189 * menu_button_el: HTMLElement, 190 * menu_popup_el: HTMLElement, 191 * menus_key: string, 192 * access_key: string | null, 193 * open_top_level_menu: (source: "click" | "keydown") => void, 194 * }[]} 195 */ 196 const top_level_menus = []; 197 /** index of the top level menu that's most recently open, or highlighted */ 198 let top_level_menu_index = -1; 199 /** most nested open MenuPopup 200 * @type {MenuPopup | undefined} */ 201 let active_menu_popup; 202 203 // There can be multiple menu bars instantiated from the same menu definitions, 204 // so this can't be a map of menu item to submenu, it has to be of menu item ELEMENTS to submenu. 205 // (or you know, it could work totally differently, this is just one way to do it) 206 /** This is for entering submenus. 207 * @type {WeakMap<HTMLElement, MenuPopup>} */ 208 const submenu_popup_by_menu_item_el = new WeakMap(); 209 210 /** This is for exiting submenus. 211 * @type {WeakMap<HTMLElement, HTMLElement>} */ 212 const parent_item_el_by_popup_el = new WeakMap(); 213 214 const close_menus = () => { 215 for (const { menu_button_el } of top_level_menus) { 216 if (menu_button_el.getAttribute("aria-expanded") === "true") { 217 menu_button_el.dispatchEvent(new CustomEvent("release", {})); 218 } 219 } 220 }; 221 222 const refocus_outside_menus = () => { 223 if (last_focus_outside_menus) { 224 last_focus_outside_menus.focus(); 225 // in case element was removed from DOM, or became unfocusable, 226 // we should fallback to focusing the containing window 227 if (document.activeElement === last_focus_outside_menus) { 228 return; 229 } 230 } 231 const window_el = menus_el.closest(".window"); 232 if (window_el) { 233 // This will refocus the last focused element in the window, 234 // or the first control in the window if there was no last focused element. 235 window_el.dispatchEvent(new CustomEvent("refocus-window")); 236 } 237 }; 238 239 /** @param {number | string} new_index_or_menu_key */ 240 const top_level_highlight = (new_index_or_menu_key) => { 241 const new_index = typeof new_index_or_menu_key === "string" ? 242 Object.keys(menus).indexOf(new_index_or_menu_key) : 243 new_index_or_menu_key; 244 if (top_level_menu_index !== -1 && top_level_menu_index !== new_index) { 245 top_level_menus[top_level_menu_index].menu_button_el.classList.remove("highlight"); 246 // closing the menu is handled externally 247 } 248 if (new_index !== -1) { 249 top_level_menus[new_index].menu_button_el.classList.add("highlight"); 250 } 251 top_level_menu_index = new_index; 252 }; 253 menus_el.addEventListener("pointerleave", () => { 254 // unhighlight unless a menu is open 255 if ( 256 top_level_menu_index !== -1 && 257 top_level_menus[top_level_menu_index].menu_popup_el.style.display === "none" 258 ) { 259 top_level_highlight(-1); 260 } 261 }); 262 263 /** @param {OSGUIMenuItem} item */ 264 const is_disabled = item => { 265 if (typeof item.enabled === "function") { 266 return !item.enabled(); 267 } else if (typeof item.enabled === "boolean") { 268 return !item.enabled; 269 } else { 270 return false;
271 } 272 }; 273 274 /** @param {OSGUIMenuItem} [item] */ 275 function send_info_event(item) { 276 // @TODO: in a future version, give the whole menu item definition (or null) 277 const description = item?.description || ""; 278 if (typeof jQuery !== "undefined") { 279 // old API (using jQuery's "extraParameters"), made forwards compatible with new API (event.detail) 280 const event = new jQuery.Event("info", { detail: { description } }); 281 const extraParam = { 282 toString() { 283 console.warn("jQuery extra parameter for info event is deprecated, use event.detail instead"); 284 return description; 285 }, 286 }; 287 jQuery(menus_el).trigger(event, extraParam); 288 } else { 289 menus_el.dispatchEvent(new CustomEvent("info", { detail: { description } })); 290 } 291 } 292 293 294 /** 295 * attached to menu bar and floating popups (which are not descendants of the menu bar) 296 * @param {KeyboardEvent} e 297 */ 298 function handleKeyDown(e) { 299 if (e.defaultPrevented) { 300 return; 301 } 302 const active_menu_popup_el = active_menu_popup?.element; 303 const top_level_menu = top_level_menus[top_level_menu_index]; 304 const { menu_button_el, open_top_level_menu } = top_level_menu || {}; 305 const menu_popup_el = active_menu_popup_el || top_level_menu?.menu_popup_el; 306 const parent_item_el = active_menu_popup_el ? parent_item_el_by_popup_el.get(active_menu_popup_el) : undefined; 307 const highlighted_item_el = menu_popup_el?.querySelector(".menu-item.highlight"); 308 if (highlighted_item_el && !(highlighted_item_el instanceof HTMLElement)) { 309 // type narrowing; should never happen; could use querySelector<HTMLElement>() instead if switching to TypeScript 310 throw new Error("Unexpected type for highlighted item element"); 311 } 312 313 // console.log("keydown", e.key, { target: e.target, active_menu_popup_el, top_level_menu, menu_popup_el, parent_item_el, highlighted_item_el }); 314 315 switch (e.key) { 316 case "ArrowLeft": 317 case "ArrowRight": 318 const right = e.key === "ArrowRight"; 319 if ( 320 highlighted_item_el?.matches(".has-submenu:not([aria-disabled='true'])") && 321 (get_direction() === "ltr") === right 322 ) { 323 // enter submenu 324 highlighted_item_el.click(); 325 e.preventDefault(); 326 } else if ( 327 active_menu_popup && 328 active_menu_popup.parentMenuPopup && // left/right doesn't make sense to close the top level menu 329 (get_direction() === "ltr") !== right 330 ) { 331 // exit submenu 332 active_menu_popup.close(true); // This changes the active_menu_popup_el to the parent menu! 333 parent_item_el.setAttribute("aria-expanded", "false"); 334 send_info_event(active_menu_popup.menuItems[active_menu_popup.itemElements.indexOf(parent_item_el)]); 335 e.preventDefault(); 336 } else if ( 337 // basically any case except if you hover to open a submenu and then press right/left 338 // in which case the menu is already open/focused 339 // This is mimicking the behavior of Explorer's menus; most Windows 98 apps work differently; @TODO: make this configurable 340 highlighted_item_el || 341 !active_menu_popup || 342 !active_menu_popup.parentMenuPopup 343 ) { 344 // go to next/previous top level menu, wrapping around 345 // and open a new menu only if a menu was already open 346 const menu_was_open = menu_popup_el && menu_popup_el.style.display !== "none"; 347 const cycle_dir = ((get_direction() === "ltr") === right) ? 1 : -1; 348 let new_index; 349 if (top_level_menu_index === -1) { 350 new_index = cycle_dir === 1 ? 0 : top_level_menus.length - 1; 351 } else { 352 new_index = (top_level_menu_index + cycle_dir + top_level_menus.length) % top_level_menus.length; 353 } 354 const new_top_level_menu = top_level_menus[new_index]; 355 const target_button_el = new_top_level_menu.menu_button_el; 356 if (menu_was_open) { 357 new_top_level_menu.open_top_level_menu("keydown"); 358 } else { 359 menu_button_el?.dispatchEvent(new CustomEvent("release", {})); 360 target_button_el.focus({ preventScroll: true }); 361 // Note case where menu is closed, menu button is hovered, then menu bar is unhovered, 362 // rehovered (outside any buttons), and unhovered, and THEN you try to go to the next menu. 363 top_level_highlight(new_index); 364 } 365 e.preventDefault(); 366 } // else: 367 // if there's no highlighted item, the user may be expecting to enter the menu even though it's already open, 368 // so it makes sense to do nothing (as Windows 98 does) and not go to the next/previous menu 369 // (although highlighting the first item might be nicer...) 370 break; 371 case "ArrowUp": 372 case "ArrowDown": 373 const down = e.key === "ArrowDown"; 374 // if (menu_popup_el && menu_popup_el.style.display !== "none") && highlighted_item_el) { 375 if (active_menu_popup) { 376 const cycle_dir = down ? 1 : -1; 377 const item_els = /** @type {HTMLElement[]} */([...menu_popup_el.querySelectorAll(".menu-item")]); 378 // @ts-ignore 379 const from_index = item_els.indexOf(highlighted_item_el); 380 let to_index = (from_index + cycle_dir + item_els.length) % item_els.length; 381 if (from_index === -1) { 382 if (down) { 383 to_index = 0; 384 } else { 385 to_index = item_els.length - 1; 386 } 387 } 388 // more fun way to do it: 389 // const to_index = (Math.max(from_index, -down) + cycle_dir + item_els.length) % item_els.length; 390 391 const to_item_el = item_els[to_index]; 392 // active_menu_popup.highlight(to_index); // wouldn't work because to_index doesn't count separators 393 active_menu_popup.highlight(to_item_el); 394 send_info_event(active_menu_popup.menuItems[active_menu_popup.itemElements.indexOf(to_item_el)]); 395 e.preventDefault(); 396 } else { 397 open_top_level_menu?.("keydown"); 398 } 399 e.preventDefault(); 400 break; 401 case "Escape": 402 if (active_menu_popup) { 403 // (@TODO: doesn't parent_item_el always exist?) 404 if (parent_item_el && parent_item_el !== menu_button_el) { 405 // exit submenu (@TODO: DRY further by moving more logic into close()?) 406 active_menu_popup.close(true); // This changes the active_menu_popup to the parent menu! 407 parent_item_el.setAttribute("aria-expanded", "false"); 408 send_info_event(active_menu_popup.menuItems[active_menu_popup.itemElements.indexOf(parent_item_el)]); 409 } else { 410 // exit top level menu 411 // close_menus takes care of releasing the pressed state of the button as well 412 close_menus(); 413 menu_button_el.focus({ preventScroll: true }); 414 } 415 e.preventDefault(); 416 } else { 417 const window_el = menus_el.closest(".window"); 418 if (window_el) { 419 // refocus last focused control in window 420 // refocus-window should never focus the menu bar 421 // it stores the last focused control in the window and specifically not in the menus 422 window_el.dispatchEvent(new CustomEvent("refocus-window")); 423 e.preventDefault(); 424 } 425 } 426 break; 427 case "Alt": 428 // close all menus and refocus the last focused control in the window 429 close_menus(); 430 refocus_outside_menus(); 431 e.preventDefault(); 432 break; 433 case "Space": 434 // opens system menu in Windows 98 435 // (at top level) 436 break; 437 case "Enter": 438 if (menu_button_el === document.activeElement) { 439 open_top_level_menu("keydown"); 440 e.preventDefault(); 441 } else { 442 highlighted_item_el?.click(); 443 e.preventDefault(); 444 } 445 break; 446 default: 447 if (e.ctrlKey || e.metaKey) { 448 break; 449 } 450 // handle access keys and first-letter navigation
451 const key = e.key.toLowerCase(); 452 const item_els = active_menu_popup ? 453 [...menu_popup_el.querySelectorAll(".menu-item")] : 454 top_level_menus.map(top_level_menu => top_level_menu.menu_button_el); 455 const item_els_by_access_key = {}; 456 for (const item_el of item_els) { 457 const access_key_el = item_el.querySelector(".menu-hotkey"); 458 const access_key = (access_key_el ? 459 access_key_el.textContent : 460 (item_el.querySelector(".menu-item-label") ?? item_el).textContent[0] 461 ).toLowerCase(); 462 item_els_by_access_key[access_key] = item_els_by_access_key[access_key] || []; 463 item_els_by_access_key[access_key].push(item_el); 464 } 465 const matching_item_els = item_els_by_access_key[key] || []; 466 // console.log({ key, item_els, item_els_by_access_key, matching_item_els }); 467 if (matching_item_els.length) { 468 if (matching_item_els.length === 1) { 469 // it's not ambiguous, so go ahead and activate it 470 const menu_item_el = matching_item_els[0]; 471 // click() doesn't work for menu buttons at the moment, 472 // and also we want to highlight the first item in the menu 473 // in that case, which doesn't happen with the mouse 474 const top_level_menu = top_level_menus.find(top_level_menu => top_level_menu.menu_button_el === menu_item_el); 475 if (top_level_menu) { 476 top_level_menu.open_top_level_menu("keydown"); 477 } else { 478 menu_item_el.click(); 479 } 480 e.preventDefault(); 481 } else { 482 // cycle the menu items that match the key 483 let index = matching_item_els.indexOf(highlighted_item_el); 484 if (index === -1) { 485 index = 0; 486 } else { 487 index = (index + 1) % matching_item_els.length; 488 } 489 const menu_item_el = matching_item_els[index]; 490 // active_menu_popup.highlight(index); // would very much not work 491 active_menu_popup.highlight(menu_item_el); 492 e.preventDefault(); 493 } 494 } 495 break; 496 } 497 } 498 499 menus_el.addEventListener("keydown", handleKeyDown); 500 501 // TODO: expose API for context menus (i.e. floating menu popups) 502 /** 503 * A floating menu popup. 504 * @param {OSGUIMenuFragment[]} menu_items 505 * @param {{ parentMenuPopup?: MenuPopup }} [options] 506 */ 507 function MenuPopup(menu_items, { parentMenuPopup } = {}) { 508 this.parentMenuPopup = parentMenuPopup; 509 this.menuItems = menu_items; 510 /** one-to-one with menuItems 511 * @type {HTMLElement[]} */ 512 this.itemElements = []; 513 // @TODO: unify terminology: separators are in itemElements but don't have class .menu-item; are they menu items? 514 515 const menu_popup_el = E("div", { 516 class: "menu-popup", 517 id: `menu-popup-${uid()}`, 518 tabIndex: "-1", 519 role: "menu", 520 }); 521 menu_popup_el.style.touchAction = "pan-y"; // allow for scrolling overflowing menus (in the future), but prevent event delay and double tap to zoom 522 menu_popup_el.style.outline = "none"; 523 const menu_popup_table_el = E("table", { class: "menu-popup-table", role: "presentation" }); 524 menu_popup_el.appendChild(menu_popup_table_el); 525 526 this.element = menu_popup_el; 527 528 /** 529 * @type {{ 530 * item_el: HTMLElement, 531 * submenu_popup_el: HTMLElement, 532 * submenu_popup: MenuPopup, 533 * }[]} 534 */ 535 let submenus = []; 536 537 menu_popup_el.addEventListener("keydown", handleKeyDown); 538 539 menu_popup_el.addEventListener("pointerleave", () => { 540 // If there's a submenu popup, highlight the item for that, otherwise nothing. 541 542 // (could querySelector on aria-expanded instead of looping here, alternatively) 543 for (const submenu of submenus) { 544 if (submenu.submenu_popup_el.style.display !== "none") { 545 this.highlight(submenu.item_el); 546 return; 547 } 548 } 549 this.highlight(-1); 550 }); 551 552 menu_popup_el.addEventListener("focusin", (e) => { 553 // Prevent focus going to menu items. As designed, it works with aria-activedescendant and focus on the menu popup itself. 554 // (on desktop when clicking (and dragging out) then pressing a key, or on mobile when tapping, a focus ring was visible, 555 // and it wouldn't go away with keyboard navigation either (because it was only changing aria-activedescendant presumably?)) 556 menu_popup_el.focus({ preventScroll: true }); 557 }); 558 559 /** @type {HTMLElement | null} */ 560 let last_item_el; 561 /** @param {number | HTMLElement} index_or_element */ 562 this.highlight = (index_or_element) => { // index includes separators 563 let item_el; 564 if (typeof index_or_element === "number") { 565 item_el = this.itemElements[index_or_element]; 566 } else { 567 item_el = index_or_element; 568 } 569 if (last_item_el && last_item_el !== item_el) { 570 last_item_el.classList.remove("highlight"); 571 } 572 if (item_el) { 573 item_el.classList.add("highlight"); 574 menu_popup_el.setAttribute("aria-activedescendant", item_el.id); 575 last_item_el = item_el; 576 } else { 577 menu_popup_el.removeAttribute("aria-activedescendant"); 578 last_item_el = null; 579 } 580 }; 581 582 this.close = (focus_parent_menu_popup = true) => { // Note: won't focus menu bar buttons. 583 // idempotent 584 for (const submenu of submenus) { 585 submenu.submenu_popup.close(false); 586 } 587 if (focus_parent_menu_popup) { 588 this.parentMenuPopup?.element.focus({ preventScroll: true }); 589 } 590 menu_popup_el.style.display = "none"; 591 this.highlight(-1); 592 // after closing submenus, which should move the active_menu_popup to this level, move it up to the parent level 593 if (active_menu_popup === this) { 594 active_menu_popup = this.parentMenuPopup; 595 } 596 }; 597 598 /** 599 * @param {HTMLElement} parent_element 600 * @param {OSGUIMenuItem | "MENU_DIVIDER"} item 601 * @param {number} item_index 602 */ 603 const add_menu_item = (parent_element, item, item_index) => { 604 const row_el = E("tr", { class: "menu-row" }); 605 this.itemElements.push(row_el); 606 parent_element.appendChild(row_el); 607 if (item === MENU_DIVIDER) { 608 const td_el = E("td", { colspan: "4" }); 609 const hr_el = E("hr", { class: "menu-hr" }); 610 // hr_el.setAttribute("role", "separator"); // this is the implicit ARIA role for <hr> 611 // and setting it on the <tr> might cause problems due to multiple elements with the role 612 // hopefully it's fine that the semantic <hr> is nested? 613 td_el.appendChild(hr_el); 614 row_el.appendChild(td_el); 615 // Favorites menu behavior: 616 // hr_el.addEventListener("click", () => { 617 // this.highlight(-1); 618 // }); 619 // Normal menu behavior: 620 hr_el.addEventListener("pointerenter", () => { 621 this.highlight(-1); 622 }); 623 } else { 624 const item_el = row_el; 625 item_el.classList.add("menu-item"); 626 item_el.id = `menu-item-${uid()}`; 627 item_el.tabIndex = -1; // may be needed for aria-activedescendant in some browsers? 628 item_el.setAttribute("role", item.checkbox ? item.checkbox.type === "radio" ? "menuitemradio" : "menuitemcheckbox" : "menuitem"); 629 // By default a screen reader will read the label of the menu item and its shortcut. 630 // The shortcut is noisy (albeit potentially useful), so I'm disabling it to match system behavior (at least with Orca). 631 // The access key is not read if it's part of a word like "&New", as it's just an underlined letter, 632 // but it is read in translated labels like "ìë¡ ë§ë¤ê¸° (&N)". 633 // The AccessKeys.toText() is so it doesn't announce an ampersand from the access key. 634 if (item.label || item.item) { 635 // deprecation notice for item.item is handled below, don't need to duplicate the warning 636 item_el.setAttribute("aria-label", AccessKeys.toText(item.label || item.item)); 637 } 638 // Include the shortcut semantically. 639 // TODO: automatically include the access key, while the specific menu (or menu bar) is focused, and update docs to note this 640 item_el.setAttribute("aria-keyshortcuts", item.ariaKeyShortcuts || ""); 641 642 if (item.description) { 643 item_el.setAttribute("aria-description", item.description); 644 } 645 const checkbox_area_el = E("td", { class: "menu-item-checkbox-area" }); 646 const label_el = E("td", { class: "menu-item-label" }); 647 const shortcut_el = E("td", { class: "menu-item-shortcut" }); 648 const submenu_area_el = E("td", { class: "menu-item-submenu-area" }); 649 650 item_el.appendChild(checkbox_area_el); 651 item_el.appendChild(label_el); 652 item_el.appendChild(shortcut_el); 653 item_el.appendChild(submenu_area_el); 654 655 if (item.label) { 656 label_el.appendChild(AccessKeys.toFragment(item.label)); 657 } else if (item.item) {
658 // I originally called it `item` because it's not "just" a label (it defines the access key, and also IDs), 659 // but `item` is a kinda wacky name (an "item's item" doesn't make sense) 660 label_el.appendChild(AccessKeys.toFragment(item.item)); 661 console.warn("Menu item option `item` is deprecated; use `label` instead."); 662 } 663 664 if (item.shortcutLabel) { 665 shortcut_el.textContent = item.shortcutLabel; 666 } else if (item.shortcut) { 667 shortcut_el.textContent = item.shortcut; 668 console.warn("Menu item option `shortcut` is deprecated; use `shortcutLabel` instead (and ideally provide `ariaKeyShortcuts` as well)"); 669 } 670 671 menu_popup_el.addEventListener("update", () => { 672 // item_el.disabled = is_disabled(item); // doesn't work, probably because it's a <tr> 673 if (is_disabled(item)) { 674 item_el.setAttribute("disabled", ""); 675 item_el.setAttribute("aria-disabled", "true"); 676 } else { 677 item_el.removeAttribute("disabled"); 678 item_el.removeAttribute("aria-disabled"); 679 } 680 if (item.checkbox && item.checkbox.check) { 681 const checked = item.checkbox.check(); 682 item_el.setAttribute("aria-checked", checked ? "true" : "false"); 683 } 684 }); 685 // Why not call `send_info_event` in `highlight`? 686 // Consider the case where you hover to open a submenu: it highlights nothing, 687 // but it shouldn't reset the status bar. 688 // So it needs to be more directly based on the pointer and keyboard interactions. 689 // *Maybe* it could be a parameter (to `highlight`) if that's really helpful, but it's probably not. 690 // *Maybe* it could look at more of the overall state within `highlight`, 691 // but could it distinguish hovering an outer vs an inner item if two are highlighted? 692 item_el.addEventListener("pointerenter", () => { 693 this.highlight(item_index); 694 send_info_event(item); 695 }); 696 item_el.addEventListener("pointerleave", (event) => { 697 if ( 698 menu_popup_el.style.display !== "none" && // pointer hasn't "left" due to closing 699 event.pointerType !== "touch" // pointer hasn't "left" as in finger lifting off 700 ) { 701 send_info_event(); 702 } 703 }); 704 705 if (item.checkbox?.type === "radio") { 706 checkbox_area_el.classList.add("radio"); 707 } else if (item.checkbox) { 708 checkbox_area_el.classList.add("checkbox"); 709 } 710 711 /** @type {(highlight_first?: boolean) => void} */ 712 let open_submenu; 713 /** @type {HTMLDivElement} */ 714 let submenu_popup_el; 715 if (item.submenu) { 716 item_el.classList.add("has-submenu"); // @TODO: remove this, and use [aria-haspopup] instead (note true = menu) 717 submenu_area_el.classList.toggle("point-right", get_direction() === "rtl"); 718 719 const submenu_popup = new MenuPopup(item.submenu, { parentMenuPopup: this }); 720 submenu_popup_el = submenu_popup.element; 721 document.body?.appendChild(submenu_popup_el); 722 submenu_popup_el.style.display = "none"; 723 724 item_el.setAttribute("aria-haspopup", "true"); 725 item_el.setAttribute("aria-expanded", "false"); 726 item_el.setAttribute("aria-controls", submenu_popup_el.id); 727 728 submenu_popup_by_menu_item_el.set(item_el, submenu_popup); 729 parent_item_el_by_popup_el.set(submenu_popup_el, item_el); 730 submenu_popup_el.dataset.semanticParent = menu_popup_el.id; // for $Window to understand the popup belongs to its window 731 menu_popup_el.setAttribute("aria-owns", `${menu_popup_el.getAttribute("aria-owns") || ""} ${submenu_popup_el.id}`); 732 submenu_popup_el.setAttribute("aria-labelledby", item_el.id); 733 734 735 open_submenu = (highlight_first = true) => { 736 if (submenu_popup_el.style.display !== "none") { 737 return; 738 } 739 if (item_el.getAttribute("aria-disabled") === "true") { 740 return; 741 } 742 close_submenus_at_this_level(); 743 744 item_el.setAttribute("aria-expanded", "true"); 745 746 submenu_popup_el.style.display = ""; 747 submenu_popup_el.style.zIndex = `${get_new_menu_z_index()}`; 748 submenu_popup_el.setAttribute("dir", get_direction()); 749 if (window.inheritTheme) { 750 window.inheritTheme(submenu_popup_el, menu_popup_el); 751 } 752 if (!submenu_popup_el.parentElement) { 753 document.body.appendChild(submenu_popup_el); 754 } 755 756 // console.log("open_submenu â submenu_popup_el.style.zIndex", submenu_popup_el.style.zIndex, "$Window.Z_INDEX", $Window.Z_INDEX, "menus_el.closest('.window').style.zIndex", menus_el.closest(".window").style.zIndex); 757 // setTimeout(() => { console.log("after timeout, menus_el.closest('.window').style.zIndex", menus_el.closest(".window").style.zIndex); }, 0); 758 submenu_popup_el.dispatchEvent(new CustomEvent("update", {})); 759 if (highlight_first) { 760 submenu_popup.highlight(0); 761 send_info_event(submenu_popup.menuItems[0]); 762 } else { 763 submenu_popup.highlight(-1); 764 // send_info_event(); // no, keep the status bar text! 765 } 766 767 const rect = item_el.getBoundingClientRect(); 768 let submenu_popup_rect = submenu_popup_el.getBoundingClientRect(); 769 submenu_popup_el.style.position = "absolute"; 770 submenu_popup_el.style.left = `${(get_direction() === "rtl" ? rect.left - submenu_popup_rect.width : rect.right) + window.scrollX}px`; 771 submenu_popup_el.style.top = `${rect.top + window.scrollY}px`;
772 773 submenu_popup_rect = submenu_popup_el.getBoundingClientRect(); 774 // This is surely not the cleanest way of doing this, 775 // and the logic is not very robust in the first place, 776 // but I want to get RTL support done and so I'm mirroring this in the simplest way possible. 777 if (get_direction() === "rtl") { 778 if (submenu_popup_rect.left < 0) { 779 submenu_popup_el.style.left = `${rect.right}px`; 780 submenu_popup_rect = submenu_popup_el.getBoundingClientRect(); 781 if (submenu_popup_rect.right > innerWidth) { 782 submenu_popup_el.style.left = `${innerWidth - submenu_popup_rect.width}px`; 783 } 784 } 785 } else { 786 if (submenu_popup_rect.right > innerWidth) { 787 submenu_popup_el.style.left = `${rect.left - submenu_popup_rect.width}px`; 788 submenu_popup_rect = submenu_popup_el.getBoundingClientRect(); 789 if (submenu_popup_rect.left < 0) { 790 submenu_popup_el.style.left = "0"; 791 } 792 } 793 } 794 795 submenu_popup_el.focus({ preventScroll: true }); 796 active_menu_popup = submenu_popup; 797 }; 798 799 submenus.push({ 800 item_el, 801 submenu_popup_el, 802 submenu_popup, 803 }); 804 805 function close_submenus_at_this_level() { 806 for (const { submenu_popup, item_el } of submenus) { 807 submenu_popup.close(false); 808 item_el.setAttribute("aria-expanded", "false"); 809 } 810 menu_popup_el.focus({ preventScroll: true }); 811 } 812 813 // It should close when hovering a different higher level menu 814 // after a delay, unless the mouse returns to the submenu. 815 // If you return the mouse from a submenu into its parent 816 // *directly onto the parent menu item*, it stays open, but if you cross other menu items 817 // in the parent menu, (@TODO:) it'll close after the delay even if you land on the parent menu item. 818 // Once a submenu opens (completing its animation if it has one), 819 // - up/down should navigate the submenu (although it should not show as focused right away) 820 // (i.e. up/down always navigate the most-nested open submenu, as long as it's not animating, in which case nothing happens) 821 // - @TODO: the submenu cancels its closing timeout (if you've moved outside all menus, say) 822 // (but if you move outside menus AFTER the submenu has opened, it should start the closing timeout) 823 // @TODO: make this more robust in general! Make some automated tests. 824 825 /** @type {number | null} */ 826 let open_tid; 827 /** @type {number | null} */ 828 let close_tid; 829 submenu_popup_el.addEventListener("pointerenter", () => { 830 if (open_tid) { clearTimeout(open_tid); open_tid = null; } 831 if (close_tid) { clearTimeout(close_tid); close_tid = null; } 832 }); 833 item_el.addEventListener("pointerenter", () => { 834 // @TODO: don't cancel close timer? in Windows 98 it'll still close after a delay if you hover the submenu's parent item 835 if (open_tid) { clearTimeout(open_tid); open_tid = null; } 836 if (close_tid) { clearTimeout(close_tid); close_tid = null; } 837 open_tid = setTimeout(() => { 838 open_submenu(false); 839 }, 501); // @HACK: slightly longer than close timer so it doesn't close immediately 840 }); 841 item_el.addEventListener("pointerleave", () => { 842 if (open_tid) { clearTimeout(open_tid); open_tid = null; } 843 }); 844 menu_popup_el.addEventListener("pointerenter", (event) => { 845 // console.log(event.target.closest(".menu-item")); 846 // @ts-ignore 847 if (event.target.closest(".menu-item") === item_el) { 848 return; 849 } 850 if (!close_tid) { 851 // This is a little confusing, with timers per-item... 852 // @TODO: try doing this with just one or two timers. 853 // if (submenus.some(submenu => submenu.submenu_popup_el.style.display !== "none")) { 854 if (submenu_popup_el.style.display !== "none") { 855 close_tid = setTimeout(() => { 856 if (!window.debugKeepMenusOpen) { 857 // close_submenu(); 858 close_submenus_at_this_level(); 859 } 860 }, 500); 861 } 862 } 863 }); 864 // keep submenu open while mouse is outside any parent menus 865 // (@TODO: what if it goes to another parent menu though?) 866 menu_popup_el.addEventListener("pointerleave", () => { 867 if (close_tid) { clearTimeout(close_tid); close_tid = null; } 868 }); 869 870 item_el.addEventListener("pointerdown", () => { open_submenu(false); }); 871 } 872 873 let just_activated = false; // to prevent double-activation from pointerup + click 874 const item_action = () => { 875 if (just_activated) { 876 return; 877 } 878 just_activated = true; 879 setTimeout(() => { just_activated = false; }, 10); 880 881 if (item.checkbox) { 882 if (item.checkbox.toggle) { 883 item.checkbox.toggle(); 884 } 885 menu_popup_el.dispatchEvent(new CustomEvent("update", {})); 886 } else if (item.action) { 887 close_menus(); 888 // refocus_outside_menus is before action, so that things like copy/paste can work 889 // (using the original focused textarea/input/etc.)
890 // and so the action can focus things, like a new dialog box 891 refocus_outside_menus(); 892 item.action(); 893 } 894 }; 895 // pointerup is for gliding to menu items to activate 896 item_el.addEventListener("pointerup", e => { 897 if (e.pointerType === "mouse" && e.button !== 0) { 898 return; 899 } 900 if (e.pointerType === "touch") { 901 // Will use click instead; otherwise focus is lost on a delay: if it opens a dialog for example, 902 // you have to hold down on the menu item for a bit otherwise it'll blur the dialog after opening. 903 // I think this is caused by the pointer falling through to elements without touch-action defined. 904 // RIGHT NOW, gliding to menu items isn't supported for touch anyways, 905 // although I'd like to support it in the future. 906 // Well, it might have accessibility problems, so maybe not. I think this is fine. 907 return; 908 } 909 item_el.click(); 910 }); 911 item_el.addEventListener("click", e => { 912 if (item.submenu) { 913 open_submenu(true); 914 } else { 915 item_action(); 916 } 917 }); 918 } 919 }; 920 921 if (menu_items.length === 0) { 922 menu_items = [{ 923 label: "(Empty)", 924 enabled: false, 925 }]; 926 } 927 let init_index = 0; 928 for (const item of menu_items) { 929 if (typeof item === "object" && "radioItems" in item) { 930 const tbody = E("tbody", { role: "group" }); // multiple tbody elements are allowed, can be used for grouping rows, 931 // and in this case providing an ARIA role for the radio group. 932 if (item.ariaLabel) { 933 tbody.setAttribute("aria-label", item.ariaLabel); 934 } 935 936 for (const radio_item of item.radioItems) { 937 radio_item.checkbox = { 938 type: "radio", 939 check: () => radio_item.value === item.getValue(), 940 toggle: () => { 941 item.setValue(radio_item.value); 942 }, 943 }; 944 add_menu_item(tbody, radio_item, init_index++); 945 } 946 menu_popup_table_el.appendChild(tbody); 947 } else { 948 add_menu_item(menu_popup_table_el, item, init_index++); 949 } 950 } 951 } 952 953 // let this_click_opened_the_menu = false; 954 const make_menu_button = (menus_key, menu_items) => { 955 const menu_button_el = E("div", { 956 class: "menu-button", 957 "aria-expanded": "false", 958 "aria-haspopup": "true", 959 role: "menuitem", 960 }); 961 962 menus_el.appendChild(menu_button_el); 963 964 const menu_popup = new MenuPopup(menu_items); 965 const menu_popup_el = menu_popup.element; 966 document.body?.appendChild(menu_popup_el); 967 submenu_popup_by_menu_item_el.set(menu_button_el, menu_popup); 968 parent_item_el_by_popup_el.set(menu_popup_el, menu_button_el); 969 menu_button_el.id = `menu-button-${menus_key}-${uid()}`; 970 menu_popup_el.dataset.semanticParent = menu_button_el.id; // for $Window to understand the popup belongs to its window 971 menu_button_el.setAttribute("aria-controls", menu_popup_el.id); 972 menu_popup_el.setAttribute("aria-labelledby", menu_button_el.id); 973 menus_el.setAttribute("aria-owns", `${menus_el.getAttribute("aria-owns") || ""} ${menu_popup_el.id}`); 974 975 const update_position_from_containing_bounds = () => { 976 const rect = menu_button_el.getBoundingClientRect(); 977 let popup_rect = menu_popup_el.getBoundingClientRect(); 978 menu_popup_el.style.position = "absolute"; 979 menu_popup_el.style.left = `${(get_direction() === "rtl" ? rect.right - popup_rect.width : rect.left) + window.scrollX}px`; 980 menu_popup_el.style.top = `${rect.bottom + window.scrollY}px`; 981 982 const uncorrected_rect = menu_popup_el.getBoundingClientRect(); 983 // rounding down is needed for RTL layout for the rightmost menu, to prevent a scrollbar 984 if (Math.floor(uncorrected_rect.right) > innerWidth) { 985 menu_popup_el.style.left = `${innerWidth - uncorrected_rect.width}px`; 986 } 987 if (Math.ceil(uncorrected_rect.left) < 0) { 988 menu_popup_el.style.left = "0px"; 989 } 990 }; 991 window.addEventListener("resize", update_position_from_containing_bounds); 992 menu_popup_el.addEventListener("update", update_position_from_containing_bounds); 993 // update_position_from_containing_bounds(); // will be called when the menu is opened 994 995 const menu_id = menus_key.replace("&", "").replace(/ /g, "-").toLowerCase(); 996 menu_button_el.classList.add(`${menu_id}-menu-button`); 997 // menu_popup_el.id = `${menu_id}-menu-popup-${uid()}`; // id is set by MenuPopup and changing it breaks the `data-semantic-parent` relationship 998 menu_popup_el.style.display = "none"; 999 menu_button_el.innerHTML = `<span>${AccessKeys.toHTML(menus_key)}</span>`; // span is for button offset effect on press 1000 menu_button_el.tabIndex = -1; 1001 1002 menu_button_el.setAttribute("aria-haspopup", "true"); 1003 menu_button_el.setAttribute("aria-controls", menu_popup_el.id); 1004 1005 menu_button_el.addEventListener("focus", () => { 1006 top_level_highlight(menus_key); 1007 }); 1008 menu_button_el.addEventListener("pointerdown", e => { 1009 if (menu_button_el.classList.contains("active")) { 1010 menu_button_el.dispatchEvent(new CustomEvent("release", {})); 1011 refocus_outside_menus(); 1012 e.preventDefault(); // needed for refocus_outside_menus() to work 1013 } else { 1014 open_top_level_menu(e.type); 1015 } 1016 }); 1017 menu_button_el.addEventListener("pointermove", e => { 1018 top_level_highlight(menus_key); 1019 if (e.pointerType === "touch") { 1020 return; 1021 } 1022 if (selecting_menus) { 1023 open_top_level_menu(e.type); 1024 } 1025 }); 1026 function open_top_level_menu(type = "other") { 1027 1028 const new_index = Object.keys(menus).indexOf(menus_key); 1029 if (new_index === top_level_menu_index && menu_button_el.getAttribute("aria-expanded") === "true") { 1030 return; // already open 1031 } 1032 1033 close_menus(); 1034 1035 menu_button_el.classList.add("active"); 1036 menu_button_el.setAttribute("aria-expanded", "true"); 1037 menu_popup_el.style.display = ""; 1038 menu_popup_el.style.zIndex = `${get_new_menu_z_index()}`; 1039 menu_popup_el.setAttribute("dir", get_direction()); 1040 if (window.inheritTheme) { 1041 window.inheritTheme(menu_popup_el, menus_el); 1042 } 1043 if (!menu_popup_el.parentElement) { 1044 document.body.appendChild(menu_popup_el); 1045 } 1046 // console.log("pointerdown (possibly simulated) â menu_popup_el.style.zIndex", menu_popup_el.style.zIndex, "$Window.Z_INDEX", $Window.Z_INDEX, "menus_el.closest('.window').style.zIndex", menus_el.closest(".window").style.zIndex); 1047 // setTimeout(() => { console.log("after timeout, menus_el.closest('.window').style.zIndex", menus_el.closest(".window").style.zIndex); }, 0); 1048 top_level_highlight(menus_key); 1049 1050 menu_popup_el.dispatchEvent(new CustomEvent("update", {})); 1051 1052 selecting_menus = true; 1053 1054 menu_popup_el.focus({ preventScroll: true }); 1055 active_menu_popup = menu_popup; 1056 1057 if (type === "keydown") { 1058 menu_popup.highlight(0); 1059 send_info_event(menu_popup.menuItems[0]); 1060 } else { 1061 send_info_event(); // @TODO: allow descriptions on top level menus 1062 } 1063 }; 1064 menu_button_el.addEventListener("release", () => { 1065 selecting_menus = false;
1066 1067 menu_button_el.classList.remove("active"); 1068 if (!window.debugKeepMenusOpen) { 1069 menu_popup.close(true); 1070 menu_button_el.setAttribute("aria-expanded", "false"); 1071 } 1072 1073 menus_el.dispatchEvent(new CustomEvent("default-info", {})); 1074 }); 1075 top_level_menus.push({ 1076 menu_button_el, 1077 menu_popup_el, 1078 menus_key, 1079 access_key: AccessKeys.get(menus_key), 1080 open_top_level_menu, 1081 }); 1082 }; 1083 for (const menu_key in menus) { 1084 make_menu_button(menu_key, menus[menu_key]); 1085 } 1086 1087 window.addEventListener("keydown", e => { 1088 // close any errant menus 1089 // taking care not to interfere with regular Escape key behavior 1090 // @TODO: listen for menus_el removed from DOM, and close menus there 1091 if ( 1092 !document.activeElement || 1093 !document.activeElement.closest || // window or document 1094 !document.activeElement.closest(".menus, .menu-popup") 1095 ) { 1096 if (e.key === "Escape") { 1097 if (active_menu_popup) { 1098 close_menus(); 1099 e.preventDefault(); 1100 } 1101 } 1102 } 1103 }); 1104 // window.addEventListener("blur", close_menus); 1105 window.addEventListener("blur", (event) => { 1106 // hack for Pinball (in 98.js.org) where it triggers fake blur events 1107 // in order to pause the game 1108 if (!event.isTrusted) { 1109 return; 1110 } 1111 close_menus(); 1112 }); 1113 /** 1114 * @param {PointerEvent} event 1115 */ 1116 function close_menus_on_click_outside(event) { 1117 if (event.target?.closest?.(".menus") === menus_el || event.target?.closest?.(".menu-popup")) { 1118 return; 1119 } 1120 // window.console && console.log(event.type, "occurred outside of menus (on ", event.target, ") so..."); 1121 close_menus(); 1122 top_level_highlight(-1); 1123 } 1124 window.addEventListener("pointerdown", close_menus_on_click_outside); 1125 window.addEventListener("pointerup", close_menus_on_click_outside); 1126 1127 window.addEventListener("focusout", (event) => { 1128 // if not still in menus, unhighlight (e.g. if you hit Escape to unfocus the menus) 1129 if (event.relatedTarget?.closest?.(".menus") === menus_el || event.relatedTarget?.closest?.(".menu-popup")) { 1130 return; 1131 } 1132 close_menus(); 1133 // Top level buttons should no longer be highlighted due to focus, but still may be highlighted due to hover. 1134 top_level_highlight(top_level_menus.findIndex(({ menu_button_el }) => menu_button_el.matches(":hover"))); 1135 }); 1136 1137 let keyboard_scope_elements = []; 1138 /** 1139 * @param {...EventTarget} elements 1140 */ 1141 function set_keyboard_scope(...elements) { 1142 for (const el of keyboard_scope_elements) { 1143 el.removeEventListener("keydown", keyboard_scope_keydown); 1144 } 1145 keyboard_scope_elements = elements; 1146 for (const el of keyboard_scope_elements) { 1147 el.addEventListener("keydown", keyboard_scope_keydown); 1148 } 1149 } 1150 /** 1151 * @param {KeyboardEvent} e 1152 */ 1153 function keyboard_scope_keydown(e) { 1154 // Close menus if the user presses almost any key combination 1155 // e.g. if you look in the menu to remember a shortcut, 1156 // and then use the shortcut. 1157 if ( 1158 (e.ctrlKey || e.metaKey) && // Ctrl or Command held down 1159 // and anything then pressed other than Ctrl or Command 1160 e.key !== "Control" && 1161 e.key !== "Meta" 1162 ) { 1163 close_menus(); 1164 return; 1165 } 1166 if (e.defaultPrevented) { 1167 return; // closing menus above is meant to be done when activating unrelated shortcuts 1168 // but stuff after this is should not be handled at the same time as something else 1169 } 1170 if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey) { // Alt held 1171 // Should the access key for top level menus default to the first letter, like menu items? 1172 const menu = top_level_menus.find((menu) => 1173 menu.access_key?.toLowerCase() === e.key.toLowerCase() 1174 ); 1175 if (menu) { 1176 e.preventDefault(); 1177 menu.open_top_level_menu("keydown"); 1178 } 1179 } 1180 } 1181 1182 set_keyboard_scope(window); 1183 1184 /** @type {HTMLElement} */ 1185 this.element = menus_el; 1186 /** @type {() => void} */ 1187 this.closeMenus = close_menus; 1188 /** @type {(...elements: EventTarget[]) => void} */ 1189 this.setKeyboardScope = set_keyboard_scope; 1190} 1191 1192exports.MenuBar = MenuBar; 1193exports.AccessKeys = AccessKeys; 1194exports.MENU_DIVIDER = MENU_DIVIDER; 1195 1196// @ts-ignore 1197})(typeof module !== "undefined" ? module.exports : window);
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.