Source

client/ui/elements/menu.js

/**
 * 
 * The Menu derives from [Component](kiss.ui.Component.html).
 * 
 * The menu contains a list of items where each items is:
 * ```
 * {
 *      text: "Do this",
 *      icon: "fas fa-cog", // Font awesome icon class
 *      iconSize: "40px", // Optional icon size
 *      iconColor: "#00aaee", // Optional icon color
 *      action: () => {} // Function to execute when the menu is clicked
 * }
 * ```
 * 
 * @param {object} config
 * @param {object[]|string[]} config.items - The array of menu entries
 * @param {boolean} config.closeOnClick - Set to false if the menu should not be closed after an entry is clicked. Default to true
 * @param {boolean} config.closeOnExit - Set to false if the menu should not be closed after exiting. Default to true
 * @param {string} [config.classModifier] - Custom class to apply to the menu and menu items
 * @param {string} [config.top]
 * @param {string} [config.left]
 * @param {string|number} [config.width]
 * @param {string|number} [config.maxWidth]
 * @param {string|number} [config.height]
 * @param {string|number} [config.maxHeight]
 * @param {string} [config.color]
 * @param {string} [config.background]
 * @param {string} [config.backgroundColor]
 * @param {string} [config.padding]
 * @param {string} [config.border]
 * @param {string} [config.borderStyle]
 * @param {string} [config.borderWidth]
 * @param {string} [config.borderColor]
 * @param {string} [config.borderRadius]
 * @param {string} [config.boxShadow]
 * @returns this
 * 
 * ## Generated markup
 * ```
 * <a-menu class="a-menu">
 * 
 *  <!-- For each menu item -->
 *  <div class="menu-item classModifier">
 *      <span class="menu-item-icon classModifier"></span>
 *      <span class="menu-item-text classModifier"></span>
 *  </div>
 * 
 *  <!-- For each menu separator -->
 *  <div class="menu-separator"></div>
 * 
 * </a-menu>
 * ```
 */
kiss.ui.Menu = class Menu extends kiss.ui.Component {
    /**
     * Its a Custom Web Component. Do not use the constructor directly with the **new** keyword.
     * Instead, use one of the 3 following methods:
     * 
     * Create the Web Component and call its **init** method:
     * ```
     * const myMenu = document.createElement("a-menu").init(config)
     * ```
     * 
     * Or use the shorthand for it:
     * ```
     * const myMenu = createMenu({
     *  items: [
     *      "This is a title", // Simple text is considered a title
     *      {
     *          icon: "fas fa-check",
     *          text: "Do this",
     *          action: () => {...}
     *      },
     *      "-", // Menu separator
     *      {
     *          hidden: !canSeeThisEntry, // It's possible to hide a menu entry using the hidden property
     *          icon: "fas fa-cube",
     *          text: "Do that",
     *          action: () => {...}
     *      },
     *      "Parameters:", // Text entries are processed as section titles inside the menu
     *      {
     *          icon: "fas fa-circle",
     *          iconSize: "32px", // It's possible to alter the default icon size
     *          iconColor: "#00aaee", // It's possible to alter the default icon color
     *          text: "Do that",
     *          action: () => {...}
     *      }
     *  ]
     * })
     * 
     * myMenu.render().showAt(100, 100) // Display the menu at position 100, 100
     * ```
     */
    constructor() {
        super()
    }

    /**
     * Generates an Menu from a JSON config
     * 
     * @ignore
     * @param {object} config - JSON config
     * @returns {HTMLElement}
     */
    init(config) {
        super.init(config)

        // Define a class modifier
        const altClass = config.classModifier || ""

        // Template for a single menu item
        let defaultMenuItemRenderer = function (config) {
            const iconSize = (config.iconSize) ? `font-size: ${config.iconSize};` : ""
            const iconColor = (config.iconColor) ? `color: ${config.iconColor}` : ""
            const iconStyle = (iconSize || iconColor) ? `style="${iconSize} ${iconColor}"` : ""

            return `
                ${(typeof config != "string")
                    ? `<div class="menu-item ${altClass}">
                            <span class="menu-item-icon ${altClass}"><i ${iconStyle} class="${config.icon}"></i></span>
                            <span class="menu-item-text ${altClass}">${config.text}</span>
                        </div>`
                    : ((config == "-") ? `<div class="menu-separator"></div>` : `<div class="menu-section">${config}</div>`)
                }`.removeExtraSpaces()
        }

        // Template for the complete menu which contains the menu items
        this.visibleItems = config.items.filter(item => item.hidden != true && item != null && item != "")
        
        // Add a header to close the menu for mobile UI
        this.innerHTML = (!kiss.screen.isMobile) ? "" : /*html*/`
            <span class="a-menu-mobile-close fas fa-chevron-left" onclick="this.closest('a-menu').close()"></span>
        `

        this.innerHTML += this.visibleItems.map(item => {
            const itemRenderer = item.itemRenderer || defaultMenuItemRenderer
            return itemRenderer(item)
        }).join("")

        // Set properties
        this.style.position = "absolute"
        this.style.display = "block"
        this.style.zIndex = 10000
        this.menuItems = this.querySelectorAll(".menu-item, .menu-separator, .menu-section")

        // Prevent menu from overflowing the viewport height
        if (!config.maxHeight) config.maxHeight = () => kiss.screen.current.height - 20

        // Apply configs
        this._setProperties(config, [
            [
                ["padding", "top", "left", "bottom", "right", "width", "minWidth", "maxWidth", "height", "minHeight", "maxHeight", "color", "background", "backgroundColor", "border", "borderStyle", "borderWidth", "borderColor", "borderRadius", "boxShadow", "zIndex"],
                [this.style]
            ],
            [
                ["itemBackground=background"],
                Array.from(this.menuItems).map(menuItem => menuItem.style) // Apply to all menu sub-items
            ]
        ])

        // Auto closing (default to true)
        if (config.closeOnClick !== false) config.closeOnClick = true

        // Bind menu item actions to DOM nodes 'onclick' event
        let menu = this
        for (let i = 0; i < this.menuItems.length; i++) {
            if (typeof menu.visibleItems[i] != "string") this.menuItems[i].onclick = function (event) {
                if (config.closeOnClick) menu.close()
                menu.visibleItems[i].action(event)
            }
        }

        // Remove menu on exit
        if (config.closeOnExit !== false) this.onmouseleave = () => this.close()

        // Set a default animation
        this.setAnimation(config.animation || {
            name: "zoomIn",
            speed: "light"
        })

        return this
    }

    /**
     * Close the menu (remove it from the DOM)
     */
    close() {
        kiss.views.remove(this.id)
    }

    /**
     * Ensure the menu is 100% visible inside the viewport
     * 
     * @private
     * @ignore
     */
    _afterRender() {
        kiss.tools.moveToViewport(this)
    }
}

/**
 * Close all menus when clicking outside of menu
 */
document.addEventListener('mousedown', function(event) {
    const menu = document.querySelector('a-menu')
    if (!menu) return

    const rect = menu.getBoundingClientRect()
    const x = event.clientX
    const y = event.clientY

    if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return

    const menus = Array.from(document.querySelectorAll("a-menu"))
    menus.forEach(menu => menu.close())        
})

// Create a Custom Element and add a shortcut to create it
customElements.define("a-menu", kiss.ui.Menu)

/**
 * Shorthand to create a new Menu. See [kiss.ui.Menu](kiss.ui.Menu.html)
 * 
 * @param {object} config
 * @returns HTMLElement
 */
const createMenu = (config) => document.createElement("a-menu").init(config)

;