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