browser/devtools/shared/widgets/ViewHelpers.jsm

Wed, 31 Dec 2014 06:55:46 +0100

author
Michael Schloh von Bennewitz <michael@schloh.com>
date
Wed, 31 Dec 2014 06:55:46 +0100
changeset 1
ca08bd8f51b2
permissions
-rw-r--r--

Added tag TORBROWSER_REPLICA for changeset 6474c204b198

     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 Cc = Components.classes;
     9 const Ci = Components.interfaces;
    10 const Cu = Components.utils;
    12 const PANE_APPEARANCE_DELAY = 50;
    13 const PAGE_SIZE_ITEM_COUNT_RATIO = 5;
    14 const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]);
    16 Cu.import("resource://gre/modules/Services.jsm");
    17 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
    18 Cu.import("resource://gre/modules/Timer.jsm");
    19 Cu.import("resource://gre/modules/devtools/DevToolsUtils.jsm");
    21 this.EXPORTED_SYMBOLS = [
    22   "Heritage", "ViewHelpers", "WidgetMethods",
    23   "setNamedTimeout", "clearNamedTimeout",
    24   "setConditionalTimeout", "clearConditionalTimeout",
    25 ];
    27 /**
    28  * Inheritance helpers from the addon SDK's core/heritage.
    29  * Remove these when all devtools are loadered.
    30  */
    31 this.Heritage = {
    32   /**
    33    * @see extend in sdk/core/heritage.
    34    */
    35   extend: function(aPrototype, aProperties = {}) {
    36     return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties));
    37   },
    39   /**
    40    * @see getOwnPropertyDescriptors in sdk/core/heritage.
    41    */
    42   getOwnPropertyDescriptors: function(aObject) {
    43     return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => {
    44       aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName);
    45       return aDescriptor;
    46     }, {});
    47   }
    48 };
    50 /**
    51  * Helper for draining a rapid succession of events and invoking a callback
    52  * once everything settles down.
    53  *
    54  * @param string aId
    55  *        A string identifier for the named timeout.
    56  * @param number aWait
    57  *        The amount of milliseconds to wait after no more events are fired.
    58  * @param function aCallback
    59  *        Invoked when no more events are fired after the specified time.
    60  */
    61 this.setNamedTimeout = function setNamedTimeout(aId, aWait, aCallback) {
    62   clearNamedTimeout(aId);
    64   namedTimeoutsStore.set(aId, setTimeout(() =>
    65     namedTimeoutsStore.delete(aId) && aCallback(), aWait));
    66 };
    68 /**
    69  * Clears a named timeout.
    70  * @see setNamedTimeout
    71  *
    72  * @param string aId
    73  *        A string identifier for the named timeout.
    74  */
    75 this.clearNamedTimeout = function clearNamedTimeout(aId) {
    76   if (!namedTimeoutsStore) {
    77     return;
    78   }
    79   clearTimeout(namedTimeoutsStore.get(aId));
    80   namedTimeoutsStore.delete(aId);
    81 };
    83 /**
    84  * Same as `setNamedTimeout`, but invokes the callback only if the provided
    85  * predicate function returns true. Otherwise, the timeout is re-triggered.
    86  *
    87  * @param string aId
    88  *        A string identifier for the conditional timeout.
    89  * @param number aWait
    90  *        The amount of milliseconds to wait after no more events are fired.
    91  * @param function aPredicate
    92  *        The predicate function used to determine whether the timeout restarts.
    93  * @param function aCallback
    94  *        Invoked when no more events are fired after the specified time, and
    95  *        the provided predicate function returns true.
    96  */
    97 this.setConditionalTimeout = function setConditionalTimeout(aId, aWait, aPredicate, aCallback) {
    98   setNamedTimeout(aId, aWait, function maybeCallback() {
    99     if (aPredicate()) {
   100       aCallback();
   101       return;
   102     }
   103     setConditionalTimeout(aId, aWait, aPredicate, aCallback);
   104   });
   105 };
   107 /**
   108  * Clears a conditional timeout.
   109  * @see setConditionalTimeout
   110  *
   111  * @param string aId
   112  *        A string identifier for the conditional timeout.
   113  */
   114 this.clearConditionalTimeout = function clearConditionalTimeout(aId) {
   115   clearNamedTimeout(aId);
   116 };
   118 XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map());
   120 /**
   121  * Helpers for creating and messaging between UI components.
   122  */
   123 this.ViewHelpers = {
   124   /**
   125    * Convenience method, dispatching a custom event.
   126    *
   127    * @param nsIDOMNode aTarget
   128    *        A custom target element to dispatch the event from.
   129    * @param string aType
   130    *        The name of the event.
   131    * @param any aDetail
   132    *        The data passed when initializing the event.
   133    * @return boolean
   134    *         True if the event was cancelled or a registered handler
   135    *         called preventDefault.
   136    */
   137   dispatchEvent: function(aTarget, aType, aDetail) {
   138     if (!(aTarget instanceof Ci.nsIDOMNode)) {
   139       return true; // Event cancelled.
   140     }
   141     let document = aTarget.ownerDocument || aTarget;
   142     let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement;
   144     let event = document.createEvent("CustomEvent");
   145     event.initCustomEvent(aType, true, true, aDetail);
   146     return dispatcher.dispatchEvent(event);
   147   },
   149   /**
   150    * Helper delegating some of the DOM attribute methods of a node to a widget.
   151    *
   152    * @param object aWidget
   153    *        The widget to assign the methods to.
   154    * @param nsIDOMNode aNode
   155    *        A node to delegate the methods to.
   156    */
   157   delegateWidgetAttributeMethods: function(aWidget, aNode) {
   158     aWidget.getAttribute =
   159       aWidget.getAttribute || aNode.getAttribute.bind(aNode);
   160     aWidget.setAttribute =
   161       aWidget.setAttribute || aNode.setAttribute.bind(aNode);
   162     aWidget.removeAttribute =
   163       aWidget.removeAttribute || aNode.removeAttribute.bind(aNode);
   164   },
   166   /**
   167    * Helper delegating some of the DOM event methods of a node to a widget.
   168    *
   169    * @param object aWidget
   170    *        The widget to assign the methods to.
   171    * @param nsIDOMNode aNode
   172    *        A node to delegate the methods to.
   173    */
   174   delegateWidgetEventMethods: function(aWidget, aNode) {
   175     aWidget.addEventListener =
   176       aWidget.addEventListener || aNode.addEventListener.bind(aNode);
   177     aWidget.removeEventListener =
   178       aWidget.removeEventListener || aNode.removeEventListener.bind(aNode);
   179   },
   181   /**
   182    * Checks if the specified object looks like it's been decorated by an
   183    * event emitter.
   184    *
   185    * @return boolean
   186    *         True if it looks, walks and quacks like an event emitter.
   187    */
   188   isEventEmitter: function(aObject) {
   189     return aObject && aObject.on && aObject.off && aObject.once && aObject.emit;
   190   },
   192   /**
   193    * Checks if the specified object is an instance of a DOM node.
   194    *
   195    * @return boolean
   196    *         True if it's a node, false otherwise.
   197    */
   198   isNode: function(aObject) {
   199     return aObject instanceof Ci.nsIDOMNode ||
   200            aObject instanceof Ci.nsIDOMElement ||
   201            aObject instanceof Ci.nsIDOMDocumentFragment;
   202   },
   204   /**
   205    * Prevents event propagation when navigation keys are pressed.
   206    *
   207    * @param Event e
   208    *        The event to be prevented.
   209    */
   210   preventScrolling: function(e) {
   211     switch (e.keyCode) {
   212       case e.DOM_VK_UP:
   213       case e.DOM_VK_DOWN:
   214       case e.DOM_VK_LEFT:
   215       case e.DOM_VK_RIGHT:
   216       case e.DOM_VK_PAGE_UP:
   217       case e.DOM_VK_PAGE_DOWN:
   218       case e.DOM_VK_HOME:
   219       case e.DOM_VK_END:
   220         e.preventDefault();
   221         e.stopPropagation();
   222     }
   223   },
   225   /**
   226    * Sets a side pane hidden or visible.
   227    *
   228    * @param object aFlags
   229    *        An object containing some of the following properties:
   230    *        - visible: true if the pane should be shown, false to hide
   231    *        - animated: true to display an animation on toggle
   232    *        - delayed: true to wait a few cycles before toggle
   233    *        - callback: a function to invoke when the toggle finishes
   234    * @param nsIDOMNode aPane
   235    *        The element representing the pane to toggle.
   236    */
   237   togglePane: function(aFlags, aPane) {
   238     // Make sure a pane is actually available first.
   239     if (!aPane) {
   240       return;
   241     }
   243     // Hiding is always handled via margins, not the hidden attribute.
   244     aPane.removeAttribute("hidden");
   246     // Add a class to the pane to handle min-widths, margins and animations.
   247     if (!aPane.classList.contains("generic-toggled-side-pane")) {
   248       aPane.classList.add("generic-toggled-side-pane");
   249     }
   251     // Avoid useless toggles.
   252     if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) {
   253       if (aFlags.callback) aFlags.callback();
   254       return;
   255     }
   257     // The "animated" attributes enables animated toggles (slide in-out).
   258     if (aFlags.animated) {
   259       aPane.setAttribute("animated", "");
   260     } else {
   261       aPane.removeAttribute("animated");
   262     }
   264     // Computes and sets the pane margins in order to hide or show it.
   265     let doToggle = () => {
   266       if (aFlags.visible) {
   267         aPane.style.marginLeft = "0";
   268         aPane.style.marginRight = "0";
   269         aPane.removeAttribute("pane-collapsed");
   270       } else {
   271         let margin = ~~(aPane.getAttribute("width")) + 1;
   272         aPane.style.marginLeft = -margin + "px";
   273         aPane.style.marginRight = -margin + "px";
   274         aPane.setAttribute("pane-collapsed", "");
   275       }
   277       // Invoke the callback when the transition ended.
   278       if (aFlags.animated) {
   279         aPane.addEventListener("transitionend", function onEvent() {
   280           aPane.removeEventListener("transitionend", onEvent, false);
   281           if (aFlags.callback) aFlags.callback();
   282         }, false);
   283       }
   284       // Invoke the callback immediately since there's no transition.
   285       else {
   286         if (aFlags.callback) aFlags.callback();
   287       }
   288     }
   290     // Sometimes it's useful delaying the toggle a few ticks to ensure
   291     // a smoother slide in-out animation.
   292     if (aFlags.delayed) {
   293       aPane.ownerDocument.defaultView.setTimeout(doToggle, PANE_APPEARANCE_DELAY);
   294     } else {
   295       doToggle();
   296     }
   297   }
   298 };
   300 /**
   301  * Localization convenience methods.
   302  *
   303  * @param string aStringBundleName
   304  *        The desired string bundle's name.
   305  */
   306 ViewHelpers.L10N = function(aStringBundleName) {
   307   XPCOMUtils.defineLazyGetter(this, "stringBundle", () =>
   308     Services.strings.createBundle(aStringBundleName));
   310   XPCOMUtils.defineLazyGetter(this, "ellipsis", () =>
   311     Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data);
   312 };
   314 ViewHelpers.L10N.prototype = {
   315   stringBundle: null,
   317   /**
   318    * L10N shortcut function.
   319    *
   320    * @param string aName
   321    * @return string
   322    */
   323   getStr: function(aName) {
   324     return this.stringBundle.GetStringFromName(aName);
   325   },
   327   /**
   328    * L10N shortcut function.
   329    *
   330    * @param string aName
   331    * @param array aArgs
   332    * @return string
   333    */
   334   getFormatStr: function(aName, ...aArgs) {
   335     return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length);
   336   },
   338   /**
   339    * L10N shortcut function for numeric arguments that need to be formatted.
   340    * All numeric arguments will be fixed to 2 decimals and given a localized
   341    * decimal separator. Other arguments will be left alone.
   342    *
   343    * @param string aName
   344    * @param array aArgs
   345    * @return string
   346    */
   347   getFormatStrWithNumbers: function(aName, ...aArgs) {
   348     let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x);
   349     return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length);
   350   },
   352   /**
   353    * Converts a number to a locale-aware string format and keeps a certain
   354    * number of decimals.
   355    *
   356    * @param number aNumber
   357    *        The number to convert.
   358    * @param number aDecimals [optional]
   359    *        Total decimals to keep.
   360    * @return string
   361    *         The localized number as a string.
   362    */
   363   numberWithDecimals: function(aNumber, aDecimals = 0) {
   364     // If this is an integer, don't do anything special.
   365     if (aNumber == (aNumber | 0)) {
   366       return aNumber;
   367     }
   368     // Remove {n} trailing decimals. Can't use toFixed(n) because
   369     // toLocaleString converts the number to a string. Also can't use
   370     // toLocaleString(, { maximumFractionDigits: n }) because it's not
   371     // implemented on OS X (bug 368838). Gross.
   372     let localized = aNumber.toLocaleString(); // localize
   373     let padded = localized + new Array(aDecimals).join("0"); // pad with zeros
   374     let match = padded.match("([^]*?\\d{" + aDecimals + "})\\d*$");
   375     return match.pop();
   376   }
   377 };
   379 /**
   380  * Shortcuts for lazily accessing and setting various preferences.
   381  * Usage:
   382  *   let prefs = new ViewHelpers.Prefs("root.path.to.branch", {
   383  *     myIntPref: ["Int", "leaf.path.to.my-int-pref"],
   384  *     myCharPref: ["Char", "leaf.path.to.my-char-pref"],
   385  *     ...
   386  *   });
   387  *
   388  *   prefs.myCharPref = "foo";
   389  *   let aux = prefs.myCharPref;
   390  *
   391  * @param string aPrefsRoot
   392  *        The root path to the required preferences branch.
   393  * @param object aPrefsObject
   394  *        An object containing { accessorName: [prefType, prefName] } keys.
   395  */
   396 ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsObject = {}) {
   397   this.root = aPrefsRoot;
   399   for (let accessorName in aPrefsObject) {
   400     let [prefType, prefName] = aPrefsObject[accessorName];
   401     this.map(accessorName, prefType, prefName);
   402   }
   403 };
   405 ViewHelpers.Prefs.prototype = {
   406   /**
   407    * Helper method for getting a pref value.
   408    *
   409    * @param string aType
   410    * @param string aPrefName
   411    * @return any
   412    */
   413   _get: function(aType, aPrefName) {
   414     if (this[aPrefName] === undefined) {
   415       this[aPrefName] = Services.prefs["get" + aType + "Pref"](aPrefName);
   416     }
   417     return this[aPrefName];
   418   },
   420   /**
   421    * Helper method for setting a pref value.
   422    *
   423    * @param string aType
   424    * @param string aPrefName
   425    * @param any aValue
   426    */
   427   _set: function(aType, aPrefName, aValue) {
   428     Services.prefs["set" + aType + "Pref"](aPrefName, aValue);
   429     this[aPrefName] = aValue;
   430   },
   432   /**
   433    * Maps a property name to a pref, defining lazy getters and setters.
   434    * Supported types are "Bool", "Char", "Int" and "Json" (which is basically
   435    * just sugar for "Char" using the standard JSON serializer).
   436    *
   437    * @param string aAccessorName
   438    * @param string aType
   439    * @param string aPrefName
   440    * @param array aSerializer
   441    */
   442   map: function(aAccessorName, aType, aPrefName, aSerializer = { in: e => e, out: e => e }) {
   443     if (aType == "Json") {
   444       this.map(aAccessorName, "Char", aPrefName, { in: JSON.parse, out: JSON.stringify });
   445       return;
   446     }
   448     Object.defineProperty(this, aAccessorName, {
   449       get: () => aSerializer.in(this._get(aType, [this.root, aPrefName].join("."))),
   450       set: (e) => this._set(aType, [this.root, aPrefName].join("."), aSerializer.out(e))
   451     });
   452   }
   453 };
   455 /**
   456  * A generic Item is used to describe children present in a Widget.
   457  *
   458  * This is basically a very thin wrapper around an nsIDOMNode, with a few
   459  * characteristics, like a `value` and an `attachment`.
   460  *
   461  * The characteristics are optional, and their meaning is entirely up to you.
   462  * - The `value` should be a string, passed as an argument.
   463  * - The `attachment` is any kind of primitive or object, passed as an argument.
   464  *
   465  * Iterable via "for (let childItem of parentItem) { }".
   466  *
   467  * @param object aOwnerView
   468  *        The owner view creating this item.
   469  * @param nsIDOMNode aElement
   470  *        A prebuilt node to be wrapped.
   471  * @param string aValue
   472  *        A string identifying the node.
   473  * @param any aAttachment
   474  *        Some attached primitive/object.
   475  */
   476 function Item(aOwnerView, aElement, aValue, aAttachment) {
   477   this.ownerView = aOwnerView;
   478   this.attachment = aAttachment;
   479   this._value = aValue + "";
   480   this._prebuiltNode = aElement;
   481 };
   483 Item.prototype = {
   484   get value() { return this._value; },
   485   get target() { return this._target; },
   487   /**
   488    * Immediately appends a child item to this item.
   489    *
   490    * @param nsIDOMNode aElement
   491    *        An nsIDOMNode representing the child element to append.
   492    * @param object aOptions [optional]
   493    *        Additional options or flags supported by this operation:
   494    *          - attachment: some attached primitive/object for the item
   495    *          - attributes: a batch of attributes set to the displayed element
   496    *          - finalize: function invoked when the child item is removed
   497    * @return Item
   498    *         The item associated with the displayed element.
   499    */
   500   append: function(aElement, aOptions = {}) {
   501     let item = new Item(this, aElement, "", aOptions.attachment);
   503     // Entangle the item with the newly inserted child node.
   504     // Make sure this is done with the value returned by appendChild(),
   505     // to avoid storing a potential DocumentFragment.
   506     this._entangleItem(item, this._target.appendChild(aElement));
   508     // Handle any additional options after entangling the item.
   509     if (aOptions.attributes) {
   510       aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1]));
   511     }
   512     if (aOptions.finalize) {
   513       item.finalize = aOptions.finalize;
   514     }
   516     // Return the item associated with the displayed element.
   517     return item;
   518   },
   520   /**
   521    * Immediately removes the specified child item from this item.
   522    *
   523    * @param Item aItem
   524    *        The item associated with the element to remove.
   525    */
   526   remove: function(aItem) {
   527     if (!aItem) {
   528       return;
   529     }
   530     this._target.removeChild(aItem._target);
   531     this._untangleItem(aItem);
   532   },
   534   /**
   535    * Entangles an item (model) with a displayed node element (view).
   536    *
   537    * @param Item aItem
   538    *        The item describing a target element.
   539    * @param nsIDOMNode aElement
   540    *        The element displaying the item.
   541    */
   542   _entangleItem: function(aItem, aElement) {
   543     this._itemsByElement.set(aElement, aItem);
   544     aItem._target = aElement;
   545   },
   547   /**
   548    * Untangles an item (model) from a displayed node element (view).
   549    *
   550    * @param Item aItem
   551    *        The item describing a target element.
   552    */
   553   _untangleItem: function(aItem) {
   554     if (aItem.finalize) {
   555       aItem.finalize(aItem);
   556     }
   557     for (let childItem of aItem) {
   558       aItem.remove(childItem);
   559     }
   561     this._unlinkItem(aItem);
   562     aItem._target = null;
   563   },
   565   /**
   566    * Deletes an item from the its parent's storage maps.
   567    *
   568    * @param Item aItem
   569    *        The item describing a target element.
   570    */
   571   _unlinkItem: function(aItem) {
   572     this._itemsByElement.delete(aItem._target);
   573   },
   575   /**
   576    * Returns a string representing the object.
   577    * @return string
   578    */
   579   toString: function() {
   580     return this._value + " :: " + this._target + " :: " + this.attachment;
   581   },
   583   _value: "",
   584   _target: null,
   585   _prebuiltNode: null,
   586   finalize: null,
   587   attachment: null
   588 };
   590 // Creating maps thousands of times for widgets with a large number of children
   591 // fills up a lot of memory. Make sure these are instantiated only if needed.
   592 DevToolsUtils.defineLazyPrototypeGetter(Item.prototype, "_itemsByElement", Map);
   594 /**
   595  * Some generic Widget methods handling Item instances.
   596  * Iterable via "for (let childItem of wrappedView) { }".
   597  *
   598  * Usage:
   599  *   function MyView() {
   600  *     this.widget = new MyWidget(document.querySelector(".my-node"));
   601  *   }
   602  *
   603  *   MyView.prototype = Heritage.extend(WidgetMethods, {
   604  *     myMethod: function() {},
   605  *     ...
   606  *   });
   607  *
   608  * See https://gist.github.com/victorporof/5749386 for more details.
   609  * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation example.
   610  *
   611  * Language:
   612  *   - An "item" is an instance of an Item.
   613  *   - An "element" or "node" is a nsIDOMNode.
   614  *
   615  * The supplied widget can be any object implementing the following methods:
   616  *   - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode, aValue:string)
   617  *   - function:nsIDOMNode getItemAtIndex(aIndex:number)
   618  *   - function removeChild(aChild:nsIDOMNode)
   619  *   - function removeAllItems()
   620  *   - get:nsIDOMNode selectedItem()
   621  *   - set selectedItem(aChild:nsIDOMNode)
   622  *   - function getAttribute(aName:string)
   623  *   - function setAttribute(aName:string, aValue:string)
   624  *   - function removeAttribute(aName:string)
   625  *   - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean)
   626  *   - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean)
   627  *
   628  * Optional methods that can be implemented by the widget:
   629  *   - function ensureElementIsVisible(aChild:nsIDOMNode)
   630  *
   631  * Optional attributes that may be handled (when calling get/set/removeAttribute):
   632  *   - "emptyText": label temporarily added when there are no items present
   633  *   - "headerText": label permanently added as a header
   634  *
   635  * For automagical keyboard and mouse accessibility, the widget should be an
   636  * event emitter with the following events:
   637  *   - "keyPress" -> (aName:string, aEvent:KeyboardEvent)
   638  *   - "mousePress" -> (aName:string, aEvent:MouseEvent)
   639  */
   640 this.WidgetMethods = {
   641   /**
   642    * Sets the element node or widget associated with this container.
   643    * @param nsIDOMNode | object aWidget
   644    */
   645   set widget(aWidget) {
   646     this._widget = aWidget;
   649     // Can't use a WeakMap for _itemsByValue because keys are strings, and
   650     // can't use one for _itemsByElement either, since it needs to be iterable.
   651     XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map());
   652     XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map());
   653     XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []);
   655     // Handle internal events emitted by the widget if necessary.
   656     if (ViewHelpers.isEventEmitter(aWidget)) {
   657       aWidget.on("keyPress", this._onWidgetKeyPress.bind(this));
   658       aWidget.on("mousePress", this._onWidgetMousePress.bind(this));
   659     }
   660   },
   662   /**
   663    * Gets the element node or widget associated with this container.
   664    * @return nsIDOMNode | object
   665    */
   666   get widget() this._widget,
   668   /**
   669    * Prepares an item to be added to this container. This allows, for example,
   670    * for a large number of items to be batched up before being sorted & added.
   671    *
   672    * If the "staged" flag is *not* set to true, the item will be immediately
   673    * inserted at the correct position in this container, so that all the items
   674    * still remain sorted. This can (possibly) be much slower than batching up
   675    * multiple items.
   676    *
   677    * By default, this container assumes that all the items should be displayed
   678    * sorted by their value. This can be overridden with the "index" flag,
   679    * specifying on which position should an item be appended. The "staged" and
   680    * "index" flags are mutually exclusive, meaning that all staged items
   681    * will always be appended.
   682    *
   683    * @param nsIDOMNode aElement
   684    *        A prebuilt node to be wrapped.
   685    * @param string aValue
   686    *        A string identifying the node.
   687    * @param object aOptions [optional]
   688    *        Additional options or flags supported by this operation:
   689    *          - attachment: some attached primitive/object for the item
   690    *          - staged: true to stage the item to be appended later
   691    *          - index: specifies on which position should the item be appended
   692    *          - attributes: a batch of attributes set to the displayed element
   693    *          - finalize: function invoked when the item is removed
   694    * @return Item
   695    *         The item associated with the displayed element if an unstaged push,
   696    *         undefined if the item was staged for a later commit.
   697    */
   698   push: function([aElement, aValue], aOptions = {}) {
   699     let item = new Item(this, aElement, aValue, aOptions.attachment);
   701     // Batch the item to be added later.
   702     if (aOptions.staged) {
   703       // An ulterior commit operation will ignore any specified index, so
   704       // no reason to keep it around.
   705       aOptions.index = undefined;
   706       return void this._stagedItems.push({ item: item, options: aOptions });
   707     }
   708     // Find the target position in this container and insert the item there.
   709     if (!("index" in aOptions)) {
   710       return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions);
   711     }
   712     // Insert the item at the specified index. If negative or out of bounds,
   713     // the item will be simply appended.
   714     return this._insertItemAt(aOptions.index, item, aOptions);
   715   },
   717   /**
   718    * Flushes all the prepared items into this container.
   719    * Any specified index on the items will be ignored. Everything is appended.
   720    *
   721    * @param object aOptions [optional]
   722    *        Additional options or flags supported by this operation:
   723    *          - sorted: true to sort all the items before adding them
   724    */
   725   commit: function(aOptions = {}) {
   726     let stagedItems = this._stagedItems;
   728     // Sort the items before adding them to this container, if preferred.
   729     if (aOptions.sorted) {
   730       stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item));
   731     }
   732     // Append the prepared items to this container.
   733     for (let { item, options } of stagedItems) {
   734       this._insertItemAt(-1, item, options);
   735     }
   736     // Recreate the temporary items list for ulterior pushes.
   737     this._stagedItems.length = 0;
   738   },
   740   /**
   741    * Immediately removes the specified item from this container.
   742    *
   743    * @param Item aItem
   744    *        The item associated with the element to remove.
   745    */
   746   remove: function(aItem) {
   747     if (!aItem) {
   748       return;
   749     }
   750     this._widget.removeChild(aItem._target);
   751     this._untangleItem(aItem);
   753     if (!this._itemsByElement.size) {
   754       this._preferredValue = this.selectedValue;
   755       this._widget.selectedItem = null;
   756       this._widget.setAttribute("emptyText", this._emptyText);
   757     }
   758   },
   760   /**
   761    * Removes the item at the specified index from this container.
   762    *
   763    * @param number aIndex
   764    *        The index of the item to remove.
   765    */
   766   removeAt: function(aIndex) {
   767     this.remove(this.getItemAtIndex(aIndex));
   768   },
   770   /**
   771    * Removes all items from this container.
   772    */
   773   empty: function() {
   774     this._preferredValue = this.selectedValue;
   775     this._widget.selectedItem = null;
   776     this._widget.removeAllItems();
   777     this._widget.setAttribute("emptyText", this._emptyText);
   779     for (let [, item] of this._itemsByElement) {
   780       this._untangleItem(item);
   781     }
   783     this._itemsByValue.clear();
   784     this._itemsByElement.clear();
   785     this._stagedItems.length = 0;
   786   },
   788   /**
   789    * Ensures the specified item is visible in this container.
   790    *
   791    * @param Item aItem
   792    *        The item to bring into view.
   793    */
   794   ensureItemIsVisible: function(aItem) {
   795     this._widget.ensureElementIsVisible(aItem._target);
   796   },
   798   /**
   799    * Ensures the item at the specified index is visible in this container.
   800    *
   801    * @param number aIndex
   802    *        The index of the item to bring into view.
   803    */
   804   ensureIndexIsVisible: function(aIndex) {
   805     this.ensureItemIsVisible(this.getItemAtIndex(aIndex));
   806   },
   808   /**
   809    * Sugar for ensuring the selected item is visible in this container.
   810    */
   811   ensureSelectedItemIsVisible: function() {
   812     this.ensureItemIsVisible(this.selectedItem);
   813   },
   815   /**
   816    * If supported by the widget, the label string temporarily added to this
   817    * container when there are no child items present.
   818    */
   819   set emptyText(aValue) {
   820     this._emptyText = aValue;
   822     // Apply the emptyText attribute right now if there are no child items.
   823     if (!this._itemsByElement.size) {
   824       this._widget.setAttribute("emptyText", aValue);
   825     }
   826   },
   828   /**
   829    * If supported by the widget, the label string permanently added to this
   830    * container as a header.
   831    * @param string aValue
   832    */
   833   set headerText(aValue) {
   834     this._headerText = aValue;
   835     this._widget.setAttribute("headerText", aValue);
   836   },
   838   /**
   839    * Toggles all the items in this container hidden or visible.
   840    *
   841    * This does not change the default filtering predicate, so newly inserted
   842    * items will always be visible. Use WidgetMethods.filterContents if you care.
   843    *
   844    * @param boolean aVisibleFlag
   845    *        Specifies the intended visibility.
   846    */
   847   toggleContents: function(aVisibleFlag) {
   848     for (let [element, item] of this._itemsByElement) {
   849       element.hidden = !aVisibleFlag;
   850     }
   851   },
   853   /**
   854    * Toggles all items in this container hidden or visible based on a predicate.
   855    *
   856    * @param function aPredicate [optional]
   857    *        Items are toggled according to the return value of this function,
   858    *        which will become the new default filtering predicate in this container.
   859    *        If unspecified, all items will be toggled visible.
   860    */
   861   filterContents: function(aPredicate = this._currentFilterPredicate) {
   862     this._currentFilterPredicate = aPredicate;
   864     for (let [element, item] of this._itemsByElement) {
   865       element.hidden = !aPredicate(item);
   866     }
   867   },
   869   /**
   870    * Sorts all the items in this container based on a predicate.
   871    *
   872    * @param function aPredicate [optional]
   873    *        Items are sorted according to the return value of the function,
   874    *        which will become the new default sorting predicate in this container.
   875    *        If unspecified, all items will be sorted by their value.
   876    */
   877   sortContents: function(aPredicate = this._currentSortPredicate) {
   878     let sortedItems = this.items.sort(this._currentSortPredicate = aPredicate);
   880     for (let i = 0, len = sortedItems.length; i < len; i++) {
   881       this.swapItems(this.getItemAtIndex(i), sortedItems[i]);
   882     }
   883   },
   885   /**
   886    * Visually swaps two items in this container.
   887    *
   888    * @param Item aFirst
   889    *        The first item to be swapped.
   890    * @param Item aSecond
   891    *        The second item to be swapped.
   892    */
   893   swapItems: function(aFirst, aSecond) {
   894     if (aFirst == aSecond) { // We're just dandy, thank you.
   895       return;
   896     }
   897     let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = aFirst;
   898     let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = aSecond;
   900     // If the two items were constructed with prebuilt nodes as DocumentFragments,
   901     // then those DocumentFragments are now empty and need to be reassembled.
   902     if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) {
   903       for (let node of firstTarget.childNodes) {
   904         firstPrebuiltTarget.appendChild(node.cloneNode(true));
   905       }
   906     }
   907     if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) {
   908       for (let node of secondTarget.childNodes) {
   909         secondPrebuiltTarget.appendChild(node.cloneNode(true));
   910       }
   911     }
   913     // 1. Get the indices of the two items to swap.
   914     let i = this._indexOfElement(firstTarget);
   915     let j = this._indexOfElement(secondTarget);
   917     // 2. Remeber the selection index, to reselect an item, if necessary.
   918     let selectedTarget = this._widget.selectedItem;
   919     let selectedIndex = -1;
   920     if (selectedTarget == firstTarget) {
   921       selectedIndex = i;
   922     } else if (selectedTarget == secondTarget) {
   923       selectedIndex = j;
   924     }
   926     // 3. Silently nuke both items, nobody needs to know about this.
   927     this._widget.removeChild(firstTarget);
   928     this._widget.removeChild(secondTarget);
   929     this._unlinkItem(aFirst);
   930     this._unlinkItem(aSecond);
   932     // 4. Add the items again, but reversing their indices.
   933     this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]);
   934     this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]);
   936     // 5. Restore the previous selection, if necessary.
   937     if (selectedIndex == i) {
   938       this._widget.selectedItem = aFirst._target;
   939     } else if (selectedIndex == j) {
   940       this._widget.selectedItem = aSecond._target;
   941     }
   943     // 6. Let the outside world know that these two items were swapped.
   944     ViewHelpers.dispatchEvent(aFirst.target, "swap", [aSecond, aFirst]);
   945   },
   947   /**
   948    * Visually swaps two items in this container at specific indices.
   949    *
   950    * @param number aFirst
   951    *        The index of the first item to be swapped.
   952    * @param number aSecond
   953    *        The index of the second item to be swapped.
   954    */
   955   swapItemsAtIndices: function(aFirst, aSecond) {
   956     this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond));
   957   },
   959   /**
   960    * Checks whether an item with the specified value is among the elements
   961    * shown in this container.
   962    *
   963    * @param string aValue
   964    *        The item's value.
   965    * @return boolean
   966    *         True if the value is known, false otherwise.
   967    */
   968   containsValue: function(aValue) {
   969     return this._itemsByValue.has(aValue) ||
   970            this._stagedItems.some(({ item }) => item._value == aValue);
   971   },
   973   /**
   974    * Gets the "preferred value". This is the latest selected item's value,
   975    * remembered just before emptying this container.
   976    * @return string
   977    */
   978   get preferredValue() {
   979     return this._preferredValue;
   980   },
   982   /**
   983    * Retrieves the item associated with the selected element.
   984    * @return Item | null
   985    */
   986   get selectedItem() {
   987     let selectedElement = this._widget.selectedItem;
   988     if (selectedElement) {
   989       return this._itemsByElement.get(selectedElement);
   990     }
   991     return null;
   992   },
   994   /**
   995    * Retrieves the selected element's index in this container.
   996    * @return number
   997    */
   998   get selectedIndex() {
   999     let selectedElement = this._widget.selectedItem;
  1000     if (selectedElement) {
  1001       return this._indexOfElement(selectedElement);
  1003     return -1;
  1004   },
  1006   /**
  1007    * Retrieves the value of the selected element.
  1008    * @return string
  1009    */
  1010   get selectedValue() {
  1011     let selectedElement = this._widget.selectedItem;
  1012     if (selectedElement) {
  1013       return this._itemsByElement.get(selectedElement)._value;
  1015     return "";
  1016   },
  1018   /**
  1019    * Retrieves the attachment of the selected element.
  1020    * @return object | null
  1021    */
  1022   get selectedAttachment() {
  1023     let selectedElement = this._widget.selectedItem;
  1024     if (selectedElement) {
  1025       return this._itemsByElement.get(selectedElement).attachment;
  1027     return null;
  1028   },
  1030   /**
  1031    * Selects the element with the entangled item in this container.
  1032    * @param Item | function aItem
  1033    */
  1034   set selectedItem(aItem) {
  1035     // A predicate is allowed to select a specific item.
  1036     // If no item is matched, then the current selection is removed.
  1037     if (typeof aItem == "function") {
  1038       aItem = this.getItemForPredicate(aItem);
  1041     // A falsy item is allowed to invalidate the current selection.
  1042     let targetElement = aItem ? aItem._target : null;
  1043     let prevElement = this._widget.selectedItem;
  1045     // Make sure the selected item's target element is focused and visible.
  1046     if (this.autoFocusOnSelection && targetElement) {
  1047       targetElement.focus();
  1049     if (this.maintainSelectionVisible && targetElement) {
  1050       if ("ensureElementIsVisible" in this._widget) {
  1051         this._widget.ensureElementIsVisible(targetElement);
  1055     // Prevent selecting the same item again and avoid dispatching
  1056     // a redundant selection event, so return early.
  1057     if (targetElement != prevElement) {
  1058       this._widget.selectedItem = targetElement;
  1059       let dispTarget = targetElement || prevElement;
  1060       let dispName = this.suppressSelectionEvents ? "suppressed-select" : "select";
  1061       ViewHelpers.dispatchEvent(dispTarget, dispName, aItem);
  1063   },
  1065   /**
  1066    * Selects the element at the specified index in this container.
  1067    * @param number aIndex
  1068    */
  1069   set selectedIndex(aIndex) {
  1070     let targetElement = this._widget.getItemAtIndex(aIndex);
  1071     if (targetElement) {
  1072       this.selectedItem = this._itemsByElement.get(targetElement);
  1073       return;
  1075     this.selectedItem = null;
  1076   },
  1078   /**
  1079    * Selects the element with the specified value in this container.
  1080    * @param string aValue
  1081    */
  1082   set selectedValue(aValue) {
  1083     this.selectedItem = this._itemsByValue.get(aValue);
  1084   },
  1086   /**
  1087    * Specifies if this container should try to keep the selected item visible.
  1088    * (For example, when new items are added the selection is brought into view).
  1089    */
  1090   maintainSelectionVisible: true,
  1092   /**
  1093    * Specifies if "select" events dispatched from the elements in this container
  1094    * when their respective items are selected should be suppressed or not.
  1096    * If this flag is set to true, then consumers of this container won't
  1097    * be normally notified when items are selected.
  1098    */
  1099   suppressSelectionEvents: false,
  1101   /**
  1102    * Focus this container the first time an element is inserted?
  1104    * If this flag is set to true, then when the first item is inserted in
  1105    * this container (and thus it's the only item available), its corresponding
  1106    * target element is focused as well.
  1107    */
  1108   autoFocusOnFirstItem: true,
  1110   /**
  1111    * Focus on selection?
  1113    * If this flag is set to true, then whenever an item is selected in
  1114    * this container (e.g. via the selectedIndex or selectedItem setters),
  1115    * its corresponding target element is focused as well.
  1117    * You can disable this flag, for example, to maintain a certain node
  1118    * focused but visually indicate a different selection in this container.
  1119    */
  1120   autoFocusOnSelection: true,
  1122   /**
  1123    * Focus on input (e.g. mouse click)?
  1125    * If this flag is set to true, then whenever an item receives user input in
  1126    * this container, its corresponding target element is focused as well.
  1127    */
  1128   autoFocusOnInput: true,
  1130   /**
  1131    * When focusing on input, allow right clicks?
  1132    * @see WidgetMethods.autoFocusOnInput
  1133    */
  1134   allowFocusOnRightClick: false,
  1136   /**
  1137    * The number of elements in this container to jump when Page Up or Page Down
  1138    * keys are pressed. If falsy, then the page size will be based on the
  1139    * number of visible items in the container.
  1140    */
  1141   pageSize: 0,
  1143   /**
  1144    * Focuses the first visible item in this container.
  1145    */
  1146   focusFirstVisibleItem: function() {
  1147     this.focusItemAtDelta(-this.itemCount);
  1148   },
  1150   /**
  1151    * Focuses the last visible item in this container.
  1152    */
  1153   focusLastVisibleItem: function() {
  1154     this.focusItemAtDelta(+this.itemCount);
  1155   },
  1157   /**
  1158    * Focuses the next item in this container.
  1159    */
  1160   focusNextItem: function() {
  1161     this.focusItemAtDelta(+1);
  1162   },
  1164   /**
  1165    * Focuses the previous item in this container.
  1166    */
  1167   focusPrevItem: function() {
  1168     this.focusItemAtDelta(-1);
  1169   },
  1171   /**
  1172    * Focuses another item in this container based on the index distance
  1173    * from the currently focused item.
  1175    * @param number aDelta
  1176    *        A scalar specifying by how many items should the selection change.
  1177    */
  1178   focusItemAtDelta: function(aDelta) {
  1179     // Make sure the currently selected item is also focused, so that the
  1180     // command dispatcher mechanism has a relative node to work with.
  1181     // If there's no selection, just select an item at a corresponding index
  1182     // (e.g. the first item in this container if aDelta <= 1).
  1183     let selectedElement = this._widget.selectedItem;
  1184     if (selectedElement) {
  1185       selectedElement.focus();
  1186     } else {
  1187       this.selectedIndex = Math.max(0, aDelta - 1);
  1188       return;
  1191     let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus";
  1192     let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta));
  1193     while (distance--) {
  1194       if (!this._focusChange(direction)) {
  1195         break; // Out of bounds.
  1199     // Synchronize the selected item as being the currently focused element.
  1200     this.selectedItem = this.getItemForElement(this._focusedElement);
  1201   },
  1203   /**
  1204    * Focuses the next or previous item in this container.
  1206    * @param string aDirection
  1207    *        Either "advanceFocus" or "rewindFocus".
  1208    * @return boolean
  1209    *         False if the focus went out of bounds and the first or last item
  1210    *         in this container was focused instead.
  1211    */
  1212   _focusChange: function(aDirection) {
  1213     let commandDispatcher = this._commandDispatcher;
  1214     let prevFocusedElement = commandDispatcher.focusedElement;
  1215     let currFocusedElement;
  1217     do {
  1218       commandDispatcher.suppressFocusScroll = true;
  1219       commandDispatcher[aDirection]();
  1220       currFocusedElement = commandDispatcher.focusedElement;
  1222       // Make sure the newly focused item is a part of this container. If the
  1223       // focus goes out of bounds, revert the previously focused item.
  1224       if (!this.getItemForElement(currFocusedElement)) {
  1225         prevFocusedElement.focus();
  1226         return false;
  1228     } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName));
  1230     // Focus remained within bounds.
  1231     return true;
  1232   },
  1234   /**
  1235    * Gets the command dispatcher instance associated with this container's DOM.
  1236    * If there are no items displayed in this container, null is returned.
  1237    * @return nsIDOMXULCommandDispatcher | null
  1238    */
  1239   get _commandDispatcher() {
  1240     if (this._cachedCommandDispatcher) {
  1241       return this._cachedCommandDispatcher;
  1243     let someElement = this._widget.getItemAtIndex(0);
  1244     if (someElement) {
  1245       let commandDispatcher = someElement.ownerDocument.commandDispatcher;
  1246       return this._cachedCommandDispatcher = commandDispatcher;
  1248     return null;
  1249   },
  1251   /**
  1252    * Gets the currently focused element in this container.
  1254    * @return nsIDOMNode
  1255    *         The focused element, or null if nothing is found.
  1256    */
  1257   get _focusedElement() {
  1258     let commandDispatcher = this._commandDispatcher;
  1259     if (commandDispatcher) {
  1260       return commandDispatcher.focusedElement;
  1262     return null;
  1263   },
  1265   /**
  1266    * Gets the item in the container having the specified index.
  1268    * @param number aIndex
  1269    *        The index used to identify the element.
  1270    * @return Item
  1271    *         The matched item, or null if nothing is found.
  1272    */
  1273   getItemAtIndex: function(aIndex) {
  1274     return this.getItemForElement(this._widget.getItemAtIndex(aIndex));
  1275   },
  1277   /**
  1278    * Gets the item in the container having the specified value.
  1280    * @param string aValue
  1281    *        The value used to identify the element.
  1282    * @return Item
  1283    *         The matched item, or null if nothing is found.
  1284    */
  1285   getItemByValue: function(aValue) {
  1286     return this._itemsByValue.get(aValue);
  1287   },
  1289   /**
  1290    * Gets the item in the container associated with the specified element.
  1292    * @param nsIDOMNode aElement
  1293    *        The element used to identify the item.
  1294    * @param object aFlags [optional]
  1295    *        Additional options for showing the source. Supported options:
  1296    *          - noSiblings: if siblings shouldn't be taken into consideration
  1297    *                        when searching for the associated item.
  1298    * @return Item
  1299    *         The matched item, or null if nothing is found.
  1300    */
  1301   getItemForElement: function(aElement, aFlags = {}) {
  1302     while (aElement) {
  1303       let item = this._itemsByElement.get(aElement);
  1305       // Also search the siblings if allowed.
  1306       if (!aFlags.noSiblings) {
  1307         item = item ||
  1308           this._itemsByElement.get(aElement.nextElementSibling) ||
  1309           this._itemsByElement.get(aElement.previousElementSibling);
  1311       if (item) {
  1312         return item;
  1314       aElement = aElement.parentNode;
  1316     return null;
  1317   },
  1319   /**
  1320    * Gets a visible item in this container validating a specified predicate.
  1322    * @param function aPredicate
  1323    *        The first item which validates this predicate is returned
  1324    * @return Item
  1325    *         The matched item, or null if nothing is found.
  1326    */
  1327   getItemForPredicate: function(aPredicate, aOwner = this) {
  1328     // Recursively check the items in this widget for a predicate match.
  1329     for (let [element, item] of aOwner._itemsByElement) {
  1330       let match;
  1331       if (aPredicate(item) && !element.hidden) {
  1332         match = item;
  1333       } else {
  1334         match = this.getItemForPredicate(aPredicate, item);
  1336       if (match) {
  1337         return match;
  1340     // Also check the staged items. No need to do this recursively since
  1341     // they're not even appended to the view yet.
  1342     for (let { item } of this._stagedItems) {
  1343       if (aPredicate(item)) {
  1344         return item;
  1347     return null;
  1348   },
  1350   /**
  1351    * Shortcut function for getItemForPredicate which works on item attachments.
  1352    * @see getItemForPredicate
  1353    */
  1354   getItemForAttachment: function(aPredicate, aOwner = this) {
  1355     return this.getItemForPredicate(e => aPredicate(e.attachment));
  1356   },
  1358   /**
  1359    * Finds the index of an item in the container.
  1361    * @param Item aItem
  1362    *        The item get the index for.
  1363    * @return number
  1364    *         The index of the matched item, or -1 if nothing is found.
  1365    */
  1366   indexOfItem: function(aItem) {
  1367     return this._indexOfElement(aItem._target);
  1368   },
  1370   /**
  1371    * Finds the index of an element in the container.
  1373    * @param nsIDOMNode aElement
  1374    *        The element get the index for.
  1375    * @return number
  1376    *         The index of the matched element, or -1 if nothing is found.
  1377    */
  1378   _indexOfElement: function(aElement) {
  1379     for (let i = 0; i < this._itemsByElement.size; i++) {
  1380       if (this._widget.getItemAtIndex(i) == aElement) {
  1381         return i;
  1384     return -1;
  1385   },
  1387   /**
  1388    * Gets the total number of items in this container.
  1389    * @return number
  1390    */
  1391   get itemCount() {
  1392     return this._itemsByElement.size;
  1393   },
  1395   /**
  1396    * Returns a list of items in this container, in the displayed order.
  1397    * @return array
  1398    */
  1399   get items() {
  1400     let store = [];
  1401     let itemCount = this.itemCount;
  1402     for (let i = 0; i < itemCount; i++) {
  1403       store.push(this.getItemAtIndex(i));
  1405     return store;
  1406   },
  1408   /**
  1409    * Returns a list of values in this container, in the displayed order.
  1410    * @return array
  1411    */
  1412   get values() {
  1413     return this.items.map(e => e._value);
  1414   },
  1416   /**
  1417    * Returns a list of attachments in this container, in the displayed order.
  1418    * @return array
  1419    */
  1420   get attachments() {
  1421     return this.items.map(e => e.attachment);
  1422   },
  1424   /**
  1425    * Returns a list of all the visible (non-hidden) items in this container,
  1426    * in the displayed order
  1427    * @return array
  1428    */
  1429   get visibleItems() {
  1430     return this.items.filter(e => !e._target.hidden);
  1431   },
  1433   /**
  1434    * Checks if an item is unique in this container. If an item's value is an
  1435    * empty string, "undefined" or "null", it is considered unique.
  1437    * @param Item aItem
  1438    *        The item for which to verify uniqueness.
  1439    * @return boolean
  1440    *         True if the item is unique, false otherwise.
  1441    */
  1442   isUnique: function(aItem) {
  1443     let value = aItem._value;
  1444     if (value == "" || value == "undefined" || value == "null") {
  1445       return true;
  1447     return !this._itemsByValue.has(value);
  1448   },
  1450   /**
  1451    * Checks if an item is eligible for this container. By default, this checks
  1452    * whether an item is unique and has a prebuilt target node.
  1454    * @param Item aItem
  1455    *        The item for which to verify eligibility.
  1456    * @return boolean
  1457    *         True if the item is eligible, false otherwise.
  1458    */
  1459   isEligible: function(aItem) {
  1460     return this.isUnique(aItem) && aItem._prebuiltNode;
  1461   },
  1463   /**
  1464    * Finds the expected item index in this container based on the default
  1465    * sort predicate.
  1467    * @param Item aItem
  1468    *        The item for which to get the expected index.
  1469    * @return number
  1470    *         The expected item index.
  1471    */
  1472   _findExpectedIndexFor: function(aItem) {
  1473     let itemCount = this.itemCount;
  1474     for (let i = 0; i < itemCount; i++) {
  1475       if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) {
  1476         return i;
  1479     return itemCount;
  1480   },
  1482   /**
  1483    * Immediately inserts an item in this container at the specified index.
  1485    * @param number aIndex
  1486    *        The position in the container intended for this item.
  1487    * @param Item aItem
  1488    *        The item describing a target element.
  1489    * @param object aOptions [optional]
  1490    *        Additional options or flags supported by this operation:
  1491    *          - attributes: a batch of attributes set to the displayed element
  1492    *          - finalize: function when the item is untangled (removed)
  1493    * @return Item
  1494    *         The item associated with the displayed element, null if rejected.
  1495    */
  1496   _insertItemAt: function(aIndex, aItem, aOptions = {}) {
  1497     if (!this.isEligible(aItem)) {
  1498       return null;
  1501     // Entangle the item with the newly inserted node.
  1502     // Make sure this is done with the value returned by insertItemAt(),
  1503     // to avoid storing a potential DocumentFragment.
  1504     let node = aItem._prebuiltNode;
  1505     let attachment = aItem.attachment;
  1506     this._entangleItem(aItem, this._widget.insertItemAt(aIndex, node, attachment));
  1508     // Handle any additional options after entangling the item.
  1509     if (!this._currentFilterPredicate(aItem)) {
  1510       aItem._target.hidden = true;
  1512     if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) {
  1513       aItem._target.focus();
  1515     if (aOptions.attributes) {
  1516       aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1]));
  1518     if (aOptions.finalize) {
  1519       aItem.finalize = aOptions.finalize;
  1522     // Hide the empty text if the selection wasn't lost.
  1523     this._widget.removeAttribute("emptyText");
  1525     // Return the item associated with the displayed element.
  1526     return aItem;
  1527   },
  1529   /**
  1530    * Entangles an item (model) with a displayed node element (view).
  1532    * @param Item aItem
  1533    *        The item describing a target element.
  1534    * @param nsIDOMNode aElement
  1535    *        The element displaying the item.
  1536    */
  1537   _entangleItem: function(aItem, aElement) {
  1538     this._itemsByValue.set(aItem._value, aItem);
  1539     this._itemsByElement.set(aElement, aItem);
  1540     aItem._target = aElement;
  1541   },
  1543   /**
  1544    * Untangles an item (model) from a displayed node element (view).
  1546    * @param Item aItem
  1547    *        The item describing a target element.
  1548    */
  1549   _untangleItem: function(aItem) {
  1550     if (aItem.finalize) {
  1551       aItem.finalize(aItem);
  1553     for (let childItem of aItem) {
  1554       aItem.remove(childItem);
  1557     this._unlinkItem(aItem);
  1558     aItem._target = null;
  1559   },
  1561   /**
  1562    * Deletes an item from the its parent's storage maps.
  1564    * @param Item aItem
  1565    *        The item describing a target element.
  1566    */
  1567   _unlinkItem: function(aItem) {
  1568     this._itemsByValue.delete(aItem._value);
  1569     this._itemsByElement.delete(aItem._target);
  1570   },
  1572   /**
  1573    * The keyPress event listener for this container.
  1574    * @param string aName
  1575    * @param KeyboardEvent aEvent
  1576    */
  1577   _onWidgetKeyPress: function(aName, aEvent) {
  1578     // Prevent scrolling when pressing navigation keys.
  1579     ViewHelpers.preventScrolling(aEvent);
  1581     switch (aEvent.keyCode) {
  1582       case aEvent.DOM_VK_UP:
  1583       case aEvent.DOM_VK_LEFT:
  1584         this.focusPrevItem();
  1585         return;
  1586       case aEvent.DOM_VK_DOWN:
  1587       case aEvent.DOM_VK_RIGHT:
  1588         this.focusNextItem();
  1589         return;
  1590       case aEvent.DOM_VK_PAGE_UP:
  1591         this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
  1592         return;
  1593       case aEvent.DOM_VK_PAGE_DOWN:
  1594         this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO)));
  1595         return;
  1596       case aEvent.DOM_VK_HOME:
  1597         this.focusFirstVisibleItem();
  1598         return;
  1599       case aEvent.DOM_VK_END:
  1600         this.focusLastVisibleItem();
  1601         return;
  1603   },
  1605   /**
  1606    * The mousePress event listener for this container.
  1607    * @param string aName
  1608    * @param MouseEvent aEvent
  1609    */
  1610   _onWidgetMousePress: function(aName, aEvent) {
  1611     if (aEvent.button != 0 && !this.allowFocusOnRightClick) {
  1612       // Only allow left-click to trigger this event.
  1613       return;
  1616     let item = this.getItemForElement(aEvent.target);
  1617     if (item) {
  1618       // The container is not empty and we clicked on an actual item.
  1619       this.selectedItem = item;
  1620       // Make sure the current event's target element is also focused.
  1621       this.autoFocusOnInput && item._target.focus();
  1623   },
  1625   /**
  1626    * The predicate used when filtering items. By default, all items in this
  1627    * view are visible.
  1629    * @param Item aItem
  1630    *        The item passing through the filter.
  1631    * @return boolean
  1632    *         True if the item should be visible, false otherwise.
  1633    */
  1634   _currentFilterPredicate: function(aItem) {
  1635     return true;
  1636   },
  1638   /**
  1639    * The predicate used when sorting items. By default, items in this view
  1640    * are sorted by their label.
  1642    * @param Item aFirst
  1643    *        The first item used in the comparison.
  1644    * @param Item aSecond
  1645    *        The second item used in the comparison.
  1646    * @return number
  1647    *         -1 to sort aFirst to a lower index than aSecond
  1648    *          0 to leave aFirst and aSecond unchanged with respect to each other
  1649    *          1 to sort aSecond to a lower index than aFirst
  1650    */
  1651   _currentSortPredicate: function(aFirst, aSecond) {
  1652     return +(aFirst._value.toLowerCase() > aSecond._value.toLowerCase());
  1653   },
  1655   /**
  1656    * Call a method on this widget named `aMethodName`. Any further arguments are
  1657    * passed on to the method. Returns the result of the method call.
  1659    * @param String aMethodName
  1660    *        The name of the method you want to call.
  1661    * @param aArgs
  1662    *        Optional. Any arguments you want to pass through to the method.
  1663    */
  1664   callMethod: function(aMethodName, ...aArgs) {
  1665     return this._widget[aMethodName].apply(this._widget, aArgs);
  1666   },
  1668   _widget: null,
  1669   _emptyText: "",
  1670   _headerText: "",
  1671   _preferredValue: "",
  1672   _cachedCommandDispatcher: null
  1673 };
  1675 /**
  1676  * A generator-iterator over all the items in this container.
  1677  */
  1678 Item.prototype["@@iterator"] =
  1679 WidgetMethods["@@iterator"] = function*() {
  1680   yield* this._itemsByElement.values();
  1681 };

mercurial