| // Copyright 2016 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| /** |
| * @fileoverview This file represents a standalone, reusable drop down menu |
| * widget that can be attached to any element on a given page. It supports |
| * multiple instances of the widget on a page. It has no dependencies. Usage |
| * is as simple as creating a new Menu object and supplying it with a target |
| * element. |
| */ |
| |
| /** |
| * The entry point and constructor for the Menu object. Creating |
| * a valid instance of this object will insert a drop down menu |
| * near the element supplied as the target, attach all the necessary |
| * events and insert the necessary elements on the page. |
| * |
| * @param {Element} target the target element on the page to which |
| * the drop down menu will be placed near. |
| * @param {Function=} opt_onShow function to execute every time the |
| * menu is made visible, most likely through a click on the target. |
| * @constructor |
| */ |
| var Menu = function(target, opt_onShow) { |
| this.iid = Menu.instance.length; |
| Menu.instance[this.iid] = this; |
| this.target = target; |
| this.onShow = opt_onShow || null; |
| |
| // An optional trigger element on the page that can be used to trigger |
| // the drop-down. Currently hard-coded to be the same as the target element. |
| this.trigger = target; |
| this.items = []; |
| this.onOpenEvents = []; |
| this.menu = this.createElement('div', 'menuDiv instance' + this.iid); |
| this.targetId = this.target.getAttribute('id'); |
| let menuId = (this.targetId != null) ? |
| 'menuDiv-' + this.targetId : 'menuDiv-instance' + this.iid; |
| this.menu.setAttribute('id', menuId); |
| this.menu.role = 'listbox'; |
| this.hide(); |
| this.addCategory('default'); |
| this.addEvent(this.trigger, 'click', this.toggle.bind(this)); |
| this.addEvent(window, 'resize', this.adjustSizeAndLocation.bind(this)); |
| |
| // Hide the menu if a user clicks outside the menu widget |
| this.addEvent(document, 'click', this.hide.bind(this)); |
| this.addEvent(this.menu, 'click', this.stopPropagation()); |
| this.addEvent(this.trigger, 'click', this.stopPropagation()); |
| }; |
| |
| // A reference to the element or node that the drop down |
| // will appear next to |
| Menu.prototype.target = null; |
| |
| // Element ID of the target. ID will be assigned to the newly created |
| // menu div based on the target ID. A default ID will be |
| // assigned If there is no ID on the target. |
| Menu.prototype.targetId = null; |
| |
| /** |
| * A reference to the element or node that will trigger |
| * the drop down to appear. If not specified, this value |
| * will be the same as <Menu Instance>.target |
| * @type {Element} |
| */ |
| Menu.prototype.trigger = null; |
| |
| // A reference to the event type that will "open" the |
| // menu div. By default this is the (on)click method. |
| Menu.prototype.triggerType = null; |
| |
| // A reference to the element that will appear when the |
| // trigger is clicked. |
| Menu.prototype.menu = null; |
| |
| /** |
| * Function to execute every time the menu is made shown. |
| * @type {Function} |
| */ |
| Menu.prototype.onShow = null; |
| |
| // A list of category divs. By default these categories |
| // are set to display none until at least one element |
| // is placed within them. |
| Menu.prototype.categories = null; |
| |
| // An id used to track timed intervals |
| Menu.prototype.thread = -1; |
| |
| // The static instance id (iid) denoting which menu in the |
| // list of Menu.instance items is this instantiated object. |
| Menu.prototype.iid = -1; |
| |
| // A counter to indicate the number of items added with |
| // addItem(). After 5 items, a height is set on the menu |
| // and a scroll bar will appear. |
| Menu.prototype.items = null; |
| |
| // A flag to detect whether or not a scroll bar has been added |
| Menu.prototype.scrolls = false; |
| |
| // onOpen event handlers; each function in this list will |
| // be executed and passed the executing instance as a |
| // parameter before the menu is to be displayed. |
| Menu.prototype.onOpenEvents = null; |
| |
| /** |
| * An extended short-cut for document.createElement(); this |
| * method allows the creation of an element, the assignment |
| * of one or more class names and the ability to set the |
| * content of the created element all with one function call. |
| * @param {string} element name of the element to create. Examples would |
| * be 'div' or 'a'. |
| * @param {string} opt_className an optional string to assign to the |
| * newly created element's className property. |
| * @param {string|Element} opt_content either a snippet of HTML or a HTML |
| * element that is to be appended to the newly created element. |
| * @return {Element} a reference to the newly created element. |
| */ |
| Menu.prototype.createElement = function(element, opt_className, opt_content) { |
| let div = document.createElement(element); |
| div.className = opt_className; |
| if (opt_content) { |
| this.append(opt_content, div); |
| } |
| return div; |
| }; |
| |
| /** |
| * Uses a fairly browser agnostic approach to applying a callback to |
| * an element on the page. |
| * |
| * @param {Element|EventTarget} element a reference to an element on the page to |
| * which to attach and event. |
| * @param {string} eventType a browser compatible event type as a string |
| * without the sometimes assumed on- prefix. Examples: 'click', |
| * 'mousedown', 'mouseover', etc... |
| * @param {Function} callback a function reference to invoke when the |
| * the event occurs. |
| */ |
| Menu.prototype.addEvent = function(element, eventType, callback) { |
| if (element.addEventListener) { |
| element.addEventListener(eventType, callback, false); |
| } else { |
| try { |
| element.attachEvent('on' + eventType, callback); |
| } catch (e) { |
| element['on' + eventType] = callback; |
| } |
| } |
| }; |
| |
| /** |
| * Similar to addEvent, this provides a specialied handler for onOpen |
| * events that apply to this instance of the Menu class. The supplied |
| * callbacks are appended to an internal array and called in order |
| * every time the menu is opened. The array can be accessed via |
| * menuInstance.onOpenEvents. |
| */ |
| Menu.prototype.addOnOpen = function(eventCallback) { |
| let eventIndex = this.onOpenEvents.length; |
| this.onOpenEvents.push(eventCallback); |
| return eventIndex; |
| }; |
| |
| /** |
| * This method will create a div with the classes .menuCategory and the |
| * name of the category as supplied in the first parameter. It then, if |
| * a title is supplied, creates a title div and appends it as well. The |
| * optional title is styled with the .categoryTitle and category name |
| * class. |
| * |
| * Categories are stored within the menu object instance for programmatic |
| * manipulation in the array, menuInstance.categories. Note also that this |
| * array is doubly linked insofar as that the category div can be accessed |
| * via it's index in the array as well as by instance.categories[category] |
| * where category is the string name supplied when creating the category. |
| * |
| * @param {string} category the string name used to create the category; |
| * used as both a class name and a key into the internal array. It |
| * must be a valid JavaScript variable name. |
| * @param {string|Element} opt_title this optional field is used to visibly |
| * denote the category title. It can be either HTML or an element. |
| * @return {Element} the newly created div. |
| */ |
| Menu.prototype.addCategory = function(category, opt_title) { |
| this.categories = this.categories || []; |
| let categoryDiv = this.createElement('div', 'menuCategory ' + category); |
| categoryDiv._categoryName = category; |
| if (opt_title) { |
| let categoryTitle = this.createElement('b', 'categoryTitle ' + |
| category, opt_title); |
| categoryTitle.style.display = 'block'; |
| this.append(categoryTitle); |
| categoryDiv._categoryTitle = categoryTitle; |
| } |
| this.append(categoryDiv); |
| this.categories[this.categories.length] = this.categories[category] = |
| categoryDiv; |
| |
| return categoryDiv; |
| }; |
| |
| /** |
| * This method removes the contents of a given category but does not |
| * remove the category itself. |
| */ |
| Menu.prototype.emptyCategory = function(category) { |
| if (!this.categories[category]) { |
| return; |
| } |
| let div = this.categories[category]; |
| for (let i = div.childNodes.length - 1; i >= 0; i--) { |
| div.removeChild(div.childNodes[i]); |
| } |
| }; |
| |
| /** |
| * This function is the most drastic of the cleansing functions; it removes |
| * all categories and all menu items and all HTML snippets that have been |
| * added to this instance of the Menu class. |
| */ |
| Menu.prototype.clear = function() { |
| for (var i = 0; i < this.categories.length; i++) { |
| // Prevent memory leaks |
| this.categories[this.categories[i]._categoryName] = null; |
| } |
| this.items.splice(0, this.items.length); |
| this.categories.splice(0, this.categories.length); |
| this.categories = []; |
| this.items = []; |
| for (var i = this.menu.childNodes.length - 1; i >= 0; i--) { |
| this.menu.removeChild(this.menu.childNodes[i]); |
| } |
| }; |
| |
| /** |
| * Passed an instance of a menu item, it will be removed from the menu |
| * object, including any residual array links and possible memory leaks. |
| * @param {Element} item a reference to the menu item to remove. |
| * @return {Element} returns the item removed. |
| */ |
| Menu.prototype.removeItem = function(item) { |
| let result = null; |
| for (let i = 0; i < this.items.length; i++) { |
| if (this.items[i] == item) { |
| result = this.items[i]; |
| this.items.splice(i, 1); |
| } |
| // Renumber |
| this.items[i].item._index = i; |
| } |
| return result; |
| }; |
| |
| /** |
| * Removes a category from the menu element and all of its children thus |
| * allowing the Element to be collected by the browsers VM. |
| * @param {string} category the name of the category to retrieve and remove. |
| */ |
| Menu.prototype.removeCategory = function(category) { |
| let div = this.categories[category]; |
| if (!div || !div.parentNode) { |
| return; |
| } |
| if (div._categoryTitle) { |
| div._categoryTitle.parentNode.removeChild(div._categoryTitle); |
| } |
| div.parentNode.removeChild(div); |
| for (var i = 0; i < this.categories.length; i++) { |
| if (this.categories[i] === div) { |
| this.categories[this.categories[i]._categoryName] = null; |
| this.categories.splice(i, 1); |
| return; |
| } |
| } |
| for (var i = 0; i < div.childNodes.length; i++) { |
| if (div.childNodes[i]._index) { |
| this.items.splice(div.childNodes[i]._index, 1); |
| } else { |
| this.removeItem(div.childNodes[i]); |
| } |
| } |
| }; |
| |
| /** |
| * This heart of the menu population scheme, the addItem function creates |
| * a combination of elements that visually form up a menu item. If no |
| * category is supplied, the default category is used. The menu item is |
| * an <a> tag with the class .menuItem. The menu item is directly styled |
| * as a block element. Other than that, all styling should be done via a |
| * external CSS definition. |
| * |
| * @param {string|Element} html_or_element a string of HTML text or a |
| * HTML element denoting the contents of the menu item. |
| * @param {string} opt_href the href of the menu item link. This is |
| * the most direct way of defining the menu items function. |
| * [Default: '#']. |
| * @param {string} opt_category the category string name of the category |
| * to append the menu item to. If the category doesn't exist, one will |
| * be created. [Default: 'default']. |
| * @param {string} opt_title used when creating a new category and is |
| * otherwise ignored completely. It is also ignored when supplied if |
| * the named category already exists. |
| * @return {Element} returns the element that was created. |
| */ |
| Menu.prototype.addItem = function(html_or_element, opt_href, opt_category, |
| opt_title) { |
| let category = opt_category ? (this.categories[opt_category] || |
| this.addCategory(opt_category, opt_title)) : |
| this.categories['default']; |
| let menuHref = (opt_href == undefined ? '#' : opt_href); |
| let menuItem = undefined; |
| if (menuHref) { |
| menuItem = this.createElement('a', 'menuItem', html_or_element); |
| } else { |
| menuItem = this.createElement('span', 'menuText', html_or_element); |
| } |
| let itemText = typeof html_or_element == 'string' ? html_or_element : |
| html_or_element.textContent || 'ERROR'; |
| |
| menuItem.style.display = 'block'; |
| if (menuHref) { |
| menuItem.setAttribute('href', menuHref); |
| } |
| menuItem._index = this.items.length; |
| menuItem.role = 'option'; |
| this.append(menuItem, category); |
| this.items[this.items.length] = {item: menuItem, text: itemText}; |
| |
| return menuItem; |
| }; |
| |
| /** |
| * Adds a visual HTML separator to the menu, optionally creating a |
| * category as per addItem(). See above. |
| * @param {string} opt_category the category string name of the category |
| * to append the menu item to. If the category doesn't exist, one will |
| * be created. [Default: 'default']. |
| * @param {string} opt_title used when creating a new category and is |
| * otherwise ignored completely. It is also ignored when supplied if |
| * the named category already exists. |
| */ |
| Menu.prototype.addSeparator = function(opt_category, opt_title) { |
| let category = opt_category ? (this.categories[opt_category] || |
| this.addCategory(opt_category, opt_title)) : |
| this.categories['default']; |
| let hr = this.createElement('hr', 'menuSeparator'); |
| this.append(hr, category); |
| }; |
| |
| /** |
| * This method performs all the dirty work of positioning the menu. It is |
| * responsible for dynamic sizing, insertion and deletion of scroll bars |
| * and calculation of offscreen width considerations. |
| */ |
| Menu.prototype.adjustSizeAndLocation = function() { |
| let style = this.menu.style; |
| style.position = 'absolute'; |
| |
| let firstCategory = null; |
| for (let i = 0; i < this.categories.length; i++) { |
| this.categories[i].className = this.categories[i].className. |
| replace(/ first/, ''); |
| if (this.categories[i].childNodes.length == 0) { |
| this.categories[i].style.display = 'none'; |
| } else { |
| this.categories[i].style.display = ''; |
| if (!firstCategory) { |
| firstCategory = this.categories[i]; |
| firstCategory.className += ' first'; |
| } |
| } |
| } |
| |
| let alreadyVisible = style.display != 'none' && |
| style.visibility != 'hidden'; |
| let docElemWidth = document.documentElement.clientWidth; |
| let docElemHeight = document.documentElement.clientHeight; |
| let pageSize = { |
| w: (window.innerWidth || docElemWidth && docElemWidth > 0 ? |
| docElemWidth : document.body.clientWidth) || 1, |
| h: (window.innerHeight || docElemHeight && docElemHeight > 0 ? |
| docElemHeight : document.body.clientHeight) || 1, |
| }; |
| let targetPos = this.find(this.target); |
| let targetSize = {w: this.target.offsetWidth, |
| h: this.target.offsetHeight}; |
| let menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight}; |
| |
| if (!alreadyVisible) { |
| let oldVisibility = style.visibility; |
| let oldDisplay = style.display; |
| style.visibility = 'hidden'; |
| style.display = ''; |
| style.height = ''; |
| style.width = ''; |
| menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight}; |
| style.display = oldDisplay; |
| style.visibility = oldVisibility; |
| } |
| |
| let addScroll = (this.menu.offsetHeight / pageSize.h) > 0.8; |
| if (addScroll) { |
| menuSize.h = parseInt((pageSize.h * 0.8), 10); |
| style.height = menuSize.h + 'px'; |
| style.overflowX = 'hidden'; |
| style.overflowY = 'auto'; |
| } else { |
| style.height = style.overflowY = style.overflowX = ''; |
| } |
| |
| style.top = (targetPos.y + targetSize.h) + 'px'; |
| style.left = targetPos.x + 'px'; |
| |
| if (menuSize.w < 175) { |
| style.width = '175px'; |
| } |
| |
| if (addScroll) { |
| style.width = parseInt(style.width, 10) + 13 + 'px'; |
| } |
| |
| if ((targetPos.x + menuSize.w) > pageSize.w) { |
| style.left = targetPos.x - (menuSize.w - targetSize.w) + 'px'; |
| } |
| }; |
| |
| |
| /** |
| * This function is used heavily, internally. It appends text |
| * or the supplied element via appendChild(). If |
| * the opt_target variable is present, the supplied element will be |
| * the container rather than the menu div for this instance. |
| * |
| * @param {string|Element} text_or_element the html or element to insert |
| * into opt_target. |
| * @param {Element} opt_target the target element it should be appended to. |
| * |
| */ |
| Menu.prototype.append = function(text_or_element, opt_target) { |
| let element = opt_target || this.menu; |
| if (typeof opt_target == 'string' && this.categories[opt_target]) { |
| element = this.categories[opt_target]; |
| } |
| if (typeof text_or_element == 'string') { |
| element.textContent += text_or_element; |
| } else { |
| element.appendChild(text_or_element); |
| } |
| }; |
| |
| /** |
| * Displays the menu (such as upon mouseover). |
| */ |
| Menu.prototype.over = function() { |
| if (this.menu.style.display != 'none') { |
| this.show(); |
| } |
| if (this.thread != -1) { |
| clearTimeout(this.thread); |
| this.thread = -1; |
| } |
| }; |
| |
| /** |
| * Hides the menu (such as upon mouseout). |
| */ |
| Menu.prototype.out = function() { |
| if (this.thread != -1) { |
| clearTimeout(this.thread); |
| this.thread = -1; |
| } |
| this.thread = setTimeout(this.hide.bind(this), 400); |
| }; |
| |
| /** |
| * Stops event propagation. |
| */ |
| Menu.prototype.stopPropagation = function() { |
| return (function(e) { |
| if (!e) { |
| e = window.event; |
| } |
| e.cancelBubble = true; |
| if (e.stopPropagation) { |
| e.stopPropagation(); |
| } |
| }); |
| }; |
| |
| /** |
| * Toggles the menu between hide/show. |
| */ |
| Menu.prototype.toggle = function(event) { |
| event.preventDefault(); |
| if (this.menu.style.display == 'none') { |
| this.show(); |
| } else { |
| this.hide(); |
| } |
| }; |
| |
| /** |
| * Makes the menu visible, then calls the user-supplied onShow callback. |
| */ |
| Menu.prototype.show = function() { |
| if (this.menu.style.display != '') { |
| for (var i = 0; i < this.onOpenEvents.length; i++) { |
| this.onOpenEvents[i].call(null, this); |
| } |
| |
| // Invisibly show it first |
| this.menu.style.visibility = 'hidden'; |
| this.menu.style.display = ''; |
| this.adjustSizeAndLocation(); |
| if (this.trigger.nodeName && this.trigger.nodeName == 'A') { |
| this.trigger.blur(); |
| } |
| this.menu.style.visibility = 'visible'; |
| |
| // Hide other menus |
| for (var i = 0; i < Menu.instance.length; i++) { |
| let menuInstance = Menu.instance[i]; |
| if (menuInstance != this) { |
| menuInstance.hide(); |
| } |
| } |
| |
| if (this.onShow) { |
| this.onShow(); |
| } |
| } |
| }; |
| |
| /** |
| * Makes the menu invisible. |
| */ |
| Menu.prototype.hide = function() { |
| this.menu.style.display = 'none'; |
| }; |
| |
| Menu.prototype.find = function(element) { |
| let curleft = 0, curtop = 0; |
| if (element.offsetParent) { |
| do { |
| curleft += element.offsetLeft; |
| curtop += element.offsetTop; |
| } |
| while ((element = element.offsetParent) && (element.style && |
| element.style.position != 'relative' && |
| element.style.position != 'absolute')); |
| } |
| return {x: curleft, y: curtop}; |
| }; |
| |
| /** |
| * A static array of object instances for global reference. |
| * @type {Array.<Menu>} |
| */ |
| Menu.instance = []; |