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]
 * @param {string|number} [config.itemHeight] - Height of each menu item
 * @param {string} [config.itemBackground] - Background of each menu item
 * @param {object} [config.animation] - Animation config when showing the menu
 * @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}"` : ""
			const itemHeight = (config.height) ? `style="height: ${config.height}; line-height: ${config.height};"` : ""

			return `
                ${(typeof config != "string")
		? `<div class="menu-item ${altClass}" ${itemHeight}>
                            <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", "itemHeight=height"],
				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)