blob: 35bbebcf85d2fc7c1edaeec53b27df722e30fdf4 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001/* Copyright 2016 The Chromium Authors. All Rights Reserved.
2 *
3 * Use of this source code is governed by a BSD-style
4 * license that can be found in the LICENSE file or at
5 * https://developers.google.com/open-source/licenses/bsd
6 */
7
8/**
9 * @fileoverview This file represents a standalone, reusable drop down menu
10 * widget that can be attached to any element on a given page. It supports
11 * multiple instances of the widget on a page. It has no dependencies. Usage
12 * is as simple as creating a new Menu object and supplying it with a target
13 * element.
14 */
15
16/**
17 * The entry point and constructor for the Menu object. Creating
18 * a valid instance of this object will insert a drop down menu
19 * near the element supplied as the target, attach all the necessary
20 * events and insert the necessary elements on the page.
21 *
22 * @param {Element} target the target element on the page to which
23 * the drop down menu will be placed near.
24 * @param {Function=} opt_onShow function to execute every time the
25 * menu is made visible, most likely through a click on the target.
26 * @constructor
27 */
28var Menu = function(target, opt_onShow) {
29 this.iid = Menu.instance.length;
30 Menu.instance[this.iid] = this;
31 this.target = target;
32 this.onShow = opt_onShow || null;
33
34 // An optional trigger element on the page that can be used to trigger
35 // the drop-down. Currently hard-coded to be the same as the target element.
36 this.trigger = target;
37 this.items = [];
38 this.onOpenEvents = [];
39 this.menu = this.createElement('div', 'menuDiv instance' + this.iid);
40 this.targetId = this.target.getAttribute('id');
41 let menuId = (this.targetId != null) ?
42 'menuDiv-' + this.targetId : 'menuDiv-instance' + this.iid;
43 this.menu.setAttribute('id', menuId);
44 this.menu.role = 'listbox';
45 this.hide();
46 this.addCategory('default');
47 this.addEvent(this.trigger, 'click', this.toggle.bind(this));
48 this.addEvent(window, 'resize', this.adjustSizeAndLocation.bind(this));
49
50 // Hide the menu if a user clicks outside the menu widget
51 this.addEvent(document, 'click', this.hide.bind(this));
52 this.addEvent(this.menu, 'click', this.stopPropagation());
53 this.addEvent(this.trigger, 'click', this.stopPropagation());
54};
55
56// A reference to the element or node that the drop down
57// will appear next to
58Menu.prototype.target = null;
59
60// Element ID of the target. ID will be assigned to the newly created
61// menu div based on the target ID. A default ID will be
62// assigned If there is no ID on the target.
63Menu.prototype.targetId = null;
64
65/**
66 * A reference to the element or node that will trigger
67 * the drop down to appear. If not specified, this value
68 * will be the same as <Menu Instance>.target
69 * @type {Element}
70 */
71Menu.prototype.trigger = null;
72
73// A reference to the event type that will "open" the
74// menu div. By default this is the (on)click method.
75Menu.prototype.triggerType = null;
76
77// A reference to the element that will appear when the
78// trigger is clicked.
79Menu.prototype.menu = null;
80
81/**
82 * Function to execute every time the menu is made shown.
83 * @type {Function}
84 */
85Menu.prototype.onShow = null;
86
87// A list of category divs. By default these categories
88// are set to display none until at least one element
89// is placed within them.
90Menu.prototype.categories = null;
91
92// An id used to track timed intervals
93Menu.prototype.thread = -1;
94
95// The static instance id (iid) denoting which menu in the
96// list of Menu.instance items is this instantiated object.
97Menu.prototype.iid = -1;
98
99// A counter to indicate the number of items added with
100// addItem(). After 5 items, a height is set on the menu
101// and a scroll bar will appear.
102Menu.prototype.items = null;
103
104// A flag to detect whether or not a scroll bar has been added
105Menu.prototype.scrolls = false;
106
107// onOpen event handlers; each function in this list will
108// be executed and passed the executing instance as a
109// parameter before the menu is to be displayed.
110Menu.prototype.onOpenEvents = null;
111
112/**
113 * An extended short-cut for document.createElement(); this
114 * method allows the creation of an element, the assignment
115 * of one or more class names and the ability to set the
116 * content of the created element all with one function call.
117 * @param {string} element name of the element to create. Examples would
118 * be 'div' or 'a'.
119 * @param {string} opt_className an optional string to assign to the
120 * newly created element's className property.
121 * @param {string|Element} opt_content either a snippet of HTML or a HTML
122 * element that is to be appended to the newly created element.
123 * @return {Element} a reference to the newly created element.
124 */
125Menu.prototype.createElement = function(element, opt_className, opt_content) {
126 let div = document.createElement(element);
127 div.className = opt_className;
128 if (opt_content) {
129 this.append(opt_content, div);
130 }
131 return div;
132};
133
134/**
135 * Uses a fairly browser agnostic approach to applying a callback to
136 * an element on the page.
137 *
138 * @param {Element|EventTarget} element a reference to an element on the page to
139 * which to attach and event.
140 * @param {string} eventType a browser compatible event type as a string
141 * without the sometimes assumed on- prefix. Examples: 'click',
142 * 'mousedown', 'mouseover', etc...
143 * @param {Function} callback a function reference to invoke when the
144 * the event occurs.
145 */
146Menu.prototype.addEvent = function(element, eventType, callback) {
147 if (element.addEventListener) {
148 element.addEventListener(eventType, callback, false);
149 } else {
150 try {
151 element.attachEvent('on' + eventType, callback);
152 } catch (e) {
153 element['on' + eventType] = callback;
154 }
155 }
156};
157
158/**
159 * Similar to addEvent, this provides a specialied handler for onOpen
160 * events that apply to this instance of the Menu class. The supplied
161 * callbacks are appended to an internal array and called in order
162 * every time the menu is opened. The array can be accessed via
163 * menuInstance.onOpenEvents.
164 */
165Menu.prototype.addOnOpen = function(eventCallback) {
166 let eventIndex = this.onOpenEvents.length;
167 this.onOpenEvents.push(eventCallback);
168 return eventIndex;
169};
170
171/**
172 * This method will create a div with the classes .menuCategory and the
173 * name of the category as supplied in the first parameter. It then, if
174 * a title is supplied, creates a title div and appends it as well. The
175 * optional title is styled with the .categoryTitle and category name
176 * class.
177 *
178 * Categories are stored within the menu object instance for programmatic
179 * manipulation in the array, menuInstance.categories. Note also that this
180 * array is doubly linked insofar as that the category div can be accessed
181 * via it's index in the array as well as by instance.categories[category]
182 * where category is the string name supplied when creating the category.
183 *
184 * @param {string} category the string name used to create the category;
185 * used as both a class name and a key into the internal array. It
186 * must be a valid JavaScript variable name.
187 * @param {string|Element} opt_title this optional field is used to visibly
188 * denote the category title. It can be either HTML or an element.
189 * @return {Element} the newly created div.
190 */
191Menu.prototype.addCategory = function(category, opt_title) {
192 this.categories = this.categories || [];
193 let categoryDiv = this.createElement('div', 'menuCategory ' + category);
194 categoryDiv._categoryName = category;
195 if (opt_title) {
196 let categoryTitle = this.createElement('b', 'categoryTitle ' +
197 category, opt_title);
198 categoryTitle.style.display = 'block';
199 this.append(categoryTitle);
200 categoryDiv._categoryTitle = categoryTitle;
201 }
202 this.append(categoryDiv);
203 this.categories[this.categories.length] = this.categories[category] =
204 categoryDiv;
205
206 return categoryDiv;
207};
208
209/**
210 * This method removes the contents of a given category but does not
211 * remove the category itself.
212 */
213Menu.prototype.emptyCategory = function(category) {
214 if (!this.categories[category]) {
215 return;
216 }
217 let div = this.categories[category];
218 for (let i = div.childNodes.length - 1; i >= 0; i--) {
219 div.removeChild(div.childNodes[i]);
220 }
221};
222
223/**
224 * This function is the most drastic of the cleansing functions; it removes
225 * all categories and all menu items and all HTML snippets that have been
226 * added to this instance of the Menu class.
227 */
228Menu.prototype.clear = function() {
229 for (var i = 0; i < this.categories.length; i++) {
230 // Prevent memory leaks
231 this.categories[this.categories[i]._categoryName] = null;
232 }
233 this.items.splice(0, this.items.length);
234 this.categories.splice(0, this.categories.length);
235 this.categories = [];
236 this.items = [];
237 for (var i = this.menu.childNodes.length - 1; i >= 0; i--) {
238 this.menu.removeChild(this.menu.childNodes[i]);
239 }
240};
241
242/**
243 * Passed an instance of a menu item, it will be removed from the menu
244 * object, including any residual array links and possible memory leaks.
245 * @param {Element} item a reference to the menu item to remove.
246 * @return {Element} returns the item removed.
247 */
248Menu.prototype.removeItem = function(item) {
249 let result = null;
250 for (let i = 0; i < this.items.length; i++) {
251 if (this.items[i] == item) {
252 result = this.items[i];
253 this.items.splice(i, 1);
254 }
255 // Renumber
256 this.items[i].item._index = i;
257 }
258 return result;
259};
260
261/**
262 * Removes a category from the menu element and all of its children thus
263 * allowing the Element to be collected by the browsers VM.
264 * @param {string} category the name of the category to retrieve and remove.
265 */
266Menu.prototype.removeCategory = function(category) {
267 let div = this.categories[category];
268 if (!div || !div.parentNode) {
269 return;
270 }
271 if (div._categoryTitle) {
272 div._categoryTitle.parentNode.removeChild(div._categoryTitle);
273 }
274 div.parentNode.removeChild(div);
275 for (var i = 0; i < this.categories.length; i++) {
276 if (this.categories[i] === div) {
277 this.categories[this.categories[i]._categoryName] = null;
278 this.categories.splice(i, 1);
279 return;
280 }
281 }
282 for (var i = 0; i < div.childNodes.length; i++) {
283 if (div.childNodes[i]._index) {
284 this.items.splice(div.childNodes[i]._index, 1);
285 } else {
286 this.removeItem(div.childNodes[i]);
287 }
288 }
289};
290
291/**
292 * This heart of the menu population scheme, the addItem function creates
293 * a combination of elements that visually form up a menu item. If no
294 * category is supplied, the default category is used. The menu item is
295 * an <a> tag with the class .menuItem. The menu item is directly styled
296 * as a block element. Other than that, all styling should be done via a
297 * external CSS definition.
298 *
299 * @param {string|Element} html_or_element a string of HTML text or a
300 * HTML element denoting the contents of the menu item.
301 * @param {string} opt_href the href of the menu item link. This is
302 * the most direct way of defining the menu items function.
303 * [Default: '#'].
304 * @param {string} opt_category the category string name of the category
305 * to append the menu item to. If the category doesn't exist, one will
306 * be created. [Default: 'default'].
307 * @param {string} opt_title used when creating a new category and is
308 * otherwise ignored completely. It is also ignored when supplied if
309 * the named category already exists.
310 * @return {Element} returns the element that was created.
311 */
312Menu.prototype.addItem = function(html_or_element, opt_href, opt_category,
313 opt_title) {
314 let category = opt_category ? (this.categories[opt_category] ||
315 this.addCategory(opt_category, opt_title)) :
316 this.categories['default'];
317 let menuHref = (opt_href == undefined ? '#' : opt_href);
318 let menuItem = undefined;
319 if (menuHref) {
320 menuItem = this.createElement('a', 'menuItem', html_or_element);
321 } else {
322 menuItem = this.createElement('span', 'menuText', html_or_element);
323 }
324 let itemText = typeof html_or_element == 'string' ? html_or_element :
325 html_or_element.textContent || 'ERROR';
326
327 menuItem.style.display = 'block';
328 if (menuHref) {
329 menuItem.setAttribute('href', menuHref);
330 }
331 menuItem._index = this.items.length;
332 menuItem.role = 'option';
333 this.append(menuItem, category);
334 this.items[this.items.length] = {item: menuItem, text: itemText};
335
336 return menuItem;
337};
338
339/**
340 * Adds a visual HTML separator to the menu, optionally creating a
341 * category as per addItem(). See above.
342 * @param {string} opt_category the category string name of the category
343 * to append the menu item to. If the category doesn't exist, one will
344 * be created. [Default: 'default'].
345 * @param {string} opt_title used when creating a new category and is
346 * otherwise ignored completely. It is also ignored when supplied if
347 * the named category already exists.
348 */
349Menu.prototype.addSeparator = function(opt_category, opt_title) {
350 let category = opt_category ? (this.categories[opt_category] ||
351 this.addCategory(opt_category, opt_title)) :
352 this.categories['default'];
353 let hr = this.createElement('hr', 'menuSeparator');
354 this.append(hr, category);
355};
356
357/**
358 * This method performs all the dirty work of positioning the menu. It is
359 * responsible for dynamic sizing, insertion and deletion of scroll bars
360 * and calculation of offscreen width considerations.
361 */
362Menu.prototype.adjustSizeAndLocation = function() {
363 let style = this.menu.style;
364 style.position = 'absolute';
365
366 let firstCategory = null;
367 for (let i = 0; i < this.categories.length; i++) {
368 this.categories[i].className = this.categories[i].className.
369 replace(/ first/, '');
370 if (this.categories[i].childNodes.length == 0) {
371 this.categories[i].style.display = 'none';
372 } else {
373 this.categories[i].style.display = '';
374 if (!firstCategory) {
375 firstCategory = this.categories[i];
376 firstCategory.className += ' first';
377 }
378 }
379 }
380
381 let alreadyVisible = style.display != 'none' &&
382 style.visibility != 'hidden';
383 let docElemWidth = document.documentElement.clientWidth;
384 let docElemHeight = document.documentElement.clientHeight;
385 let pageSize = {
386 w: (window.innerWidth || docElemWidth && docElemWidth > 0 ?
387 docElemWidth : document.body.clientWidth) || 1,
388 h: (window.innerHeight || docElemHeight && docElemHeight > 0 ?
389 docElemHeight : document.body.clientHeight) || 1,
390 };
391 let targetPos = this.find(this.target);
392 let targetSize = {w: this.target.offsetWidth,
393 h: this.target.offsetHeight};
394 let menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight};
395
396 if (!alreadyVisible) {
397 let oldVisibility = style.visibility;
398 let oldDisplay = style.display;
399 style.visibility = 'hidden';
400 style.display = '';
401 style.height = '';
402 style.width = '';
403 menuSize = {w: this.menu.offsetWidth, h: this.menu.offsetHeight};
404 style.display = oldDisplay;
405 style.visibility = oldVisibility;
406 }
407
408 let addScroll = (this.menu.offsetHeight / pageSize.h) > 0.8;
409 if (addScroll) {
410 menuSize.h = parseInt((pageSize.h * 0.8), 10);
411 style.height = menuSize.h + 'px';
412 style.overflowX = 'hidden';
413 style.overflowY = 'auto';
414 } else {
415 style.height = style.overflowY = style.overflowX = '';
416 }
417
418 style.top = (targetPos.y + targetSize.h) + 'px';
419 style.left = targetPos.x + 'px';
420
421 if (menuSize.w < 175) {
422 style.width = '175px';
423 }
424
425 if (addScroll) {
426 style.width = parseInt(style.width, 10) + 13 + 'px';
427 }
428
429 if ((targetPos.x + menuSize.w) > pageSize.w) {
430 style.left = targetPos.x - (menuSize.w - targetSize.w) + 'px';
431 }
432};
433
434
435/**
436 * This function is used heavily, internally. It appends text
437 * or the supplied element via appendChild(). If
438 * the opt_target variable is present, the supplied element will be
439 * the container rather than the menu div for this instance.
440 *
441 * @param {string|Element} text_or_element the html or element to insert
442 * into opt_target.
443 * @param {Element} opt_target the target element it should be appended to.
444 *
445 */
446Menu.prototype.append = function(text_or_element, opt_target) {
447 let element = opt_target || this.menu;
448 if (typeof opt_target == 'string' && this.categories[opt_target]) {
449 element = this.categories[opt_target];
450 }
451 if (typeof text_or_element == 'string') {
452 element.textContent += text_or_element;
453 } else {
454 element.appendChild(text_or_element);
455 }
456};
457
458/**
459 * Displays the menu (such as upon mouseover).
460 */
461Menu.prototype.over = function() {
462 if (this.menu.style.display != 'none') {
463 this.show();
464 }
465 if (this.thread != -1) {
466 clearTimeout(this.thread);
467 this.thread = -1;
468 }
469};
470
471/**
472 * Hides the menu (such as upon mouseout).
473 */
474Menu.prototype.out = function() {
475 if (this.thread != -1) {
476 clearTimeout(this.thread);
477 this.thread = -1;
478 }
479 this.thread = setTimeout(this.hide.bind(this), 400);
480};
481
482/**
483 * Stops event propagation.
484 */
485Menu.prototype.stopPropagation = function() {
486 return (function(e) {
487 if (!e) {
488 e = window.event;
489 }
490 e.cancelBubble = true;
491 if (e.stopPropagation) {
492 e.stopPropagation();
493 }
494 });
495};
496
497/**
498 * Toggles the menu between hide/show.
499 */
500Menu.prototype.toggle = function(event) {
501 event.preventDefault();
502 if (this.menu.style.display == 'none') {
503 this.show();
504 } else {
505 this.hide();
506 }
507};
508
509/**
510 * Makes the menu visible, then calls the user-supplied onShow callback.
511 */
512Menu.prototype.show = function() {
513 if (this.menu.style.display != '') {
514 for (var i = 0; i < this.onOpenEvents.length; i++) {
515 this.onOpenEvents[i].call(null, this);
516 }
517
518 // Invisibly show it first
519 this.menu.style.visibility = 'hidden';
520 this.menu.style.display = '';
521 this.adjustSizeAndLocation();
522 if (this.trigger.nodeName && this.trigger.nodeName == 'A') {
523 this.trigger.blur();
524 }
525 this.menu.style.visibility = 'visible';
526
527 // Hide other menus
528 for (var i = 0; i < Menu.instance.length; i++) {
529 let menuInstance = Menu.instance[i];
530 if (menuInstance != this) {
531 menuInstance.hide();
532 }
533 }
534
535 if (this.onShow) {
536 this.onShow();
537 }
538 }
539};
540
541/**
542 * Makes the menu invisible.
543 */
544Menu.prototype.hide = function() {
545 this.menu.style.display = 'none';
546};
547
548Menu.prototype.find = function(element) {
549 let curleft = 0, curtop = 0;
550 if (element.offsetParent) {
551 do {
552 curleft += element.offsetLeft;
553 curtop += element.offsetTop;
554 }
555 while ((element = element.offsetParent) && (element.style &&
556 element.style.position != 'relative' &&
557 element.style.position != 'absolute'));
558 }
559 return {x: curleft, y: curtop};
560};
561
562/**
563 * A static array of object instances for global reference.
564 * @type {Array.<Menu>}
565 */
566Menu.instance = [];