/**
* A menu containing {@link MenuItem} entries.
* <p>Part of the XUI module which, for now, has an undocumented API.
*/
export class Menu extends HTMLElement {
static create(itemOptionsList) {
// Needed because a custom HTML element constructor may not have
// arguments.
let menu = document.createElement("xui-menu");
menu.removeAllItems();
if (itemOptionsList !== null) {
for (let opts of itemOptionsList) {
menu.addItem(MenuItem.create(opts));
}
}
return menu;
}
constructor() {
super();
// Shadow DOM is needed because 1) custom element 2) populated
// by xui-menu-items BEFORE the xui-menu is attached to the document.
let shadow = this.attachShadow({mode: "open"});
Util.addStylesheetLink(shadow);
this._menu = document.createElement("div");
this._menu.className = "xui-control xui-menu";
shadow.appendChild(this._menu);
this._menuClosed = this.menuClosed.bind(this);
this._handleEvent = this.handleEvent.bind(this);
this._ownerItem = null;
this._allItems = null; // Item cache.
}
get ownerItem() {
return this._ownerItem;
}
set ownerItem(item) {
assertOrError(this._ownerItem === null);
this._ownerItem = item;
}
removeAllItems() {
let all = this.getAllItems();
Menu.removeChildren(this._menu);
for (let item of all) {
this.detachItem(item);
}
this._allItems = null; // Clear item cache.
}
static removeChildren(parent) {
let node = parent.firstChild;
while (node !== null) {
let next = node.nextSibling;
parent.removeChild(node);
node = next;
}
}
getAllItems() {
if (this._allItems === null) {
this._allItems = [];
Menu.doGetAllItems(this._menu, this._allItems);
}
return this._allItems;
}
static doGetAllItems(tree, list) {
let node = tree.firstChild;
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node instanceof MenuItem) {
list.push(node);
} else {
Menu.doGetAllItems(node, list);
}
}
node = node.nextSibling;
}
}
addItem(item, beforeItem=null, deep=true) {
assertOrError(item.parentMenu === null);
let parentMenu = (beforeItem === null)? null : beforeItem.parentMenu;
if (parentMenu !== null) {
if (parentMenu === this) {
this.doAddItem(item, beforeItem);
} else {
if (deep) {
parentMenu.addItem(item, beforeItem, false);
}
}
} else {
this.doAddItem(item, null);
}
}
doAddItem(item, beforeItem) {
let all = this.getAllItems();
let count = all.length;
if (beforeItem === null) {
all.push(item);
} else {
for (let i = 0; i < count; ++i) {
if (all[i] === beforeItem) {
all.splice(i, 0, item);
break;
}
}
}
++count;
Menu.removeChildren(this._menu);
let row = null;
for (let i = 0; i < count; ++i) {
let added = all[i];
if (added.isIconOnly) {
let separ = added.separator;
if (row === null) {
row = document.createElement("div");
row.className = "xui-control xui-menu-row";
this._menu.appendChild(row);
if (added.separator) {
row.style.borderTopStyle = "solid";
separ = false;
}
}
row.appendChild(added);
if (separ) {
added.contents.style.borderLeftStyle = "solid";
} else {
added.contents.style.borderLeftStyle = null;
}
} else {
row = null;
this._menu.appendChild(added);
if (added.separator) {
added.contents.style.borderTopStyle = "solid";
} else {
added.contents.style.borderTopStyle = null;
}
}
}
this.attachItem(item);
this._allItems = null; // Clear item cache.
}
removeItem(item, deep=true) {
let parentMenu = item.parentMenu;
if (parentMenu === this) {
this.doRemoveItem(item);
} else {
if (deep && parentMenu !== null) {
parentMenu.removeItem(item, false);
}
}
}
doRemoveItem(item) {
let container = item.parentElement;
container.removeChild(item);
if (container.className === "xui-menu-row" &&
container.childElementCount === 0) {
this._menu.removeChild(container);
}
this.detachItem(item);
this._allItems = null; // Clear item cache.
}
getItem(name, deep=true) {
let all = this.getAllItems();
for (let item of all) {
if (item.name === name) {
return item;
}
}
if (deep) {
for (let item of all) {
if (item.itemType === "submenu") {
let found = item.submenu.getItem(name, true);
if (found !== null) {
return found;
}
}
}
}
return null;
}
open(position, reference=null) {
this.openingMenu(this);
let popup = Dialogs.open({ form: this, type: "popup",
position: position, reference: reference });
popup.addEventListener("dialogclosed", this._menuClosed);
}
openingMenu(menu) {
let event =
new Event("openingmenu",
{ bubbles: true, cancelable: false, composed: true });
event.xuiMenu = menu;
this.dispatchEvent(event);
}
menuClosed() {
let event =
new Event("menuclosed",
{ bubbles: true, cancelable: false, composed: true });
event.xuiMenu = this;
this.dispatchEvent(event);
}
// -----------------------------------------------------------------------
// MenuItem events
// -----------------------------------------------------------------------
attachItem(item) {
item.parentMenu = this;
for (const eventType of Menu.HANDLED_EVENT_TYPES) {
item.addEventListener(eventType, this._handleEvent);
}
}
detachItem(item) {
if (item.separator) {
if (item.isIconOnly) {
item.contents.style.borderLeftStyle = null;
} else {
item.contents.style.borderTopStyle = null;
}
}
item.parentMenu = null;
for (const eventType of Menu.HANDLED_EVENT_TYPES) {
item.removeEventListener(eventType, this._handleEvent);
}
}
handleEvent(event) {
let topMenu = this.getTopMenu();
if (topMenu !== this) {
// This is just a submenu. Forward event to top menu.
topMenu.handleEvent(event);
return;
}
// ---
const item = event.target;
switch (event.type) {
case "mouseenter":
this.openSubmenu(item);
break;
case "mouseup":
// User often drags her/his mouse over menu items and
// releases it over item to be selected.
this.selectMenuItem(item, Menu.mouseEventModifiers(event));
//FALLTHROUGH
case "click":
case "auxclick":
case "contextmenu":
// Any button, any modifier is OK. So for example prevent the
// browser from displaying its context menu.
event.preventDefault();
event.stopPropagation();
break;
}
// Possible enhancement: support keyboard navigation.
// * "ArrowDown": highlight/focus next item.
// * "ArrowUp": highlight/focus previous item.
// * "ArrowRight": open submenu.
// * "ArrowLeft": close submenu.
// * "Enter": select item.
}
getTopMenu() {
let topMenu = this;
while (topMenu.ownerItem !== null) {
topMenu = topMenu.ownerItem.parentMenu;
}
return topMenu;
}
openSubmenu(item) {
this.closeAllSubmenus(item.parentMenu);
if (item.itemType === "submenu" && item.enabled) {
this.openingMenu(item.submenu);
item.submenu.open("submenu", item);
}
}
closeAllSubmenus(currentSubmenu=null) {
Menu.doCloseAllSubmenus(this._menu, currentSubmenu);
}
static doCloseAllSubmenus(tree, currentSubmenu) {
let node = tree.firstChild;
while (node !== null) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node instanceof MenuItem) {
if (node.itemType === "submenu") {
const submenu = node.submenu;
if (submenu.parentNode !== null) {
// Contained in a dialog: opened hence closable.
submenu.closeAllSubmenus(currentSubmenu);
if (currentSubmenu === null ||
!currentSubmenu.isSubmenuOf(submenu)) {
Dialogs.close(submenu);
}
}
}
} else {
Menu.doCloseAllSubmenus(node, currentSubmenu);
}
}
node = node.nextSibling;
}
}
isSubmenuOf(otherMenu) {
let isSubmenu = false;
let menu = this;
for (;;) {
if (menu === otherMenu) {
isSubmenu = true;
break;
}
if (menu.ownerItem !== null) {
menu = menu.ownerItem.parentMenu;
} else {
break;
}
}
return isSubmenu;
}
static mouseEventModifiers(event) {
let modifiers = [];
if (event.altKey) {
modifiers.push("alt");
}
if (event.ctrlKey) {
modifiers.push("ctrl");
}
if (event.metaKey) {
modifiers.push("meta");
}
if (event.shiftKey) {
modifiers.push("shift");
}
return modifiers;
}
selectMenuItem(item, modifiers) {
if (item.enabled) {
switch (item.itemType) {
case "checkbox":
case "radiobutton":
item.selected = !item.selected;
//FALLTHROUGH
case "button":
this.menuItemSelected(item, modifiers);
this.closeAllSubmenus();
Dialogs.close(this);
break;
}
}
}
menuItemSelected(item, modifiers) {
let event =
new Event("menuitemselected",
{ bubbles: true, cancelable: false, composed: true });
event.xuiMenuItem = item;
event.xuiInputEventModifiers = modifiers;
this.dispatchEvent(event);
}
}
Menu.HANDLED_EVENT_TYPES = [
"mouseenter", "mouseup", "click", "auxclick", "contextmenu"
];
window.customElements.define("xui-menu", Menu);