Source: xui/Menu.js

/**
 * 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);