browser/devtools/shared/widgets/SideMenuWidget.jsm

Thu, 22 Jan 2015 13:21:57 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Thu, 22 Jan 2015 13:21:57 +0100
branch
TOR_BUG_9701
changeset 15
b8a032363ba2
permissions
-rw-r--r--

Incorporate requested changes from Mozilla in review:
https://bugzilla.mozilla.org/show_bug.cgi?id=1123480#c6

     1 /* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
     2 /* vim: set ft=javascript ts=2 et sw=2 tw=80: */
     3 /* This Source Code Form is subject to the terms of the Mozilla Public
     4  * License, v. 2.0. If a copy of the MPL was not distributed with this
     5  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     6 "use strict";
     8 const Ci = Components.interfaces;
     9 const Cu = Components.utils;
    11 Cu.import("resource:///modules/devtools/ViewHelpers.jsm");
    12 Cu.import("resource://gre/modules/devtools/event-emitter.js");
    14 this.EXPORTED_SYMBOLS = ["SideMenuWidget"];
    16 /**
    17  * A simple side menu, with the ability of grouping menu items.
    18  *
    19  * Note: this widget should be used in tandem with the WidgetMethods in
    20  * ViewHelpers.jsm.
    21  *
    22  * @param nsIDOMNode aNode
    23  *        The element associated with the widget.
    24  * @param Object aOptions
    25  *        - showArrows: specifies if items should display horizontal arrows.
    26  *        - showItemCheckboxes: specifies if items should display checkboxes.
    27  *        - showGroupCheckboxes: specifies if groups should display checkboxes.
    28  */
    29 this.SideMenuWidget = function SideMenuWidget(aNode, aOptions={}) {
    30   this.document = aNode.ownerDocument;
    31   this.window = this.document.defaultView;
    32   this._parent = aNode;
    34   let { showArrows, showItemCheckboxes, showGroupCheckboxes } = aOptions;
    35   this._showArrows = showArrows || false;
    36   this._showItemCheckboxes = showItemCheckboxes || false;
    37   this._showGroupCheckboxes = showGroupCheckboxes || false;
    39   // Create an internal scrollbox container.
    40   this._list = this.document.createElement("scrollbox");
    41   this._list.className = "side-menu-widget-container theme-sidebar";
    42   this._list.setAttribute("flex", "1");
    43   this._list.setAttribute("orient", "vertical");
    44   this._list.setAttribute("with-arrows", this._showArrows);
    45   this._list.setAttribute("with-item-checkboxes", this._showItemCheckboxes);
    46   this._list.setAttribute("with-group-checkboxes", this._showGroupCheckboxes);
    47   this._list.setAttribute("tabindex", "0");
    48   this._list.addEventListener("keypress", e => this.emit("keyPress", e), false);
    49   this._list.addEventListener("mousedown", e => this.emit("mousePress", e), false);
    50   this._parent.appendChild(this._list);
    52   // Menu items can optionally be grouped.
    53   this._groupsByName = new Map(); // Can't use a WeakMap because keys are strings.
    54   this._orderedGroupElementsArray = [];
    55   this._orderedMenuElementsArray = [];
    56   this._itemsByElement = new Map();
    58   // This widget emits events that can be handled in a MenuContainer.
    59   EventEmitter.decorate(this);
    61   // Delegate some of the associated node's methods to satisfy the interface
    62   // required by MenuContainer instances.
    63   ViewHelpers.delegateWidgetAttributeMethods(this, aNode);
    64   ViewHelpers.delegateWidgetEventMethods(this, aNode);
    65 };
    67 SideMenuWidget.prototype = {
    68   /**
    69    * Specifies if groups in this container should be sorted.
    70    */
    71   sortedGroups: true,
    73   /**
    74    * The comparator used to sort groups.
    75    */
    76   groupSortPredicate: function(a, b) a.localeCompare(b),
    78   /**
    79    * Specifies that the container viewport should be "stuck" to the
    80    * bottom. That is, the container is automatically scrolled down to
    81    * keep appended items visible, but only when the scroll position is
    82    * already at the bottom.
    83    */
    84   autoscrollWithAppendedItems: false,
    86   /**
    87    * Inserts an item in this container at the specified index, optionally
    88    * grouping by name.
    89    *
    90    * @param number aIndex
    91    *        The position in the container intended for this item.
    92    * @param nsIDOMNode aContents
    93    *        The node displayed in the container.
    94    * @param object aAttachment [optional]
    95    *        Some attached primitive/object. Custom options supported:
    96    *          - group: a string specifying the group to place this item into
    97    *          - checkboxState: the checked state of the checkbox, if shown
    98    *          - checkboxTooltip: the tooltip text for the checkbox, if shown
    99    * @return nsIDOMNode
   100    *         The element associated with the displayed item.
   101    */
   102   insertItemAt: function(aIndex, aContents, aAttachment={}) {
   103     // Maintaining scroll position at the bottom when a new item is inserted
   104     // depends on several factors (the order of testing is important to avoid
   105     // needlessly expensive operations that may cause reflows):
   106     let maintainScrollAtBottom =
   107       // 1. The behavior should be enabled,
   108       this.autoscrollWithAppendedItems &&
   109       // 2. There shouldn't currently be any selected item in the list.
   110       !this._selectedItem &&
   111       // 3. The new item should be appended at the end of the list.
   112       (aIndex < 0 || aIndex >= this._orderedMenuElementsArray.length) &&
   113       // 4. The list should already be scrolled at the bottom.
   114       (this._list.scrollTop + this._list.clientHeight >= this._list.scrollHeight);
   116     let group = this._getMenuGroupForName(aAttachment.group);
   117     let item = this._getMenuItemForGroup(group, aContents, aAttachment);
   118     let element = item.insertSelfAt(aIndex);
   120     if (maintainScrollAtBottom) {
   121       this._list.scrollTop = this._list.scrollHeight;
   122     }
   124     return element;
   125   },
   127   /**
   128    * Returns the child node in this container situated at the specified index.
   129    *
   130    * @param number aIndex
   131    *        The position in the container intended for this item.
   132    * @return nsIDOMNode
   133    *         The element associated with the displayed item.
   134    */
   135   getItemAtIndex: function(aIndex) {
   136     return this._orderedMenuElementsArray[aIndex];
   137   },
   139   /**
   140    * Removes the specified child node from this container.
   141    *
   142    * @param nsIDOMNode aChild
   143    *        The element associated with the displayed item.
   144    */
   145   removeChild: function(aChild) {
   146     this._getNodeForContents(aChild).remove();
   148     this._orderedMenuElementsArray.splice(
   149       this._orderedMenuElementsArray.indexOf(aChild), 1);
   151     this._itemsByElement.delete(aChild);
   153     if (this._selectedItem == aChild) {
   154       this._selectedItem = null;
   155     }
   156   },
   158   /**
   159    * Removes all of the child nodes from this container.
   160    */
   161   removeAllItems: function() {
   162     let parent = this._parent;
   163     let list = this._list;
   165     while (list.hasChildNodes()) {
   166       list.firstChild.remove();
   167     }
   169     this._selectedItem = null;
   171     this._groupsByName.clear();
   172     this._orderedGroupElementsArray.length = 0;
   173     this._orderedMenuElementsArray.length = 0;
   174     this._itemsByElement.clear();
   175   },
   177   /**
   178    * Gets the currently selected child node in this container.
   179    * @return nsIDOMNode
   180    */
   181   get selectedItem() {
   182     return this._selectedItem;
   183   },
   185   /**
   186    * Sets the currently selected child node in this container.
   187    * @param nsIDOMNode aChild
   188    */
   189   set selectedItem(aChild) {
   190     let menuArray = this._orderedMenuElementsArray;
   192     if (!aChild) {
   193       this._selectedItem = null;
   194     }
   195     for (let node of menuArray) {
   196       if (node == aChild) {
   197         this._getNodeForContents(node).classList.add("selected");
   198         this._selectedItem = node;
   199       } else {
   200         this._getNodeForContents(node).classList.remove("selected");
   201       }
   202     }
   203   },
   205   /**
   206    * Ensures the specified element is visible.
   207    *
   208    * @param nsIDOMNode aElement
   209    *        The element to make visible.
   210    */
   211   ensureElementIsVisible: function(aElement) {
   212     if (!aElement) {
   213       return;
   214     }
   216     // Ensure the element is visible but not scrolled horizontally.
   217     let boxObject = this._list.boxObject.QueryInterface(Ci.nsIScrollBoxObject);
   218     boxObject.ensureElementIsVisible(aElement);
   219     boxObject.scrollBy(-this._list.clientWidth, 0);
   220   },
   222   /**
   223    * Shows all the groups, even the ones with no visible children.
   224    */
   225   showEmptyGroups: function() {
   226     for (let group of this._orderedGroupElementsArray) {
   227       group.hidden = false;
   228     }
   229   },
   231   /**
   232    * Hides all the groups which have no visible children.
   233    */
   234   hideEmptyGroups: function() {
   235     let visibleChildNodes = ".side-menu-widget-item-contents:not([hidden=true])";
   237     for (let group of this._orderedGroupElementsArray) {
   238       group.hidden = group.querySelectorAll(visibleChildNodes).length == 0;
   239     }
   240     for (let menuItem of this._orderedMenuElementsArray) {
   241       menuItem.parentNode.hidden = menuItem.hidden;
   242     }
   243   },
   245   /**
   246    * Adds a new attribute or changes an existing attribute on this container.
   247    *
   248    * @param string aName
   249    *        The name of the attribute.
   250    * @param string aValue
   251    *        The desired attribute value.
   252    */
   253   setAttribute: function(aName, aValue) {
   254     this._parent.setAttribute(aName, aValue);
   256     if (aName == "emptyText") {
   257       this._textWhenEmpty = aValue;
   258     }
   259   },
   261   /**
   262    * Removes an attribute on this container.
   263    *
   264    * @param string aName
   265    *        The name of the attribute.
   266    */
   267   removeAttribute: function(aName) {
   268     this._parent.removeAttribute(aName);
   270     if (aName == "emptyText") {
   271       this._removeEmptyText();
   272     }
   273   },
   275   /**
   276    * Set the checkbox state for the item associated with the given node.
   277    *
   278    * @param nsIDOMNode aNode
   279    *        The dom node for an item we want to check.
   280    * @param boolean aCheckState
   281    *        True to check, false to uncheck.
   282    */
   283   checkItem: function(aNode, aCheckState) {
   284     const widgetItem = this._itemsByElement.get(aNode);
   285     if (!widgetItem) {
   286       throw new Error("No item for " + aNode);
   287     }
   288     widgetItem.check(aCheckState);
   289   },
   291   /**
   292    * Sets the text displayed in this container when empty.
   293    * @param string aValue
   294    */
   295   set _textWhenEmpty(aValue) {
   296     if (this._emptyTextNode) {
   297       this._emptyTextNode.setAttribute("value", aValue);
   298     }
   299     this._emptyTextValue = aValue;
   300     this._showEmptyText();
   301   },
   303   /**
   304    * Creates and appends a label signaling that this container is empty.
   305    */
   306   _showEmptyText: function() {
   307     if (this._emptyTextNode || !this._emptyTextValue) {
   308       return;
   309     }
   310     let label = this.document.createElement("label");
   311     label.className = "plain side-menu-widget-empty-text";
   312     label.setAttribute("value", this._emptyTextValue);
   314     this._parent.insertBefore(label, this._list);
   315     this._emptyTextNode = label;
   316   },
   318   /**
   319    * Removes the label representing a notice in this container.
   320    */
   321   _removeEmptyText: function() {
   322     if (!this._emptyTextNode) {
   323       return;
   324     }
   326     this._parent.removeChild(this._emptyTextNode);
   327     this._emptyTextNode = null;
   328   },
   330   /**
   331    * Gets a container representing a group for menu items. If the container
   332    * is not available yet, it is immediately created.
   333    *
   334    * @param string aName
   335    *        The required group name.
   336    * @return SideMenuGroup
   337    *         The newly created group.
   338    */
   339   _getMenuGroupForName: function(aName) {
   340     let cachedGroup = this._groupsByName.get(aName);
   341     if (cachedGroup) {
   342       return cachedGroup;
   343     }
   345     let group = new SideMenuGroup(this, aName, {
   346       showCheckbox: this._showGroupCheckboxes
   347     });
   349     this._groupsByName.set(aName, group);
   350     group.insertSelfAt(this.sortedGroups ? group.findExpectedIndexForSelf(this.groupSortPredicate) : -1);
   352     return group;
   353   },
   355   /**
   356    * Gets a menu item to be displayed inside a group.
   357    * @see SideMenuWidget.prototype._getMenuGroupForName
   358    *
   359    * @param SideMenuGroup aGroup
   360    *        The group to contain the menu item.
   361    * @param nsIDOMNode aContents
   362    *        The node displayed in the container.
   363    * @param object aAttachment [optional]
   364    *        Some attached primitive/object.
   365    */
   366   _getMenuItemForGroup: function(aGroup, aContents, aAttachment) {
   367     return new SideMenuItem(aGroup, aContents, aAttachment, {
   368       showArrow: this._showArrows,
   369       showCheckbox: this._showItemCheckboxes
   370     });
   371   },
   373   /**
   374    * Returns the .side-menu-widget-item node corresponding to a SideMenuItem.
   375    * To optimize the markup, some redundant elemenst are skipped when creating
   376    * these child items, in which case we need to be careful on which nodes
   377    * .selected class names are added, or which nodes are removed.
   378    *
   379    * @param nsIDOMNode aChild
   380    *        An element which is the target node of a SideMenuItem.
   381    * @return nsIDOMNode
   382    *         The wrapper node if there is one, or the same child otherwise.
   383    */
   384   _getNodeForContents: function(aChild) {
   385     if (aChild.hasAttribute("merged-item-contents")) {
   386       return aChild;
   387     } else {
   388       return aChild.parentNode;
   389     }
   390   },
   392   window: null,
   393   document: null,
   394   _showArrows: false,
   395   _showItemCheckboxes: false,
   396   _showGroupCheckboxes: false,
   397   _parent: null,
   398   _list: null,
   399   _selectedItem: null,
   400   _groupsByName: null,
   401   _orderedGroupElementsArray: null,
   402   _orderedMenuElementsArray: null,
   403   _itemsByElement: null,
   404   _emptyTextNode: null,
   405   _emptyTextValue: ""
   406 };
   408 /**
   409  * A SideMenuGroup constructor for the BreadcrumbsWidget.
   410  * Represents a group which should contain SideMenuItems.
   411  *
   412  * @param SideMenuWidget aWidget
   413  *        The widget to contain this menu item.
   414  * @param string aName
   415  *        The string displayed in the container.
   416  * @param object aOptions [optional]
   417  *        An object containing the following properties:
   418  *          - showCheckbox: specifies if a checkbox should be displayed.
   419  */
   420 function SideMenuGroup(aWidget, aName, aOptions={}) {
   421   this.document = aWidget.document;
   422   this.window = aWidget.window;
   423   this.ownerView = aWidget;
   424   this.identifier = aName;
   426   // Create an internal title and list container.
   427   if (aName) {
   428     let target = this._target = this.document.createElement("vbox");
   429     target.className = "side-menu-widget-group";
   430     target.setAttribute("name", aName);
   432     let list = this._list = this.document.createElement("vbox");
   433     list.className = "side-menu-widget-group-list";
   435     let title = this._title = this.document.createElement("hbox");
   436     title.className = "side-menu-widget-group-title";
   438     let name = this._name = this.document.createElement("label");
   439     name.className = "plain name";
   440     name.setAttribute("value", aName);
   441     name.setAttribute("crop", "end");
   442     name.setAttribute("flex", "1");
   444     // Show a checkbox before the content.
   445     if (aOptions.showCheckbox) {
   446       let checkbox = this._checkbox = makeCheckbox(title, { description: aName });
   447       checkbox.className = "side-menu-widget-group-checkbox";
   448     }
   450     title.appendChild(name);
   451     target.appendChild(title);
   452     target.appendChild(list);
   453   }
   454   // Skip a few redundant nodes when no title is shown.
   455   else {
   456     let target = this._target = this._list = this.document.createElement("vbox");
   457     target.className = "side-menu-widget-group side-menu-widget-group-list";
   458     target.setAttribute("merged-group-contents", "");
   459   }
   460 }
   462 SideMenuGroup.prototype = {
   463   get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray,
   464   get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray,
   465   get _itemsByElement() { return this.ownerView._itemsByElement; },
   467   /**
   468    * Inserts this group in the parent container at the specified index.
   469    *
   470    * @param number aIndex
   471    *        The position in the container intended for this group.
   472    */
   473   insertSelfAt: function(aIndex) {
   474     let ownerList = this.ownerView._list;
   475     let groupsArray = this._orderedGroupElementsArray;
   477     if (aIndex >= 0) {
   478       ownerList.insertBefore(this._target, groupsArray[aIndex]);
   479       groupsArray.splice(aIndex, 0, this._target);
   480     } else {
   481       ownerList.appendChild(this._target);
   482       groupsArray.push(this._target);
   483     }
   484   },
   486   /**
   487    * Finds the expected index of this group based on its name.
   488    *
   489    * @return number
   490    *         The expected index.
   491    */
   492   findExpectedIndexForSelf: function(sortPredicate) {
   493     let identifier = this.identifier;
   494     let groupsArray = this._orderedGroupElementsArray;
   496     for (let group of groupsArray) {
   497       let name = group.getAttribute("name");
   498       if (sortPredicate(name, identifier) > 0 && // Insertion sort at its best :)
   499           !name.contains(identifier)) { // Least significant group should be last.
   500         return groupsArray.indexOf(group);
   501       }
   502     }
   503     return -1;
   504   },
   506   window: null,
   507   document: null,
   508   ownerView: null,
   509   identifier: "",
   510   _target: null,
   511   _checkbox: null,
   512   _title: null,
   513   _name: null,
   514   _list: null
   515 };
   517 /**
   518  * A SideMenuItem constructor for the BreadcrumbsWidget.
   519  *
   520  * @param SideMenuGroup aGroup
   521  *        The group to contain this menu item.
   522  * @param nsIDOMNode aContents
   523  *        The node displayed in the container.
   524  * @param object aAttachment [optional]
   525  *        The attachment object.
   526  * @param object aOptions [optional]
   527  *        An object containing the following properties:
   528  *          - showArrow: specifies if a horizontal arrow should be displayed.
   529  *          - showCheckbox: specifies if a checkbox should be displayed.
   530  */
   531 function SideMenuItem(aGroup, aContents, aAttachment={}, aOptions={}) {
   532   this.document = aGroup.document;
   533   this.window = aGroup.window;
   534   this.ownerView = aGroup;
   536   if (aOptions.showArrow || aOptions.showCheckbox) {
   537     let container = this._container = this.document.createElement("hbox");
   538     container.className = "side-menu-widget-item";
   540     let target = this._target = this.document.createElement("vbox");
   541     target.className = "side-menu-widget-item-contents";
   543     // Show a checkbox before the content.
   544     if (aOptions.showCheckbox) {
   545       let checkbox = this._checkbox = makeCheckbox(container, aAttachment);
   546       checkbox.className = "side-menu-widget-item-checkbox";
   547     }
   549     container.appendChild(target);
   551     // Show a horizontal arrow towards the content.
   552     if (aOptions.showArrow) {
   553       let arrow = this._arrow = this.document.createElement("hbox");
   554       arrow.className = "side-menu-widget-item-arrow";
   555       container.appendChild(arrow);
   556     }
   557   }
   558   // Skip a few redundant nodes when no horizontal arrow or checkbox is shown.
   559   else {
   560     let target = this._target = this._container = this.document.createElement("hbox");
   561     target.className = "side-menu-widget-item side-menu-widget-item-contents";
   562     target.setAttribute("merged-item-contents", "");
   563   }
   565   this._target.setAttribute("flex", "1");
   566   this.contents = aContents;
   567 }
   569 SideMenuItem.prototype = {
   570   get _orderedGroupElementsArray() this.ownerView._orderedGroupElementsArray,
   571   get _orderedMenuElementsArray() this.ownerView._orderedMenuElementsArray,
   572   get _itemsByElement() { return this.ownerView._itemsByElement; },
   574   /**
   575    * Inserts this item in the parent group at the specified index.
   576    *
   577    * @param number aIndex
   578    *        The position in the container intended for this item.
   579    * @return nsIDOMNode
   580    *         The element associated with the displayed item.
   581    */
   582   insertSelfAt: function(aIndex) {
   583     let ownerList = this.ownerView._list;
   584     let menuArray = this._orderedMenuElementsArray;
   586     if (aIndex >= 0) {
   587       ownerList.insertBefore(this._container, ownerList.childNodes[aIndex]);
   588       menuArray.splice(aIndex, 0, this._target);
   589     } else {
   590       ownerList.appendChild(this._container);
   591       menuArray.push(this._target);
   592     }
   593     this._itemsByElement.set(this._target, this);
   595     return this._target;
   596   },
   598   /**
   599    * Check or uncheck the checkbox associated with this item.
   600    *
   601    * @param boolean aCheckState
   602    *        True to check, false to uncheck.
   603    */
   604   check: function(aCheckState) {
   605     if (!this._checkbox) {
   606       throw new Error("Cannot check items that do not have checkboxes.");
   607     }
   608     // Don't set or remove the "checked" attribute, assign the property instead.
   609     // Otherwise, the "CheckboxStateChange" event will not be fired. XUL!!
   610     this._checkbox.checked = !!aCheckState;
   611   },
   613   /**
   614    * Sets the contents displayed in this item's view.
   615    *
   616    * @param string | nsIDOMNode aContents
   617    *        The string or node displayed in the container.
   618    */
   619   set contents(aContents) {
   620     // If there are already some contents displayed, replace them.
   621     if (this._target.hasChildNodes()) {
   622       this._target.replaceChild(aContents, this._target.firstChild);
   623       return;
   624     }
   625     // These are the first contents ever displayed.
   626     this._target.appendChild(aContents);
   627   },
   629   window: null,
   630   document: null,
   631   ownerView: null,
   632   _target: null,
   633   _container: null,
   634   _checkbox: null,
   635   _arrow: null
   636 };
   638 /**
   639  * Creates a checkbox to a specified parent node. Emits a "check" event
   640  * whenever the checkbox is checked or unchecked by the user.
   641  *
   642  * @param nsIDOMNode aParentNode
   643  *        The parent node to contain this checkbox.
   644  * @param object aOptions
   645  *        An object containing some or all of the following properties:
   646  *          - description: defaults to "item" if unspecified
   647  *          - checkboxState: true for checked, false for unchecked
   648  *          - checkboxTooltip: the tooltip text of the checkbox
   649  */
   650 function makeCheckbox(aParentNode, aOptions) {
   651   let checkbox = aParentNode.ownerDocument.createElement("checkbox");
   652   checkbox.setAttribute("tooltiptext", aOptions.checkboxTooltip);
   654   if (aOptions.checkboxState) {
   655     checkbox.setAttribute("checked", true);
   656   } else {
   657     checkbox.removeAttribute("checked");
   658   }
   660   // Stop the toggling of the checkbox from selecting the list item.
   661   checkbox.addEventListener("mousedown", e => {
   662     e.stopPropagation();
   663   }, false);
   665   // Emit an event from the checkbox when it is toggled. Don't listen for the
   666   // "command" event! It won't fire for programmatic changes. XUL!!
   667   checkbox.addEventListener("CheckboxStateChange", e => {
   668     ViewHelpers.dispatchEvent(checkbox, "check", {
   669       description: aOptions.description || "item",
   670       checked: checkbox.checked
   671     });
   672   }, false);
   674   aParentNode.appendChild(checkbox);
   675   return checkbox;
   676 }

mercurial