PageSourceSearch

https://jspaint.app/lib/os-gui/MenuBar.js

js jspaint.app collected 2026-10-02 16:46:01 UTC 47,269 bytes, 1,197 lines download raw bytes

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.