/* -*- Mode: javascript; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim: set ft=javascript ts=2 et sw=2 tw=80: */ /* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ "use strict"; const Cc = Components.classes; const Ci = Components.interfaces; const Cu = Components.utils; const PANE_APPEARANCE_DELAY = 50; const PAGE_SIZE_ITEM_COUNT_RATIO = 5; const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]); Cu.import("resource://gre/modules/Services.jsm"); Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Timer.jsm"); this.EXPORTED_SYMBOLS = [ "Heritage", "ViewHelpers", "WidgetMethods", "setNamedTimeout", "clearNamedTimeout" ]; /** * Inheritance helpers from the addon SDK's core/heritage. * Remove these when all devtools are loadered. */ this.Heritage = { /** * @see extend in sdk/core/heritage. */ extend: function(aPrototype, aProperties = {}) { return Object.create(aPrototype, this.getOwnPropertyDescriptors(aProperties)); }, /** * @see getOwnPropertyDescriptors in sdk/core/heritage. */ getOwnPropertyDescriptors: function(aObject) { return Object.getOwnPropertyNames(aObject).reduce((aDescriptor, aName) => { aDescriptor[aName] = Object.getOwnPropertyDescriptor(aObject, aName); return aDescriptor; }, {}); } }; /** * Helper for draining a rapid succession of events and invoking a callback * once everything settles down. * * @param string aId * A string identifier for the named timeout. * @param number aWait * The amount of milliseconds to wait after no more events are fired. * @param function aCallback * Invoked when no more events are fired after the specified time. */ this.setNamedTimeout = function(aId, aWait, aCallback) { clearNamedTimeout(aId); namedTimeoutsStore.set(aId, setTimeout(() => namedTimeoutsStore.delete(aId) && aCallback(), aWait)); }; /** * Clears a named timeout. * @see setNamedTimeout * * @param string aId * A string identifier for the named timeout. */ this.clearNamedTimeout = function(aId) { if (!namedTimeoutsStore) { return; } clearTimeout(namedTimeoutsStore.get(aId)); namedTimeoutsStore.delete(aId); }; XPCOMUtils.defineLazyGetter(this, "namedTimeoutsStore", () => new Map()); /** * Helpers for creating and messaging between UI components. */ this.ViewHelpers = { /** * Convenience method, dispatching a custom event. * * @param nsIDOMNode aTarget * A custom target element to dispatch the event from. * @param string aType * The name of the event. * @param any aDetail * The data passed when initializing the event. * @return boolean * True if the event was cancelled or a registered handler * called preventDefault. */ dispatchEvent: function(aTarget, aType, aDetail) { if (!(aTarget instanceof Ci.nsIDOMNode)) { return true; // Event cancelled. } let document = aTarget.ownerDocument || aTarget; let dispatcher = aTarget.ownerDocument ? aTarget : document.documentElement; let event = document.createEvent("CustomEvent"); event.initCustomEvent(aType, true, true, aDetail); return dispatcher.dispatchEvent(event); }, /** * Helper delegating some of the DOM attribute methods of a node to a widget. * * @param object aWidget * The widget to assign the methods to. * @param nsIDOMNode aNode * A node to delegate the methods to. */ delegateWidgetAttributeMethods: function(aWidget, aNode) { aWidget.getAttribute = aNode.getAttribute.bind(aNode); aWidget.setAttribute = aNode.setAttribute.bind(aNode); aWidget.removeAttribute = aNode.removeAttribute.bind(aNode); }, /** * Helper delegating some of the DOM event methods of a node to a widget. * * @param object aWidget * The widget to assign the methods to. * @param nsIDOMNode aNode * A node to delegate the methods to. */ delegateWidgetEventMethods: function(aWidget, aNode) { aWidget.addEventListener = aNode.addEventListener.bind(aNode); aWidget.removeEventListener = aNode.removeEventListener.bind(aNode); }, /** * Checks if the specified object looks like it's been decorated by an * event emitter. * * @return boolean * True if it looks, walks and quacks like an event emitter. */ isEventEmitter: function(aObject) { return aObject && aObject.on && aObject.off && aObject.once && aObject.emit; }, /** * Checks if the specified object is an instance of a DOM node. * * @return boolean * True if it's a node, false otherwise. */ isNode: function(aObject) { return aObject instanceof Ci.nsIDOMNode || aObject instanceof Ci.nsIDOMElement || aObject instanceof Ci.nsIDOMDocumentFragment; }, /** * Prevents event propagation when navigation keys are pressed. * * @param Event e * The event to be prevented. */ preventScrolling: function(e) { switch (e.keyCode) { case e.DOM_VK_UP: case e.DOM_VK_DOWN: case e.DOM_VK_LEFT: case e.DOM_VK_RIGHT: case e.DOM_VK_PAGE_UP: case e.DOM_VK_PAGE_DOWN: case e.DOM_VK_HOME: case e.DOM_VK_END: e.preventDefault(); e.stopPropagation(); } }, /** * Sets a side pane hidden or visible. * * @param object aFlags * An object containing some of the following properties: * - visible: true if the pane should be shown, false to hide * - animated: true to display an animation on toggle * - delayed: true to wait a few cycles before toggle * - callback: a function to invoke when the toggle finishes * @param nsIDOMNode aPane * The element representing the pane to toggle. */ togglePane: function(aFlags, aPane) { // Make sure a pane is actually available first. if (!aPane) { return; } // Hiding is always handled via margins, not the hidden attribute. aPane.removeAttribute("hidden"); // Add a class to the pane to handle min-widths, margins and animations. if (!aPane.classList.contains("generic-toggled-side-pane")) { aPane.classList.add("generic-toggled-side-pane"); } // Avoid useless toggles. if (aFlags.visible == !aPane.hasAttribute("pane-collapsed")) { if (aFlags.callback) aFlags.callback(); return; } // The "animated" attributes enables animated toggles (slide in-out). if (aFlags.animated) { aPane.setAttribute("animated", ""); } else { aPane.removeAttribute("animated"); } // Computes and sets the pane margins in order to hide or show it. let doToggle = () => { if (aFlags.visible) { aPane.style.marginLeft = "0"; aPane.style.marginRight = "0"; aPane.removeAttribute("pane-collapsed"); } else { let margin = ~~(aPane.getAttribute("width")) + 1; aPane.style.marginLeft = -margin + "px"; aPane.style.marginRight = -margin + "px"; aPane.setAttribute("pane-collapsed", ""); } // Invoke the callback when the transition ended. if (aFlags.animated) { aPane.addEventListener("transitionend", function onEvent() { aPane.removeEventListener("transitionend", onEvent, false); if (aFlags.callback) aFlags.callback(); }, false); } // Invoke the callback immediately since there's no transition. else { if (aFlags.callback) aFlags.callback(); } } // Sometimes it's useful delaying the toggle a few ticks to ensure // a smoother slide in-out animation. if (aFlags.delayed) { aPane.ownerDocument.defaultView.setTimeout(doToggle, PANE_APPEARANCE_DELAY); } else { doToggle(); } } }; /** * Localization convenience methods. * * @param string aStringBundleName * The desired string bundle's name. */ ViewHelpers.L10N = function(aStringBundleName) { XPCOMUtils.defineLazyGetter(this, "stringBundle", () => Services.strings.createBundle(aStringBundleName)); XPCOMUtils.defineLazyGetter(this, "ellipsis", () => Services.prefs.getComplexValue("intl.ellipsis", Ci.nsIPrefLocalizedString).data); }; ViewHelpers.L10N.prototype = { stringBundle: null, /** * L10N shortcut function. * * @param string aName * @return string */ getStr: function(aName) { return this.stringBundle.GetStringFromName(aName); }, /** * L10N shortcut function. * * @param string aName * @param array aArgs * @return string */ getFormatStr: function(aName, ...aArgs) { return this.stringBundle.formatStringFromName(aName, aArgs, aArgs.length); }, /** * L10N shortcut function for numeric arguments that need to be formatted. * All numeric arguments will be fixed to 2 decimals and given a localized * decimal separator. Other arguments will be left alone. * * @param string aName * @param array aArgs * @return string */ getFormatStrWithNumbers: function(aName, ...aArgs) { let newArgs = aArgs.map(x => typeof x == "number" ? this.numberWithDecimals(x, 2) : x); return this.stringBundle.formatStringFromName(aName, newArgs, newArgs.length); }, /** * Converts a number to a locale-aware string format and keeps a certain * number of decimals. * * @param number aNumber * The number to convert. * @param number aDecimals [optional] * Total decimals to keep. * @return string * The localized number as a string. */ numberWithDecimals: function(aNumber, aDecimals = 0) { // If this is an integer, don't do anything special. if (aNumber == (aNumber | 0)) { return aNumber; } // Remove {n} trailing decimals. Can't use toFixed(n) because // toLocaleString converts the number to a string. Also can't use // toLocaleString(, { maximumFractionDigits: n }) because it's not // implemented on OS X (bug 368838). Gross. let localized = aNumber.toLocaleString(); // localize let padded = localized + new Array(aDecimals).join("0"); // pad with zeros let match = padded.match("([^]*?\\d{" + aDecimals + "})\\d*$"); return match.pop(); } }; /** * Shortcuts for lazily accessing and setting various preferences. * Usage: * let prefs = new ViewHelpers.Prefs("root.path.to.branch", { * myIntPref: ["Int", "leaf.path.to.my-int-pref"], * myCharPref: ["Char", "leaf.path.to.my-char-pref"], * ... * }); * * prefs.myCharPref = "foo"; * let aux = prefs.myCharPref; * * @param string aPrefsRoot * The root path to the required preferences branch. * @param object aPrefsObject * An object containing { accessorName: [prefType, prefName] } keys. */ ViewHelpers.Prefs = function(aPrefsRoot = "", aPrefsObject = {}) { this.root = aPrefsRoot; for (let accessorName in aPrefsObject) { let [prefType, prefName] = aPrefsObject[accessorName]; this.map(accessorName, prefType, prefName); } }; ViewHelpers.Prefs.prototype = { /** * Helper method for getting a pref value. * * @param string aType * @param string aPrefName * @return any */ _get: function(aType, aPrefName) { if (this[aPrefName] === undefined) { this[aPrefName] = Services.prefs["get" + aType + "Pref"](aPrefName); } return this[aPrefName]; }, /** * Helper method for setting a pref value. * * @param string aType * @param string aPrefName * @param any aValue */ _set: function(aType, aPrefName, aValue) { Services.prefs["set" + aType + "Pref"](aPrefName, aValue); this[aPrefName] = aValue; }, /** * Maps a property name to a pref, defining lazy getters and setters. * * @param string aAccessorName * @param string aType * @param string aPrefName */ map: function(aAccessorName, aType, aPrefName) { Object.defineProperty(this, aAccessorName, { get: () => this._get(aType, [this.root, aPrefName].join(".")), set: (aValue) => this._set(aType, [this.root, aPrefName].join("."), aValue) }); } }; /** * A generic Item is used to describe children present in a Widget. * The label, value and description properties are necessarily strings. * Iterable via "for (let childItem in parentItem) { }". * * @param object aOwnerView * The owner view creating this item. * @param any aAttachment * Some attached primitive/object. * @param nsIDOMNode | nsIDOMDocumentFragment | array aContents [optional] * A prebuilt node, or an array containing the following properties: * - aLabel: the label displayed in the widget * - aValue: the actual internal value of the item * - aDescription: an optional description of the item */ function Item(aOwnerView, aAttachment, aContents = []) { this.ownerView = aOwnerView; this.attachment = aAttachment; let [aLabel, aValue, aDescription] = aContents; // Make sure the label and the value are always strings. this._label = aLabel + ""; this._value = aValue + ""; // Make sure the description is also a string, but only if it's available. if (aDescription !== undefined) { this._description = aDescription + ""; } // Allow the insertion of prebuilt nodes, otherwise delegate the item view // creation to a widget. if (ViewHelpers.isNode(aLabel)) { this._prebuiltTarget = aLabel; } XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map()); }; Item.prototype = { get label() this._label, get value() this._value, get description() this._description, get target() this._target, /** * Immediately appends a child item to this item. * * @param nsIDOMNode aElement * An nsIDOMNode representing the child element to append. * @param object aOptions [optional] * Additional options or flags supported by this operation: * - attachment: some attached primitive/object for the item * - attributes: a batch of attributes set to the displayed element * - finalize: function invoked when the child item is removed * @return Item * The item associated with the displayed element. */ append: function(aElement, aOptions = {}) { let item = new Item(this, aOptions.attachment); // Entangle the item with the newly inserted child node. this._entangleItem(item, this._target.appendChild(aElement)); // Handle any additional options after entangling the item. if (aOptions.attributes) { aOptions.attributes.forEach(e => item._target.setAttribute(e[0], e[1])); } if (aOptions.finalize) { item.finalize = aOptions.finalize; } // Return the item associated with the displayed element. return item; }, /** * Immediately removes the specified child item from this item. * * @param Item aItem * The item associated with the element to remove. */ remove: function(aItem) { if (!aItem) { return; } this._target.removeChild(aItem._target); this._untangleItem(aItem); }, /** * Entangles an item (model) with a displayed node element (view). * * @param Item aItem * The item describing a target element. * @param nsIDOMNode aElement * The element displaying the item. */ _entangleItem: function(aItem, aElement) { this._itemsByElement.set(aElement, aItem); aItem._target = aElement; }, /** * Untangles an item (model) from a displayed node element (view). * * @param Item aItem * The item describing a target element. */ _untangleItem: function(aItem) { if (aItem.finalize) { aItem.finalize(aItem); } for (let childItem in aItem) { aItem.remove(childItem); } this._unlinkItem(aItem); aItem._prebuiltTarget = null; aItem._target = null; }, /** * Deletes an item from the its parent's storage maps. * * @param Item aItem * The item describing a target element. */ _unlinkItem: function(aItem) { this._itemsByElement.delete(aItem._target); }, /** * Returns a string representing the object. * @return string */ toString: function() { if (this._label != "undefined" && this._value != "undefined") { return this._label + " -> " + this._value; } if (this.attachment) { return this.attachment.toString(); } return "(null)"; }, _label: "", _value: "", _description: undefined, _prebuiltTarget: null, _target: null, finalize: null, attachment: null }; /** * Some generic Widget methods handling Item instances. * Iterable via "for (let childItem in wrappedView) { }". * * Usage: * function MyView() { * this.widget = new MyWidget(document.querySelector(".my-node")); * } * * MyView.prototype = Heritage.extend(WidgetMethods, { * myMethod: function() {}, * ... * }); * * See https://gist.github.com/victorporof/5749386 for more details. * * Language: * - An "item" is an instance of an Item. * - An "element" or "node" is a nsIDOMNode. * * The supplied element node or widget can either be a , or any * other object interfacing the following methods: * - function:nsIDOMNode insertItemAt(aIndex:number, aLabel:string, aValue:string) * - function:nsIDOMNode getItemAtIndex(aIndex:number) * - function removeChild(aChild:nsIDOMNode) * - function removeAllItems() * - get:nsIDOMNode selectedItem() * - set selectedItem(aChild:nsIDOMNode) * - function getAttribute(aName:string) * - function setAttribute(aName:string, aValue:string) * - function removeAttribute(aName:string) * - function addEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) * - function removeEventListener(aName:string, aCallback:function, aBubbleFlag:boolean) * * For automagical keyboard and mouse accessibility, the element node or widget * should be an event emitter with the following events: * - "keyPress" -> (aName:string, aEvent:KeyboardEvent) * - "mousePress" -> (aName:string, aEvent:MouseEvent) */ this.WidgetMethods = { /** * Sets the element node or widget associated with this container. * @param nsIDOMNode | object aWidget */ set widget(aWidget) { this._widget = aWidget; // Can't use WeakMaps for itemsByLabel or itemsByValue because // keys are strings, and itemsByElement needs to be iterable. XPCOMUtils.defineLazyGetter(this, "_itemsByLabel", () => new Map()); XPCOMUtils.defineLazyGetter(this, "_itemsByValue", () => new Map()); XPCOMUtils.defineLazyGetter(this, "_itemsByElement", () => new Map()); XPCOMUtils.defineLazyGetter(this, "_stagedItems", () => []); // Handle internal events emitted by the widget if necessary. if (ViewHelpers.isEventEmitter(aWidget)) { aWidget.on("keyPress", this._onWidgetKeyPress.bind(this)); aWidget.on("mousePress", this._onWidgetMousePress.bind(this)); } }, /** * Gets the element node or widget associated with this container. * @return nsIDOMNode | object */ get widget() this._widget, /** * Prepares an item to be added to this container. This allows, for example, * for a large number of items to be batched up before being sorted & added. * * If the "staged" flag is *not* set to true, the item will be immediately * inserted at the correct position in this container, so that all the items * still remain sorted. This can (possibly) be much slower than batching up * multiple items. * * By default, this container assumes that all the items should be displayed * sorted by their label. This can be overridden with the "index" flag, * specifying on which position should an item be appended. The "staged" and * "index" flags are mutually exclusive, meaning that all staged items * will always be appended. * * Furthermore, this container makes sure that all the items are unique * (two items with the same label or value are not allowed) and non-degenerate * (items with "undefined" or "null" labels/values). This can, as well, be * overridden via the "relaxed" flag. * * @param nsIDOMNode | nsIDOMDocumentFragment array aContents * A prebuilt node, or an array containing the following properties: * - label: the label displayed in the container * - value: the actual internal value of the item * - description: an optional description of the item * @param object aOptions [optional] * Additional options or flags supported by this operation: * - staged: true to stage the item to be appended later * - index: specifies on which position should the item be appended * - relaxed: true if this container should allow dupes & degenerates * - attachment: some attached primitive/object for the item * - attributes: a batch of attributes set to the displayed element * - finalize: function invokde when the item is removed * @return Item * The item associated with the displayed element if an unstaged push, * undefined if the item was staged for a later commit. */ push: function(aContents, aOptions = {}) { let item = new Item(this, aOptions.attachment, aContents); // Batch the item to be added later. if (aOptions.staged) { // An ulterior commit operation will ignore any specified index. delete aOptions.index; return void this._stagedItems.push({ item: item, options: aOptions }); } // Find the target position in this container and insert the item there. if (!("index" in aOptions)) { return this._insertItemAt(this._findExpectedIndexFor(item), item, aOptions); } // Insert the item at the specified index. If negative or out of bounds, // the item will be simply appended. return this._insertItemAt(aOptions.index, item, aOptions); }, /** * Flushes all the prepared items into this container. * Any specified index on the items will be ignored. Everything is appended. * * @param object aOptions [optional] * Additional options or flags supported by this operation: * - sorted: true to sort all the items before adding them */ commit: function(aOptions = {}) { let stagedItems = this._stagedItems; // Sort the items before adding them to this container, if preferred. if (aOptions.sorted) { stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item)); } // Append the prepared items to this container. for (let { item, options } of stagedItems) { this._insertItemAt(-1, item, options); } // Recreate the temporary items list for ulterior pushes. this._stagedItems.length = 0; }, /** * Updates this container to reflect the information provided by the * currently selected item. * * @return boolean * True if a selected item was available, false otherwise. */ refresh: function() { let selectedItem = this.selectedItem; if (!selectedItem) { return false; } let { _label: label, _value: value, _description: desc } = selectedItem; this._widget.removeAttribute("notice"); this._widget.setAttribute("label", label); this._widget.setAttribute("tooltiptext", desc !== undefined ? desc : value); return true; }, /** * Immediately removes the specified item from this container. * * @param Item aItem * The item associated with the element to remove. */ remove: function(aItem) { if (!aItem) { return; } this._widget.removeChild(aItem._target); this._untangleItem(aItem); }, /** * Removes the item at the specified index from this container. * * @param number aIndex * The index of the item to remove. */ removeAt: function(aIndex) { this.remove(this.getItemAtIndex(aIndex)); }, /** * Removes all items from this container. */ empty: function() { this._preferredValue = this.selectedValue; this._widget.selectedItem = null; this._widget.removeAllItems(); this._widget.setAttribute("notice", this.emptyText); this._widget.setAttribute("label", this.emptyText); this._widget.removeAttribute("tooltiptext"); for (let [, item] of this._itemsByElement) { this._untangleItem(item); } this._itemsByLabel.clear(); this._itemsByValue.clear(); this._itemsByElement.clear(); this._stagedItems.length = 0; }, /** * The label string automatically added to this container when there are * no child nodes present. */ emptyText: "", /** * Toggles all the items in this container hidden or visible. * * This does not change the default filtering predicate, so newly inserted * items will always be visible. Use WidgetMethods.filterContents if you care. * * @param boolean aVisibleFlag * Specifies the intended visibility. */ toggleContents: function(aVisibleFlag) { for (let [element, item] of this._itemsByElement) { element.hidden = !aVisibleFlag; } }, /** * Toggles all items in this container hidden or visible based on a predicate. * * @param function aPredicate [optional] * Items are toggled according to the return value of this function, * which will become the new default filtering predicate in this container. * If unspecified, all items will be toggled visible. */ filterContents: function(aPredicate = this._currentFilterPredicate) { this._currentFilterPredicate = aPredicate; for (let [element, item] of this._itemsByElement) { element.hidden = !aPredicate(item); } }, /** * Sorts all the items in this container based on a predicate. * * @param function aPredicate [optional] * Items are sorted according to the return value of the function, * which will become the new default sorting predicate in this container. * If unspecified, all items will be sorted by their label. */ sortContents: function(aPredicate = this._currentSortPredicate) { let sortedItems = this.items.sort(this._currentSortPredicate = aPredicate); for (let i = 0, len = sortedItems.length; i < len; i++) { this.swapItems(this.getItemAtIndex(i), sortedItems[i]); } }, /** * Visually swaps two items in this container. * * @param Item aFirst * The first item to be swapped. * @param Item aSecond * The second item to be swapped. */ swapItems: function(aFirst, aSecond) { if (aFirst == aSecond) { // We're just dandy, thank you. return; } let { _prebuiltTarget: firstPrebuiltTarget, target: firstTarget } = aFirst; let { _prebuiltTarget: secondPrebuiltTarget, target: secondTarget } = aSecond; // If the two items were constructed with prebuilt nodes as DocumentFragments, // then those DocumentFragments are now empty and need to be reassembled. if (firstPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { for (let node of firstTarget.childNodes) { firstPrebuiltTarget.appendChild(node.cloneNode(true)); } } if (secondPrebuiltTarget instanceof Ci.nsIDOMDocumentFragment) { for (let node of secondTarget.childNodes) { secondPrebuiltTarget.appendChild(node.cloneNode(true)); } } // 1. Get the indices of the two items to swap. let i = this._indexOfElement(firstTarget); let j = this._indexOfElement(secondTarget); // 2. Remeber the selection index, to reselect an item, if necessary. let selectedTarget = this._widget.selectedItem; let selectedIndex = -1; if (selectedTarget == firstTarget) { selectedIndex = i; } else if (selectedTarget == secondTarget) { selectedIndex = j; } // 3. Silently nuke both items, nobody needs to know about this. this._widget.removeChild(firstTarget); this._widget.removeChild(secondTarget); this._unlinkItem(aFirst); this._unlinkItem(aSecond); // 4. Add the items again, but reversing their indices. this._insertItemAt.apply(this, i < j ? [i, aSecond] : [j, aFirst]); this._insertItemAt.apply(this, i < j ? [j, aFirst] : [i, aSecond]); // 5. Restore the previous selection, if necessary. if (selectedIndex == i) { this._widget.selectedItem = aFirst._target; } else if (selectedIndex == j) { this._widget.selectedItem = aSecond._target; } }, /** * Visually swaps two items in this container at specific indices. * * @param number aFirst * The index of the first item to be swapped. * @param number aSecond * The index of the second item to be swapped. */ swapItemsAtIndices: function(aFirst, aSecond) { this.swapItems(this.getItemAtIndex(aFirst), this.getItemAtIndex(aSecond)); }, /** * Checks whether an item with the specified label is among the elements * shown in this container. * * @param string aLabel * The item's label. * @return boolean * True if the label is known, false otherwise. */ containsLabel: function(aLabel) { return this._itemsByLabel.has(aLabel) || this._stagedItems.some(({ item }) => item._label == aLabel); }, /** * Checks whether an item with the specified value is among the elements * shown in this container. * * @param string aValue * The item's value. * @return boolean * True if the value is known, false otherwise. */ containsValue: function(aValue) { return this._itemsByValue.has(aValue) || this._stagedItems.some(({ item }) => item._value == aValue); }, /** * Gets the "preferred value". This is the latest selected item's value, * remembered just before emptying this container. * @return string */ get preferredValue() this._preferredValue, /** * Retrieves the item associated with the selected element. * @return Item */ get selectedItem() { let selectedElement = this._widget.selectedItem; if (selectedElement) { return this._itemsByElement.get(selectedElement); } return null; }, /** * Retrieves the selected element's index in this container. * @return number */ get selectedIndex() { let selectedElement = this._widget.selectedItem; if (selectedElement) { return this._indexOfElement(selectedElement); } return -1; }, /** * Retrieves the label of the selected element. * @return string */ get selectedLabel() { let selectedElement = this._widget.selectedItem; if (selectedElement) { return this._itemsByElement.get(selectedElement)._label; } return ""; }, /** * Retrieves the value of the selected element. * @return string */ get selectedValue() { let selectedElement = this._widget.selectedItem; if (selectedElement) { return this._itemsByElement.get(selectedElement)._value; } return ""; }, /** * Retrieves the attachment of the selected element. * @return string */ get selectedAttachment() { let selectedElement = this._widget.selectedItem; if (selectedElement) { return this._itemsByElement.get(selectedElement).attachment; } return null; }, /** * Selects the element with the entangled item in this container. * @param Item | function aItem */ set selectedItem(aItem) { // A predicate is allowed to select a specific item. // If no item is matched, then the current selection is removed. if (typeof aItem == "function") { aItem = this.getItemForPredicate(aItem); } // A falsy item is allowed to invalidate the current selection. let targetElement = aItem ? aItem._target : null; let prevElement = this._widget.selectedItem; // Make sure the currently selected item's target element is also focused. if (this.autoFocusOnSelection && targetElement) { targetElement.focus(); } // Prevent selecting the same item again and avoid dispatching // a redundant selection event, so return early. if (targetElement != prevElement) { this._widget.selectedItem = targetElement; ViewHelpers.dispatchEvent(targetElement || prevElement, "select", aItem); } // Updates this container to reflect the information provided by the // currently selected item. this.refresh(); }, /** * Selects the element at the specified index in this container. * @param number aIndex */ set selectedIndex(aIndex) { let targetElement = this._widget.getItemAtIndex(aIndex); if (targetElement) { this.selectedItem = this._itemsByElement.get(targetElement); return; } this.selectedItem = null; }, /** * Selects the element with the specified label in this container. * @param string aLabel */ set selectedLabel(aLabel) this.selectedItem = this._itemsByLabel.get(aLabel), /** * Selects the element with the specified value in this container. * @param string aValue */ set selectedValue(aValue) this.selectedItem = this._itemsByValue.get(aValue), /** * Focus this container the first time an element is inserted? * * If this flag is set to true, then when the first item is inserted in * this container (and thus it's the only item available), its corresponding * target element is focused as well. */ autoFocusOnFirstItem: true, /** * Focus on selection? * * If this flag is set to true, then whenever an item is selected in * this container (e.g. via the selectedIndex or selectedItem setters), * its corresponding target element is focused as well. * * You can disable this flag, for example, to maintain a certain node * focused but visually indicate a different selection in this container. */ autoFocusOnSelection: true, /** * Focus on input (e.g. mouse click)? * * If this flag is set to true, then whenever an item receives user input in * this container, its corresponding target element is focused as well. */ autoFocusOnInput: true, /** * When focusing on input, allow right clicks? * @see WidgetMethods.autoFocusOnInput */ allowFocusOnRightClick: false, /** * The number of elements in this container to jump when Page Up or Page Down * keys are pressed. If falsy, then the page size will be based on the * number of visible items in the container. */ pageSize: 0, /** * Focuses the first visible item in this container. */ focusFirstVisibleItem: function() { this.focusItemAtDelta(-this.itemCount); }, /** * Focuses the last visible item in this container. */ focusLastVisibleItem: function() { this.focusItemAtDelta(+this.itemCount); }, /** * Focuses the next item in this container. */ focusNextItem: function() { this.focusItemAtDelta(+1); }, /** * Focuses the previous item in this container. */ focusPrevItem: function() { this.focusItemAtDelta(-1); }, /** * Focuses another item in this container based on the index distance * from the currently focused item. * * @param number aDelta * A scalar specifying by how many items should the selection change. */ focusItemAtDelta: function(aDelta) { // Make sure the currently selected item is also focused, so that the // command dispatcher mechanism has a relative node to work with. // If there's no selection, just select an item at a corresponding index // (e.g. the first item in this container if aDelta <= 1). let selectedElement = this._widget.selectedItem; if (selectedElement) { selectedElement.focus(); } else { this.selectedIndex = Math.max(0, aDelta - 1); return; } let direction = aDelta > 0 ? "advanceFocus" : "rewindFocus"; let distance = Math.abs(Math[aDelta > 0 ? "ceil" : "floor"](aDelta)); while (distance--) { if (!this._focusChange(direction)) { break; // Out of bounds. } } // Synchronize the selected item as being the currently focused element. this.selectedItem = this.getItemForElement(this._focusedElement); }, /** * Focuses the next or previous item in this container. * * @param string aDirection * Either "advanceFocus" or "rewindFocus". * @return boolean * False if the focus went out of bounds and the first or last item * in this container was focused instead. */ _focusChange: function(aDirection) { let commandDispatcher = this._commandDispatcher; let prevFocusedElement = commandDispatcher.focusedElement; let currFocusedElement; do { commandDispatcher.suppressFocusScroll = true; commandDispatcher[aDirection](); currFocusedElement = commandDispatcher.focusedElement; // Make sure the newly focused item is a part of this container. If the // focus goes out of bounds, revert the previously focused item. if (!this.getItemForElement(currFocusedElement)) { prevFocusedElement.focus(); return false; } } while (!WIDGET_FOCUSABLE_NODES.has(currFocusedElement.tagName)); // Focus remained within bounds. return true; }, /** * Gets the command dispatcher instance associated with this container's DOM. * If there are no items displayed in this container, null is returned. * @return nsIDOMXULCommandDispatcher | null */ get _commandDispatcher() { if (this._cachedCommandDispatcher) { return this._cachedCommandDispatcher; } let someElement = this._widget.getItemAtIndex(0); if (someElement) { let commandDispatcher = someElement.ownerDocument.commandDispatcher; return this._cachedCommandDispatcher = commandDispatcher; } return null; }, /** * Gets the currently focused element in this container. * * @return nsIDOMNode * The focused element, or null if nothing is found. */ get _focusedElement() { let commandDispatcher = this._commandDispatcher; if (commandDispatcher) { return commandDispatcher.focusedElement; } return null; }, /** * Gets the item in the container having the specified index. * * @param number aIndex * The index used to identify the element. * @return Item * The matched item, or null if nothing is found. */ getItemAtIndex: function(aIndex) { return this.getItemForElement(this._widget.getItemAtIndex(aIndex)); }, /** * Gets the item in the container having the specified label. * * @param string aLabel * The label used to identify the element. * @return Item * The matched item, or null if nothing is found. */ getItemByLabel: function(aLabel) { return this._itemsByLabel.get(aLabel); }, /** * Gets the item in the container having the specified value. * * @param string aValue * The value used to identify the element. * @return Item * The matched item, or null if nothing is found. */ getItemByValue: function(aValue) { return this._itemsByValue.get(aValue); }, /** * Gets the item in the container associated with the specified element. * * @param nsIDOMNode aElement * The element used to identify the item. * @param object aFlags [optional] * Additional options for showing the source. Supported options: * - noSiblings: if siblings shouldn't be taken into consideration * when searching for the associated item. * @return Item * The matched item, or null if nothing is found. */ getItemForElement: function(aElement, aFlags = {}) { while (aElement) { let item = this._itemsByElement.get(aElement); // Also search the siblings if allowed. if (!aFlags.noSiblings) { item = item || this._itemsByElement.get(aElement.nextElementSibling) || this._itemsByElement.get(aElement.previousElementSibling); } if (item) { return item; } aElement = aElement.parentNode; } return null; }, /** * Gets a visible item in this container validating a specified predicate. * * @param function aPredicate * The first item which validates this predicate is returned * @return Item * The matched item, or null if nothing is found. */ getItemForPredicate: function(aPredicate, aOwner = this) { // Recursively check the items in this widget for a predicate match. for (let [element, item] of aOwner._itemsByElement) { let match; if (aPredicate(item) && !element.hidden) { match = item; } else { match = this.getItemForPredicate(aPredicate, item); } if (match) { return match; } } // Also check the staged items. No need to do this recursively since // they're not even appended to the view yet. for (let { item } of this._stagedItems) { if (aPredicate(item)) { return item; } } return null; }, /** * Finds the index of an item in the container. * * @param Item aItem * The item get the index for. * @return number * The index of the matched item, or -1 if nothing is found. */ indexOfItem: function(aItem) { return this._indexOfElement(aItem._target); }, /** * Finds the index of an element in the container. * * @param nsIDOMNode aElement * The element get the index for. * @return number * The index of the matched element, or -1 if nothing is found. */ _indexOfElement: function(aElement) { for (let i = 0; i < this._itemsByElement.size; i++) { if (this._widget.getItemAtIndex(i) == aElement) { return i; } } return -1; }, /** * Gets the total number of items in this container. * @return number */ get itemCount() this._itemsByElement.size, /** * Returns a list of items in this container, in the displayed order. * @return array */ get items() { let store = []; let itemCount = this.itemCount; for (let i = 0; i < itemCount; i++) { store.push(this.getItemAtIndex(i)); } return store; }, /** * Returns a list of labels in this container, in the displayed order. * @return array */ get labels() { return this.items.map(e => e._label); }, /** * Returns a list of values in this container, in the displayed order. * @return array */ get values() { return this.items.map(e => e._value); }, /** * Returns a list of attachments in this container, in the displayed order. * @return array */ get attachments() { return this.items.map(e => e.attachment); }, /** * Returns a list of all the visible (non-hidden) items in this container, * in the displayed order * @return array */ get visibleItems() { return this.items.filter(e => !e._target.hidden); }, /** * Specifies the required conditions for an item to be considered unique. * Possible values: * - 1: label AND value are different from all other items * - 2: label OR value are different from all other items * - 3: only label is required to be different * - 4: only value is required to be different */ uniquenessQualifier: 1, /** * Checks if an item is unique in this container. * * @param Item aItem * The item for which to verify uniqueness. * @return boolean * True if the item is unique, false otherwise. */ isUnique: function(aItem) { switch (this.uniquenessQualifier) { case 1: return !this._itemsByLabel.has(aItem._label) && !this._itemsByValue.has(aItem._value); case 2: return !this._itemsByLabel.has(aItem._label) || !this._itemsByValue.has(aItem._value); case 3: return !this._itemsByLabel.has(aItem._label); case 4: return !this._itemsByValue.has(aItem._value); } return false; }, /** * Checks if an item is eligible for this container. * * @param Item aItem * The item for which to verify eligibility. * @return boolean * True if the item is eligible, false otherwise. */ isEligible: function(aItem) { let isUnique = this.isUnique(aItem); let isPrebuilt = !!aItem._prebuiltTarget; let isDegenerate = aItem._label == "undefined" || aItem._label == "null" || aItem._value == "undefined" || aItem._value == "null"; return isPrebuilt || (isUnique && !isDegenerate); }, /** * Finds the expected item index in this container based on the default * sort predicate. * * @param Item aItem * The item for which to get the expected index. * @return number * The expected item index. */ _findExpectedIndexFor: function(aItem) { let itemCount = this.itemCount; for (let i = 0; i < itemCount; i++) { if (this._currentSortPredicate(this.getItemAtIndex(i), aItem) > 0) { return i; } } return itemCount; }, /** * Immediately inserts an item in this container at the specified index. * * @param number aIndex * The position in the container intended for this item. * @param Item aItem * An object containing a label and a value property (at least). * @param object aOptions [optional] * Additional options or flags supported by this operation: * - node: allows the insertion of prebuilt nodes instead of labels * - relaxed: true if this container should allow dupes & degenerates * - attributes: a batch of attributes set to the displayed element * - finalize: function when the item is untangled (removed) * @return Item * The item associated with the displayed element, null if rejected. */ _insertItemAt: function(aIndex, aItem, aOptions = {}) { // Relaxed nodes may be appended without verifying their eligibility. if (!aOptions.relaxed && !this.isEligible(aItem)) { return null; } // Entangle the item with the newly inserted node. this._entangleItem(aItem, this._widget.insertItemAt(aIndex, aItem._prebuiltTarget || aItem._label, // Allow the insertion of prebuilt nodes. aItem._value, aItem._description, aItem.attachment)); // Handle any additional options after entangling the item. if (!this._currentFilterPredicate(aItem)) { aItem._target.hidden = true; } if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) { aItem._target.focus(); } if (aOptions.attributes) { aOptions.attributes.forEach(e => aItem._target.setAttribute(e[0], e[1])); } if (aOptions.finalize) { aItem.finalize = aOptions.finalize; } // Return the item associated with the displayed element. return aItem; }, /** * Entangles an item (model) with a displayed node element (view). * * @param Item aItem * The item describing a target element. * @param nsIDOMNode aElement * The element displaying the item. */ _entangleItem: function(aItem, aElement) { this._itemsByLabel.set(aItem._label, aItem); this._itemsByValue.set(aItem._value, aItem); this._itemsByElement.set(aElement, aItem); aItem._target = aElement; }, /** * Untangles an item (model) from a displayed node element (view). * * @param Item aItem * The item describing a target element. */ _untangleItem: function(aItem) { if (aItem.finalize) { aItem.finalize(aItem); } for (let childItem in aItem) { aItem.remove(childItem); } this._unlinkItem(aItem); aItem._prebuiltTarget = null; aItem._target = null; }, /** * Deletes an item from the its parent's storage maps. * * @param Item aItem * The item describing a target element. */ _unlinkItem: function(aItem) { this._itemsByLabel.delete(aItem._label); this._itemsByValue.delete(aItem._value); this._itemsByElement.delete(aItem._target); }, /** * The keyPress event listener for this container. * @param string aName * @param KeyboardEvent aEvent */ _onWidgetKeyPress: function(aName, aEvent) { // Prevent scrolling when pressing navigation keys. ViewHelpers.preventScrolling(aEvent); switch (aEvent.keyCode) { case aEvent.DOM_VK_UP: case aEvent.DOM_VK_LEFT: this.focusPrevItem(); return; case aEvent.DOM_VK_DOWN: case aEvent.DOM_VK_RIGHT: this.focusNextItem(); return; case aEvent.DOM_VK_PAGE_UP: this.focusItemAtDelta(-(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); return; case aEvent.DOM_VK_PAGE_DOWN: this.focusItemAtDelta(+(this.pageSize || (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); return; case aEvent.DOM_VK_HOME: this.focusFirstVisibleItem(); return; case aEvent.DOM_VK_END: this.focusLastVisibleItem(); return; } }, /** * The mousePress event listener for this container. * @param string aName * @param MouseEvent aEvent */ _onWidgetMousePress: function(aName, aEvent) { if (aEvent.button != 0 && !this.allowFocusOnRightClick) { // Only allow left-click to trigger this event. return; } let item = this.getItemForElement(aEvent.target); if (item) { // The container is not empty and we clicked on an actual item. this.selectedItem = item; // Make sure the current event's target element is also focused. this.autoFocusOnInput && item._target.focus(); } }, /** * The predicate used when filtering items. By default, all items in this * view are visible. * * @param Item aItem * The item passing through the filter. * @return boolean * True if the item should be visible, false otherwise. */ _currentFilterPredicate: function(aItem) { return true; }, /** * The predicate used when sorting items. By default, items in this view * are sorted by their label. * * @param Item aFirst * The first item used in the comparison. * @param Item aSecond * The second item used in the comparison. * @return number * -1 to sort aFirst to a lower index than aSecond * 0 to leave aFirst and aSecond unchanged with respect to each other * 1 to sort aSecond to a lower index than aFirst */ _currentSortPredicate: function(aFirst, aSecond) { return +(aFirst._label.toLowerCase() > aSecond._label.toLowerCase()); }, /** * Call a method on this widget named `aMethodName`. Any further arguments are * passed on to the method. Returns the result of the method call. * * @param String aMethodName * The name of the method you want to call. * @param aArgs * Optional. Any arguments you want to pass through to the method. */ callMethod: function(aMethodName, ...aArgs) { return this._widget[aMethodName].apply(this._widget, aArgs); }, _widget: null, _preferredValue: null, _cachedCommandDispatcher: null }; /** * A generator-iterator over all the items in this container. */ Item.prototype.__iterator__ = WidgetMethods.__iterator__ = function() { for (let [, item] of this._itemsByElement) { yield item; } };