Files
2023-02-13 19:32:10 +07:00

997 lines
38 KiB
JavaScript

((exports) => {
function E(nodeName, attrs) {
const el = document.createElement(nodeName);
if (attrs) {
for (const key in attrs) {
if (key === "class") {
el.className = attrs[key];
} else {
el.setAttribute(key, attrs[key]);
}
}
}
return el;
}
let uid_counter = 0;
function uid() {
// make id from counter (guaranteeing page-local uniqueness)
// and random base 36 number (making it look random, so there's no temptation to use it as a sequence)
// Note: Math.random().toString(36).slice(2) can give empty string
return (uid_counter++).toString(36) + Math.random().toString(36).slice(2);
}
// @TODO: export hotkey helpers; also include one for escaping &'s (useful for dynamic menus like a list of history entries)
// also @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)
// & defines accelerators (hotkeys) in menus and buttons and things, which get underlined in the UI.
// & can be escaped by doubling it, e.g. "&Taskbar && Start Menu"
function index_of_hotkey(text) {
// Returns the index of the ampersand that defines a hotkey, or -1 if not present.
// return english_text.search(/(?<!&)&(?!&|\s)/); // not enough browser support for negative lookbehind assertions
// The space here handles beginning-of-string matching and counteracts the offset for the [^&] so it acts like a negative lookbehind
return ` ${text}`.search(/[^&]&[^&\s]/);
}
// function has_hotkey(text) {
// return index_of_hotkey(text) !== -1;
// }
function remove_hotkey(text) {
return text.replace(/\s?\(&.\)/, "").replace(/([^&]|^)&([^&\s])/, "$1$2");
}
function display_hotkey(text) {
// TODO: use a more general term like .hotkey or .accelerator?
return text.replace(/([^&]|^)&([^&\s])/, "$1<span class='menu-hotkey'>$2</span>").replace(/&&/g, "&");
}
function get_hotkey(text) {
return text[index_of_hotkey(text) + 1].toUpperCase();
}
// TODO: support copy/pasting text in the text tool textarea from the menus
// probably by recording document.activeElement on pointer down,
// and restoring focus before executing menu item actions.
const MENU_DIVIDER = "MENU_DIVIDER";
const MAX_MENU_NESTING = 1000;
let internal_z_counter = 1;
function get_new_menu_z_index() {
// integrate with the OS window z-indexes, if applicable
// but don't depend on $Window existing, the modules should be independent
if (typeof $Window !== "undefined") {
return ($Window.Z_INDEX++) + MAX_MENU_NESTING; // MAX_MENU_NESTING is needed because the window gets brought to the top
}
return (++internal_z_counter) + MAX_MENU_NESTING;
}
function MenuBar(menus) {
if (!(this instanceof MenuBar)) {
return new MenuBar(menus);
}
const menus_el = E("div", {
class: "menus",
role: "menubar",
"aria-label": "Application Menu",
});
menus_el.style.touchAction = "none";
menus_el.style.pointerEvents = "none";
// returns writing/layout direction, "ltr" or "rtl"
function get_direction() {
return window.get_direction ? window.get_direction() : getComputedStyle(menus_el).direction;
}
let selecting_menus = false; // state where you can glide between menus without clicking
const top_level_menus = [];
let top_level_menu_index = -1; // index of the top level menu that's most recently open, or highlighted
let active_menu_popup; // most nested open MenuPopup
const menu_popup_by_el = new WeakMap(); // maps DOM elements to MenuPopup instances
// There can be multiple menu bars instantiated from the same menu definitions,
// so this can't be a map of menu item to submenu, it has to be of menu item ELEMENTS to submenu.
// (or you know, it could work totally differently, this is just one way to do it)
// This is for entering submenus.
const submenu_popup_by_menu_item_el = new WeakMap();
// This is for exiting submenus.
const parent_item_el_by_popup_el = new WeakMap();
const close_menus = () => {
for (const { menu_button_el } of top_level_menus) {
if (menu_button_el.getAttribute("aria-expanded") === "true") {
menu_button_el.dispatchEvent(new CustomEvent("release"), {});
}
}
};
const refocus_window = () => {
const window_el = menus_el.closest(".window");
if (window_el) {
window_el.dispatchEvent(new CustomEvent("refocus-window"));
}
};
const top_level_highlight = (new_index_or_menu_key) => {
const new_index = typeof new_index_or_menu_key === "string" ?
Object.keys(menus).indexOf(new_index_or_menu_key) :
new_index_or_menu_key;
if (top_level_menu_index !== -1 && top_level_menu_index !== new_index) {
top_level_menus[top_level_menu_index].menu_button_el.classList.remove("highlight");
// could close the menu here, but it's handled externally right now
}
if (new_index !== -1) {
top_level_menus[new_index].menu_button_el.classList.add("highlight");
}
top_level_menu_index = new_index;
};
menus_el.addEventListener("pointerleave", () => {
// unhighlight unless a menu is open
if (
top_level_menu_index !== -1 &&
top_level_menus[top_level_menu_index].menu_popup_el.style.display === "none"
) {
top_level_highlight(-1);
}
});
const is_disabled = item => {
if (typeof item.enabled === "function") {
return !item.enabled();
} else if (typeof item.enabled === "boolean") {
return !item.enabled;
} else {
return false;
}
};
function send_info_event(item) {
// @TODO: in a future version, give the whole menu item definition (or null)
const description = item?.description || "";
if (window.jQuery) {
// old API (using jQuery's "extraParameters"), made forwards compatible with new API (event.detail)
const event = new window.jQuery.Event("info", { detail: { description } });
const extraParam = {
toString() {
console.warn("jQuery extra parameter for info event is deprecated, use event.detail instead");
return description;
},
};
window.jQuery(menus_el).trigger(event, extraParam);
} else {
menus_el.dispatchEvent(new CustomEvent("info", { detail: { description } }));
}
}
// attached to menu bar and floating popups (which are not descendants of the menu bar)
function handleKeyDown(e) {
if (e.defaultPrevented) {
return;
}
const active_menu_popup_el = active_menu_popup?.element;
const top_level_menu = top_level_menus[top_level_menu_index];
const { menu_button_el, open_top_level_menu } = top_level_menu || {};
const menu_popup_el = active_menu_popup_el || top_level_menu?.menu_popup_el;
const parent_item_el = parent_item_el_by_popup_el.get(active_menu_popup_el);
const highlighted_item_el = menu_popup_el?.querySelector(".menu-item.highlight");
// console.log("keydown", e.key, { target: e.target, active_menu_popup_el, top_level_menu, menu_popup_el, parent_item_el, highlighted_item_el });
switch (e.key) {
case "ArrowLeft":
case "ArrowRight":
const right = e.key === "ArrowRight";
if (
highlighted_item_el?.matches(".has-submenu:not([aria-disabled='true'])") &&
(get_direction() === "ltr") === right
) {
// enter submenu
highlighted_item_el.click();
e.preventDefault();
} else if (
active_menu_popup &&
active_menu_popup.parentMenuPopup && // left/right doesn't make sense to close the top level menu
(get_direction() === "ltr") !== right
) {
// exit submenu
active_menu_popup.close(true); // This changes the active_menu_popup_el to the parent menu!
parent_item_el.setAttribute("aria-expanded", "false");
send_info_event(active_menu_popup.menuItems[active_menu_popup.itemElements.indexOf(parent_item_el)]);
e.preventDefault();
} else if (
// basically any case except if you hover to open a submenu and then press right/left
// in which case the menu is already open/focused
// This is mimicking the behavior of Explorer's menus; most Windows 98 apps work differently; @TODO: make this configurable
highlighted_item_el ||
!active_menu_popup ||
!active_menu_popup.parentMenuPopup
) {
// go to next/previous top level menu, wrapping around
// and open a new menu only if a menu was already open
const menu_was_open = menu_popup_el && menu_popup_el.style.display !== "none";
const cycle_dir = ((get_direction() === "ltr") === right) ? 1 : -1;
let new_index;
if (top_level_menu_index === -1) {
new_index = cycle_dir === 1 ? 0 : top_level_menus.length - 1;
} else {
new_index = (top_level_menu_index + cycle_dir + top_level_menus.length) % top_level_menus.length;
}
const new_top_level_menu = top_level_menus[new_index];
const target_button_el = new_top_level_menu.menu_button_el;
if (menu_was_open) {
new_top_level_menu.open_top_level_menu("keydown");
} else {
menu_button_el?.dispatchEvent(new CustomEvent("release"), {});
target_button_el.focus({ preventScroll: true });
// Note case where menu is closed, menu button is hovered, then menu bar is unhovered,
// rehovered (outside any buttons), and unhovered, and THEN you try to go to the next menu.
top_level_highlight(new_index);
}
e.preventDefault();
} // else:
// if there's no highlighted item, the user may be expecting to enter the menu even though it's already open,
// so it makes sense to do nothing (as Windows 98 does) and not go to the next/previous menu
// (although highlighting the first item might be nicer...)
break;
case "ArrowUp":
case "ArrowDown":
const down = e.key === "ArrowDown";
// if (menu_popup_el && menu_popup_el.style.display !== "none") && highlighted_item_el) {
if (active_menu_popup) {
const cycle_dir = down ? 1 : -1;
const item_els = [...menu_popup_el.querySelectorAll(".menu-item")];
const from_index = item_els.indexOf(highlighted_item_el);
let to_index = (from_index + cycle_dir + item_els.length) % item_els.length;
if (from_index === -1) {
if (down) {
to_index = 0;
} else {
to_index = item_els.length - 1;
}
}
// more fun way to do it:
// const to_index = (Math.max(from_index, -down) + cycle_dir + item_els.length) % item_els.length;
const to_item_el = item_els[to_index];
// active_menu_popup.highlight(to_index); // wouldn't work because to_index doesn't count separators
active_menu_popup.highlight(to_item_el);
send_info_event(active_menu_popup.menuItems[active_menu_popup.itemElements.indexOf(to_item_el)]);
e.preventDefault();
} else {
open_top_level_menu?.("keydown");
}
e.preventDefault();
break;
case "Escape":
if (active_menu_popup) {
// (@TODO: doesn't parent_item_el always exist?)
if (parent_item_el && parent_item_el !== menu_button_el) {
// exit submenu (@TODO: DRY further by moving more logic into close()?)
active_menu_popup.close(true); // This changes the active_menu_popup to the parent menu!
parent_item_el.setAttribute("aria-expanded", "false");
send_info_event(active_menu_popup.menuItems[active_menu_popup.itemElements.indexOf(parent_item_el)]);
} else {
// close_menus takes care of releasing the pressed state of the button as well
close_menus();
menu_button_el.focus({ preventScroll: true });
}
e.preventDefault();
} else {
const window_el = menus_el.closest(".window");
if (window_el) {
// refocus last focused control in window
// refocus-window should never focus the menu bar
// it stores the last focused control in the window and specifically not in the menus
window_el.dispatchEvent(new CustomEvent("refocus-window"));
e.preventDefault();
}
}
break;
case "Alt":
// close all menus and refocus the last focused control in the window
close_menus();
refocus_window();
e.preventDefault();
break;
case "Space":
// opens system menu in Windows 98
// (at top level)
break;
case "Enter":
if (menu_button_el === document.activeElement) {
open_top_level_menu("keydown");
e.preventDefault();
} else {
highlighted_item_el?.click();
e.preventDefault();
}
break;
default:
if (e.ctrlKey || e.metaKey) {
break;
}
// handle accelerators and first-letter navigation
const key = e.key.toLowerCase();
const item_els = active_menu_popup ?
[...menu_popup_el.querySelectorAll(".menu-item")] :
top_level_menus.map(top_level_menu => top_level_menu.menu_button_el);
const item_els_by_accelerator = {};
for (const item_el of item_els) {
const accelerator = item_el.querySelector(".menu-hotkey");
const accelerator_key = (accelerator ?
accelerator.textContent :
(item_el.querySelector(".menu-item-label") ?? item_el).textContent[0]
).toLowerCase();
item_els_by_accelerator[accelerator_key] = item_els_by_accelerator[accelerator_key] || [];
item_els_by_accelerator[accelerator_key].push(item_el);
}
const matching_item_els = item_els_by_accelerator[key] || [];
// console.log({ key, item_els, item_els_by_accelerator, matching_item_els });
if (matching_item_els.length) {
if (matching_item_els.length === 1) {
// it's unambiguous, go ahead and activate it
const menu_item_el = matching_item_els[0];
// click() doesn't work for menu buttons at the moment,
// and also we want to highlight the first item in the menu
// in that case, which doesn't happen with the mouse
const top_level_menu = top_level_menus.find(top_level_menu => top_level_menu.menu_button_el === menu_item_el);
if (top_level_menu) {
top_level_menu.open_top_level_menu("keydown");
} else {
menu_item_el.click();
}
e.preventDefault();
} else {
// cycle the menu items that match the key
let index = matching_item_els.indexOf(highlighted_item_el);
if (index === -1) {
index = 0;
} else {
index = (index + 1) % matching_item_els.length;
}
const menu_item_el = matching_item_els[index];
// active_menu_popup.highlight(index); // would very much not work
active_menu_popup.highlight(menu_item_el);
e.preventDefault();
}
}
break;
}
}
menus_el.addEventListener("keydown", handleKeyDown);
// TODO: API for context menus (i.e. floating menu popups)
function MenuPopup(menu_items, { parentMenuPopup } = {}) {
this.parentMenuPopup = parentMenuPopup;
this.menuItems = menu_items;
this.itemElements = []; // one-to-one with menuItems (note: not all itemElements have class .menu-item) (@TODO: unify terminology)
const menu_popup_el = E("div", {
class: "menu-popup",
id: `menu-popup-${uid()}`,
tabIndex: "-1",
role: "menu",
});
menu_popup_el.style.touchAction = "pan-y"; // will allow for scrolling overflowing menus in the future, but prevent event delay and double tap to zoom
menu_popup_el.style.outline = "none";
const menu_popup_table_el = E("table", { class: "menu-popup-table", role: "presentation" });
menu_popup_el.appendChild(menu_popup_table_el);
this.element = menu_popup_el;
menu_popup_by_el.set(menu_popup_el, this);
let submenus = [];
menu_popup_el.addEventListener("keydown", handleKeyDown);
menu_popup_el.addEventListener("pointerleave", () => {
// if there's a submenu popup, highlight the item for that, otherwise nothing
// could use aria-expanded for selecting this, alternatively
for (const submenu of submenus) {
if (submenu.submenu_popup_el.style.display !== "none") {
this.highlight(submenu.item_el);
return;
}
}
this.highlight(-1);
});
menu_popup_el.addEventListener("focusin", (e) => {
// prevent focus going to menu items; as designed, it works with aria-activedescendant and focus on the menu popup itself
// (on desktop when clicking (and dragging out) then pressing a key, or on mobile when tapping, a focus ring was visible, and it wouldn't go away with keyboard navigation either)
menu_popup_el.focus({ preventScroll: true });
});
let last_item_el;
this.highlight = (index_or_element) => { // index includes separators
let item_el = index_or_element;
if (typeof index_or_element === "number") {
item_el = this.itemElements[index_or_element];
}
if (last_item_el && last_item_el !== item_el) {
last_item_el.classList.remove("highlight");
}
if (item_el) {
item_el.classList.add("highlight");
menu_popup_el.setAttribute("aria-activedescendant", item_el.id);
last_item_el = item_el;
} else {
menu_popup_el.removeAttribute("aria-activedescendant");
last_item_el = null;
}
};
this.close = (focus_parent_menu_popup = true) => { // Note: won't focus menu bar buttons.
// idempotent
for (const submenu of submenus) {
submenu.submenu_popup.close(false);
}
if (focus_parent_menu_popup) {
this.parentMenuPopup?.element.focus({ preventScroll: true });
}
menu_popup_el.style.display = "none";
this.highlight(-1);
// after closing submenus, which should move the active_menu_popup to this level, move it up to the parent level
if (active_menu_popup === this) {
active_menu_popup = this.parentMenuPopup;
}
};
const add_menu_item = (parent_element, item, item_index) => {
const row_el = E("tr", { class: "menu-row" });
this.itemElements.push(row_el);
parent_element.appendChild(row_el);
if (item === MENU_DIVIDER) {
const td_el = E("td", { colspan: 4 });
const hr_el = E("hr", { class: "menu-hr" });
// hr_el.setAttribute("role", "separator"); // this is the implicit ARIA role for <hr>
// and setting it on the <tr> might cause problems due to multiple elements with the role
// hopefully it's fine that the semantic <hr> is nested?
td_el.appendChild(hr_el);
row_el.appendChild(td_el);
// Favorites menu behavior:
// hr_el.addEventListener("click", () => {
// this.highlight(-1);
// });
// Normal menu behavior:
hr_el.addEventListener("pointerenter", () => {
this.highlight(-1);
});
} else {
const item_el = row_el;
item_el.classList.add("menu-item");
item_el.id = `menu-item-${uid()}`;
item_el.tabIndex = -1; // may be needed for aria-activedescendant in some browsers?
item_el.setAttribute("role", item.checkbox ? item.checkbox.type === "radio" ? "menuitemradio" : "menuitemcheckbox" : "menuitem");
// prevent announcing the SHORTCUT (distinct from the hotkey, which would already not be announced unless it's e.g. a translated string like "새로 만들기 (&N)")
// remove_hotkey so it doesn't announce an ampersand
item_el.setAttribute("aria-label", remove_hotkey(item.label || item.item));
// include the shortcut semantically; if you want to display the shortcut differently than aria-keyshortcuts syntax,
// provide both ariaKeyShortcuts and shortcutLabel (old API: shortcut)
item_el.setAttribute("aria-keyshortcuts", item.ariaKeyShortcuts || item.shortcut || item.shortcutLabel);
if (item.description) {
item_el.setAttribute("aria-description", item.description);
}
const checkbox_area_el = E("td", { class: "menu-item-checkbox-area" });
const label_el = E("td", { class: "menu-item-label" });
const shortcut_el = E("td", { class: "menu-item-shortcut" });
const submenu_area_el = E("td", { class: "menu-item-submenu-area" });
item_el.appendChild(checkbox_area_el);
item_el.appendChild(label_el);
item_el.appendChild(shortcut_el);
item_el.appendChild(submenu_area_el);
label_el.innerHTML = display_hotkey(item.label || item.item);
shortcut_el.textContent = item.shortcut;
menu_popup_el.addEventListener("update", () => {
// item_el.disabled = is_disabled(item); // doesn't work, probably because it's a <tr>
if (is_disabled(item)) {
item_el.setAttribute("disabled", "");
item_el.setAttribute("aria-disabled", "true");
} else {
item_el.removeAttribute("disabled");
item_el.removeAttribute("aria-disabled");
}
if (item.checkbox && item.checkbox.check) {
const checked = item.checkbox.check();
item_el.setAttribute("aria-checked", checked ? "true" : "false");
}
});
// You may ask, why not call `send_info_event` in `highlight`?
// Consider the case where you hover to open a menu, and it sets highlight to none,
// it shouldn't reset the status bar. It needs to be more based on the pointer and keyboard interactions directly.
// *Maybe* it could be a parameter (to `highlight`) if that's really helpful, but it's probably not.
// *Maybe* it could look at more of the overall state within `highlight`,
// but could it distinguish hovering an outer vs an inner item if two are highlighted?
item_el.addEventListener("pointerenter", () => {
this.highlight(item_index);
send_info_event(item);
});
item_el.addEventListener("pointerleave", (event) => {
if (
menu_popup_el.style.display !== "none" && // not "left" due to closing
event.pointerType !== "touch" // not "left" as in finger lifting off
) {
send_info_event();
}
});
if (item.checkbox?.type === "radio") {
checkbox_area_el.classList.add("radio");
} else if (item.checkbox) {
checkbox_area_el.classList.add("checkbox");
}
let open_submenu, submenu_popup_el;
if (item.submenu) {
item_el.classList.add("has-submenu"); // @TODO: remove this, and use [aria-haspopup] instead (note true = menu)
submenu_area_el.classList.toggle("point-right", get_direction() === "rtl");
const submenu_popup = new MenuPopup(item.submenu, { parentMenuPopup: this });
submenu_popup_el = submenu_popup.element;
document.body?.appendChild(submenu_popup_el);
submenu_popup_el.style.display = "none";
item_el.setAttribute("aria-haspopup", "true");
item_el.setAttribute("aria-expanded", "false");
item_el.setAttribute("aria-controls", submenu_popup_el.id);
submenu_popup_by_menu_item_el.set(item_el, submenu_popup);
parent_item_el_by_popup_el.set(submenu_popup_el, item_el);
submenu_popup_el.dataset.semanticParent = menu_popup_el.id; // for $Window to understand the popup belongs to its window
menu_popup_el.setAttribute("aria-owns", `${menu_popup_el.getAttribute("aria-owns") || ""} ${submenu_popup_el.id}`);
submenu_popup_el.setAttribute("aria-labelledby", item_el.id);
open_submenu = (highlight_first = true) => {
if (submenu_popup_el.style.display !== "none") {
return;
}
if (item_el.getAttribute("aria-disabled") === "true") {
return;
}
close_submenus_at_this_level();
item_el.setAttribute("aria-expanded", "true");
submenu_popup_el.style.display = "";
submenu_popup_el.style.zIndex = get_new_menu_z_index();
submenu_popup_el.setAttribute("dir", get_direction());
if (window.inheritTheme) {
window.inheritTheme(submenu_popup_el, menu_popup_el);
}
if (!submenu_popup_el.parentElement) {
document.body.appendChild(submenu_popup_el);
}
// 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);
// setTimeout(() => { console.log("after timeout, menus_el.closest('.window').style.zIndex", menus_el.closest(".window").style.zIndex); }, 0);
submenu_popup_el.dispatchEvent(new CustomEvent("update"), {});
if (highlight_first) {
submenu_popup.highlight(0);
send_info_event(submenu_popup.menuItems[0]);
} else {
submenu_popup.highlight(-1);
// send_info_event(); // no, keep the status bar text!
}
const rect = item_el.getBoundingClientRect();
let submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
submenu_popup_el.style.position = "absolute";
submenu_popup_el.style.left = `${(get_direction() === "rtl" ? rect.left - submenu_popup_rect.width : rect.right) + window.scrollX}px`;
submenu_popup_el.style.top = `${rect.top + window.scrollY}px`;
submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
// This is surely not the cleanest way of doing this,
// and the logic is not very robust in the first place,
// but I want to get RTL support done and so I'm mirroring this in the simplest way possible.
if (get_direction() === "rtl") {
if (submenu_popup_rect.left < 0) {
submenu_popup_el.style.left = `${rect.right}px`;
submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
if (submenu_popup_rect.right > innerWidth) {
submenu_popup_el.style.left = `${innerWidth - submenu_popup_rect.width}px`;
}
}
} else {
if (submenu_popup_rect.right > innerWidth) {
submenu_popup_el.style.left = `${rect.left - submenu_popup_rect.width}px`;
submenu_popup_rect = submenu_popup_el.getBoundingClientRect();
if (submenu_popup_rect.left < 0) {
submenu_popup_el.style.left = "0";
}
}
}
submenu_popup_el.focus({ preventScroll: true });
active_menu_popup = submenu_popup;
};
submenus.push({
item_el,
submenu_popup_el,
submenu_popup,
});
function close_submenus_at_this_level() {
for (const { submenu_popup, item_el } of submenus) {
submenu_popup.close(false);
item_el.setAttribute("aria-expanded", "false");
}
menu_popup_el.focus({ preventScroll: true });
}
// It should close when hovering a different higher level menu
// after a delay, unless the mouse returns to the submenu.
// If you return the mouse from a submenu into its parent
// *directly onto the parent menu item*, it stays open, but if you cross other menu items
// in the parent menu, (@TODO:) it'll close after the delay even if you land on the parent menu item.
// Once a submenu opens (completing its animation if it has one),
// - up/down should navigate the submenu (although it should not show as focused right away)
// (i.e. up/down always navigate the most-nested open submenu, as long as it's not animating, in which case nothing happens)
// - @TODO: the submenu cancels its closing timeout (if you've moved outside all menus, say)
// (but if you move outside menus AFTER the submenu has opened, it should start the closing timeout)
// @TODO: make this more robust in general! Make some automated tests.
let open_tid, close_tid;
submenu_popup_el.addEventListener("pointerenter", () => {
if (open_tid) { clearTimeout(open_tid); open_tid = null; }
if (close_tid) { clearTimeout(close_tid); close_tid = null; }
});
item_el.addEventListener("pointerenter", () => {
// @TODO: don't cancel close timer? in Windows 98 it'll still close after a delay if you hover the submenu's parent item
if (open_tid) { clearTimeout(open_tid); open_tid = null; }
if (close_tid) { clearTimeout(close_tid); close_tid = null; }
open_tid = setTimeout(() => {
open_submenu(false);
}, 501); // @HACK: slightly longer than close timer so it doesn't close immediately
});
item_el.addEventListener("pointerleave", () => {
if (open_tid) { clearTimeout(open_tid); open_tid = null; }
});
menu_popup_el.addEventListener("pointerenter", (event) => {
// console.log(event.target.closest(".menu-item"));
if (event.target.closest(".menu-item") === item_el) {
return;
}
if (!close_tid) {
// This is a little confusing, with timers per-item...
// @TODO: try doing this with just one or two timers.
// if (submenus.some(submenu => submenu.submenu_popup_el.style.display !== "none")) {
if (submenu_popup_el.style.display !== "none") {
close_tid = setTimeout(() => {
if (!window.debugKeepMenusOpen) {
// close_submenu();
close_submenus_at_this_level();
}
}, 500);
}
}
});
// keep submenu open while mouse is outside any parent menus
// (@TODO: what if it goes to another parent menu though?)
menu_popup_el.addEventListener("pointerleave", () => {
if (close_tid) { clearTimeout(close_tid); close_tid = null; }
});
item_el.addEventListener("pointerdown", () => { open_submenu(false); });
}
let just_activated = false; // to prevent double-activation from pointerup + click
const item_action = () => {
if (just_activated) {
return;
}
just_activated = true;
setTimeout(() => { just_activated = false; }, 10);
if (item.checkbox) {
if (item.checkbox.toggle) {
item.checkbox.toggle();
}
menu_popup_el.dispatchEvent(new CustomEvent("update"), {});
} else if (item.action) {
close_menus();
refocus_window(); // before action, so things like copy/paste have a better chance of working
item.action();
}
};
// pointerup is for gliding to menu items to activate
item_el.addEventListener("pointerup", e => {
if (e.pointerType === "mouse" && e.button !== 0) {
return;
}
if (e.pointerType === "touch") {
// Will use click instead; otherwise focus is lost on a delay: if it opens a dialog for example,
// you have to hold down on the menu item for a bit otherwise it'll blur the dialog after opening.
// I think this is caused by the pointer falling through to elements without touch-action defined.
// RIGHT NOW, gliding to menu items isn't supported for touch anyways,
// although I'd like to support it in the future.
// Well, it might have accessibility problems, so maybe not. I think this is fine.
return;
}
item_el.click();
});
item_el.addEventListener("click", e => {
if (item.submenu) {
open_submenu(true);
} else {
item_action();
}
});
}
};
if (menu_items.length === 0) {
menu_items = [{
label: "(Empty)",
enabled: false,
}];
}
let init_index = 0;
for (const item of menu_items) {
if (item.radioItems) {
const tbody = E("tbody", { role: "group" }); // multiple tbody elements are allowed, can be used for grouping rows,
// and in this case providing an ARIA role for the radio group.
if (item.ariaLabel) {
tbody.setAttribute("aria-label", item.ariaLabel);
}
for (const radio_item of item.radioItems) {
radio_item.checkbox = {
type: "radio",
check: () => radio_item.value === item.getValue(),
toggle: () => {
item.setValue(radio_item.value);
},
};
add_menu_item(tbody, radio_item, init_index++);
}
menu_popup_table_el.appendChild(tbody);
} else {
add_menu_item(menu_popup_table_el, item, init_index++);
}
}
}
// let this_click_opened_the_menu = false;
const make_menu_button = (menus_key, menu_items) => {
const menu_button_el = E("div", {
class: "menu-button",
"aria-expanded": "false",
"aria-haspopup": "true",
role: "menuitem",
});
menus_el.appendChild(menu_button_el);
const menu_popup = new MenuPopup(menu_items);
const menu_popup_el = menu_popup.element;
document.body?.appendChild(menu_popup_el);
submenu_popup_by_menu_item_el.set(menu_button_el, menu_popup);
parent_item_el_by_popup_el.set(menu_popup_el, menu_button_el);
menu_button_el.id = `menu-button-${menus_key}-${uid()}`;
menu_popup_el.dataset.semanticParent = menu_button_el.id; // for $Window to understand the popup belongs to its window
menu_button_el.setAttribute("aria-controls", menu_popup_el.id);
menu_popup_el.setAttribute("aria-labelledby", menu_button_el.id);
menus_el.setAttribute("aria-owns", `${menus_el.getAttribute("aria-owns") || ""} ${menu_popup_el.id}`);
const update_position_from_containing_bounds = () => {
const rect = menu_button_el.getBoundingClientRect();
let popup_rect = menu_popup_el.getBoundingClientRect();
menu_popup_el.style.position = "absolute";
menu_popup_el.style.left = `${(get_direction() === "rtl" ? rect.right - popup_rect.width : rect.left) + window.scrollX}px`;
menu_popup_el.style.top = `${rect.bottom + window.scrollY}px`;
const uncorrected_rect = menu_popup_el.getBoundingClientRect();
// rounding down is needed for RTL layout for the rightmost menu, to prevent a scrollbar
if (Math.floor(uncorrected_rect.right) > innerWidth) {
menu_popup_el.style.left = `${innerWidth - uncorrected_rect.width}px`;
}
if (Math.ceil(uncorrected_rect.left) < 0) {
menu_popup_el.style.left = "0px";
}
};
window.addEventListener("resize", update_position_from_containing_bounds);
menu_popup_el.addEventListener("update", update_position_from_containing_bounds);
// update_position_from_containing_bounds(); // will be called when the menu is opened
const menu_id = menus_key.replace("&", "").replace(/ /g, "-").toLowerCase();
menu_button_el.classList.add(`${menu_id}-menu-button`);
// menu_popup_el.id = `${menu_id}-menu-popup-${uid()}`; // id is created by MenuPopup and changing it breaks the data-semantic-parent relationship
menu_popup_el.style.display = "none";
menu_button_el.innerHTML = display_hotkey(menus_key);
menu_button_el.tabIndex = -1;
menu_button_el.setAttribute("aria-haspopup", "true");
menu_button_el.setAttribute("aria-controls", menu_popup_el.id);
menu_button_el.addEventListener("focus", () => {
top_level_highlight(menus_key);
});
menu_button_el.addEventListener("pointerdown", e => {
if (menu_button_el.classList.contains("active")) {
menu_button_el.dispatchEvent(new CustomEvent("release", {}));
refocus_window();
e.preventDefault(); // needed for refocus_window() to work
} else {
open_top_level_menu(e.type);
}
});
menu_button_el.addEventListener("pointermove", e => {
top_level_highlight(menus_key);
if (e.pointerType === "touch") {
return;
}
if (selecting_menus) {
open_top_level_menu(e.type);
}
});
function open_top_level_menu(type = "other") {
const new_index = Object.keys(menus).indexOf(menus_key);
if (new_index === top_level_menu_index && menu_button_el.getAttribute("aria-expanded") === "true") {
return; // already open
}
close_menus();
menu_button_el.classList.add("active");
menu_button_el.setAttribute("aria-expanded", "true");
menu_popup_el.style.display = "";
menu_popup_el.style.zIndex = get_new_menu_z_index();
menu_popup_el.setAttribute("dir", get_direction());
if (window.inheritTheme) {
window.inheritTheme(menu_popup_el, menus_el);
}
if (!menu_popup_el.parentElement) {
document.body.appendChild(menu_popup_el);
}
// 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);
// setTimeout(() => { console.log("after timeout, menus_el.closest('.window').style.zIndex", menus_el.closest(".window").style.zIndex); }, 0);
top_level_highlight(menus_key);
menu_popup_el.dispatchEvent(new CustomEvent("update"), {});
selecting_menus = true;
menu_popup_el.focus({ preventScroll: true });
active_menu_popup = menu_popup;
if (type === "keydown") {
menu_popup.highlight(0);
send_info_event(menu_popup.menuItems[0]);
} else {
send_info_event(); // @TODO: allow descriptions on top level menus
}
};
menu_button_el.addEventListener("release", () => {
selecting_menus = false;
menu_button_el.classList.remove("active");
if (!window.debugKeepMenusOpen) {
menu_popup.close(true);
menu_button_el.setAttribute("aria-expanded", "false");
}
menus_el.dispatchEvent(new CustomEvent("default-info", {}));
});
top_level_menus.push({
menu_button_el,
menu_popup_el,
menus_key,
hotkey: get_hotkey(menus_key),
open_top_level_menu,
});
};
for (const menu_key in menus) {
make_menu_button(menu_key, menus[menu_key]);
}
window.addEventListener("keydown", e => {
// close any errant menus
// taking care not to interfere with regular Escape key behavior
// @TODO: listen for menus_el removed from DOM, and close menus there
if (
!document.activeElement ||
!document.activeElement.closest || // window or document
!document.activeElement.closest(".menus, .menu-popup")
) {
if (e.key === "Escape") {
if (active_menu_popup) {
close_menus();
e.preventDefault();
}
}
}
});
// window.addEventListener("blur", close_menus);
window.addEventListener("blur", (event) => {
// hack for Pinball (in 98.js.org) where it triggers fake blur events
// in order to pause the game
if (!event.isTrusted) {
return;
}
close_menus();
});
function close_menus_on_click_outside(event) {
if (event.target?.closest?.(".menus") === menus_el || event.target?.closest?.(".menu-popup")) {
return;
}
// window.console && console.log(event.type, "occurred outside of menus (on ", event.target, ") so...");
close_menus();
top_level_highlight(-1);
}
window.addEventListener("pointerdown", close_menus_on_click_outside);
window.addEventListener("pointerup", close_menus_on_click_outside);
window.addEventListener("focusout", (event) => {
// if not still in menus, unhighlight (e.g. if you hit Escape to unfocus the menus)
if (event.relatedTarget?.closest?.(".menus") === menus_el || event.relatedTarget?.closest?.(".menu-popup")) {
return;
}
close_menus();
// Top level buttons should no longer be highlighted due to focus, but still may be highlighted due to hover.
top_level_highlight(top_level_menus.findIndex(({ menu_button_el }) => menu_button_el.matches(":hover")));
});
let keyboard_scope_elements = [];
function set_keyboard_scope(...elements) {
for (const el of keyboard_scope_elements) {
el.removeEventListener("keydown", keyboard_scope_keydown);
}
keyboard_scope_elements = elements;
for (const el of keyboard_scope_elements) {
el.addEventListener("keydown", keyboard_scope_keydown);
}
}
function keyboard_scope_keydown(e) {
// Close menus if the user presses almost any key combination
// e.g. if you look in the menu to remember a shortcut,
// and then use the shortcut.
if (
(e.ctrlKey || e.metaKey) && // Ctrl or Command held down
// and anything then pressed other than Ctrl or Command
e.key !== "Control" &&
e.key !== "Meta"
) {
close_menus();
return;
}
if (e.defaultPrevented) {
return; // closing menus above is meant to be done when activating unrelated shortcuts
// but stuff after this is should not be handled at the same time as something else
}
if (e.altKey && !e.shiftKey && !e.ctrlKey && !e.metaKey) { // Alt held
const menu = top_level_menus.find((menu) =>
menu.hotkey.toLowerCase() === e.key.toLowerCase()
);
if (menu) {
e.preventDefault();
menu.open_top_level_menu("keydown");
}
}
}
set_keyboard_scope(window);
this.element = menus_el;
this.closeMenus = close_menus;
this.setKeyboardScope = set_keyboard_scope;
}
exports.MenuBar = MenuBar;
exports.MENU_DIVIDER = MENU_DIVIDER;
})(window);