mirror of
https://github.com/ducbao414/win32.run.git
synced 2025-12-17 01:32:50 +09:00
init the awkward code
This commit is contained in:
996
static/html/jspaint/lib/os-gui/MenuBar.js
Normal file
996
static/html/jspaint/lib/os-gui/MenuBar.js
Normal file
@@ -0,0 +1,996 @@
|
||||
((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);
|
||||
Reference in New Issue
Block a user