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