diff options
Diffstat (limited to 'devtools/client/shared/widgets/view-helpers.js')
-rw-r--r-- | devtools/client/shared/widgets/view-helpers.js | 1625 |
1 files changed, 1625 insertions, 0 deletions
diff --git a/devtools/client/shared/widgets/view-helpers.js b/devtools/client/shared/widgets/view-helpers.js new file mode 100644 index 000000000..4686d4e1c --- /dev/null +++ b/devtools/client/shared/widgets/view-helpers.js @@ -0,0 +1,1625 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 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 {KeyCodes} = require("devtools/client/shared/keycodes"); + +const PANE_APPEARANCE_DELAY = 50; +const PAGE_SIZE_ITEM_COUNT_RATIO = 5; +const WIDGET_FOCUSABLE_NODES = new Set(["vbox", "hbox"]); + +var namedTimeoutsStore = new Map(); + +/** + * Inheritance helpers from the addon SDK's core/heritage. + * Remove these when all devtools are loadered. + */ +exports.Heritage = { + /** + * @see extend in sdk/core/heritage. + */ + extend: function (prototype, properties = {}) { + return Object.create(prototype, this.getOwnPropertyDescriptors(properties)); + }, + + /** + * @see getOwnPropertyDescriptors in sdk/core/heritage. + */ + getOwnPropertyDescriptors: function (object) { + return Object.getOwnPropertyNames(object).reduce((descriptor, name) => { + descriptor[name] = Object.getOwnPropertyDescriptor(object, name); + return descriptor; + }, {}); + } +}; + +/** + * Helper for draining a rapid succession of events and invoking a callback + * once everything settles down. + * + * @param string id + * A string identifier for the named timeout. + * @param number wait + * The amount of milliseconds to wait after no more events are fired. + * @param function callback + * Invoked when no more events are fired after the specified time. + */ +const setNamedTimeout = function setNamedTimeout(id, wait, callback) { + clearNamedTimeout(id); + + namedTimeoutsStore.set(id, setTimeout(() => + namedTimeoutsStore.delete(id) && callback(), wait)); +}; +exports.setNamedTimeout = setNamedTimeout; + +/** + * Clears a named timeout. + * @see setNamedTimeout + * + * @param string id + * A string identifier for the named timeout. + */ +const clearNamedTimeout = function clearNamedTimeout(id) { + if (!namedTimeoutsStore) { + return; + } + clearTimeout(namedTimeoutsStore.get(id)); + namedTimeoutsStore.delete(id); +}; +exports.clearNamedTimeout = clearNamedTimeout; + +/** + * Same as `setNamedTimeout`, but invokes the callback only if the provided + * predicate function returns true. Otherwise, the timeout is re-triggered. + * + * @param string id + * A string identifier for the conditional timeout. + * @param number wait + * The amount of milliseconds to wait after no more events are fired. + * @param function predicate + * The predicate function used to determine whether the timeout restarts. + * @param function callback + * Invoked when no more events are fired after the specified time, and + * the provided predicate function returns true. + */ +const setConditionalTimeout = function setConditionalTimeout(id, wait, + predicate, + callback) { + setNamedTimeout(id, wait, function maybeCallback() { + if (predicate()) { + callback(); + return; + } + setConditionalTimeout(id, wait, predicate, callback); + }); +}; +exports.setConditionalTimeout = setConditionalTimeout; + +/** + * Clears a conditional timeout. + * @see setConditionalTimeout + * + * @param string id + * A string identifier for the conditional timeout. + */ +const clearConditionalTimeout = function clearConditionalTimeout(id) { + clearNamedTimeout(id); +}; +exports.clearConditionalTimeout = clearConditionalTimeout; + +/** + * Helpers for creating and messaging between UI components. + */ +const ViewHelpers = exports.ViewHelpers = { + /** + * Convenience method, dispatching a custom event. + * + * @param nsIDOMNode target + * A custom target element to dispatch the event from. + * @param string type + * The name of the event. + * @param any detail + * The data passed when initializing the event. + * @return boolean + * True if the event was cancelled or a registered handler + * called preventDefault. + */ + dispatchEvent: function (target, type, detail) { + if (!(target instanceof Node)) { + // Event cancelled. + return true; + } + let document = target.ownerDocument || target; + let dispatcher = target.ownerDocument ? target : document.documentElement; + + let event = document.createEvent("CustomEvent"); + event.initCustomEvent(type, true, true, detail); + return dispatcher.dispatchEvent(event); + }, + + /** + * Helper delegating some of the DOM attribute methods of a node to a widget. + * + * @param object widget + * The widget to assign the methods to. + * @param nsIDOMNode node + * A node to delegate the methods to. + */ + delegateWidgetAttributeMethods: function (widget, node) { + widget.getAttribute = + widget.getAttribute || node.getAttribute.bind(node); + widget.setAttribute = + widget.setAttribute || node.setAttribute.bind(node); + widget.removeAttribute = + widget.removeAttribute || node.removeAttribute.bind(node); + }, + + /** + * Helper delegating some of the DOM event methods of a node to a widget. + * + * @param object widget + * The widget to assign the methods to. + * @param nsIDOMNode node + * A node to delegate the methods to. + */ + delegateWidgetEventMethods: function (widget, node) { + widget.addEventListener = + widget.addEventListener || node.addEventListener.bind(node); + widget.removeEventListener = + widget.removeEventListener || node.removeEventListener.bind(node); + }, + + /** + * 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 (object) { + return object && object.on && object.off && object.once && object.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 (object) { + return object instanceof Node || + object instanceof Element || + object instanceof DocumentFragment; + }, + + /** + * Prevents event propagation when navigation keys are pressed. + * + * @param Event e + * The event to be prevented. + */ + preventScrolling: function (e) { + switch (e.keyCode) { + case KeyCodes.DOM_VK_UP: + case KeyCodes.DOM_VK_DOWN: + case KeyCodes.DOM_VK_LEFT: + case KeyCodes.DOM_VK_RIGHT: + case KeyCodes.DOM_VK_PAGE_UP: + case KeyCodes.DOM_VK_PAGE_DOWN: + case KeyCodes.DOM_VK_HOME: + case KeyCodes.DOM_VK_END: + e.preventDefault(); + e.stopPropagation(); + } + }, + + /** + * Check if the enter key or space was pressed + * + * @param event event + * The event triggered by a keypress on an element + */ + isSpaceOrReturn: function (event) { + return event.keyCode === KeyCodes.DOM_VK_SPACE || + event.keyCode === KeyCodes.DOM_VK_RETURN; + }, + + /** + * Sets a toggled pane hidden or visible. The pane can either be displayed on + * the side (right or left depending on the locale) or at the bottom. + * + * @param object flags + * 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 pane + * The element representing the pane to toggle. + */ + togglePane: function (flags, pane) { + // Make sure a pane is actually available first. + if (!pane) { + return; + } + + // Hiding is always handled via margins, not the hidden attribute. + pane.removeAttribute("hidden"); + + // Add a class to the pane to handle min-widths, margins and animations. + pane.classList.add("generic-toggled-pane"); + + // Avoid toggles in the middle of animation. + if (pane.hasAttribute("animated")) { + return; + } + + // Avoid useless toggles. + if (flags.visible == !pane.classList.contains("pane-collapsed")) { + if (flags.callback) { + flags.callback(); + } + return; + } + + // The "animated" attributes enables animated toggles (slide in-out). + if (flags.animated) { + pane.setAttribute("animated", ""); + } else { + pane.removeAttribute("animated"); + } + + // Computes and sets the pane margins in order to hide or show it. + let doToggle = () => { + // Negative margins are applied to "right" and "left" to support RTL and + // LTR directions, as well as to "bottom" to support vertical layouts. + // Unnecessary negative margins are forced to 0 via CSS in widgets.css. + if (flags.visible) { + pane.style.marginLeft = "0"; + pane.style.marginRight = "0"; + pane.style.marginBottom = "0"; + pane.classList.remove("pane-collapsed"); + } else { + let width = Math.floor(pane.getAttribute("width")) + 1; + let height = Math.floor(pane.getAttribute("height")) + 1; + pane.style.marginLeft = -width + "px"; + pane.style.marginRight = -width + "px"; + pane.style.marginBottom = -height + "px"; + } + + // Wait for the animation to end before calling afterToggle() + if (flags.animated) { + let options = { + useCapture: false, + once: true + }; + + pane.addEventListener("transitionend", () => { + // Prevent unwanted transitions: if the panel is hidden and the layout + // changes margins will be updated and the panel will pop out. + pane.removeAttribute("animated"); + + if (!flags.visible) { + pane.classList.add("pane-collapsed"); + } + if (flags.callback) { + flags.callback(); + } + }, options); + } else { + if (!flags.visible) { + pane.classList.add("pane-collapsed"); + } + + // Invoke the callback immediately since there's no transition. + if (flags.callback) { + flags.callback(); + } + } + }; + + // Sometimes it's useful delaying the toggle a few ticks to ensure + // a smoother slide in-out animation. + if (flags.delayed) { + pane.ownerDocument.defaultView.setTimeout(doToggle, + PANE_APPEARANCE_DELAY); + } else { + doToggle(); + } + } +}; + +/** + * A generic Item is used to describe children present in a Widget. + * + * This is basically a very thin wrapper around an nsIDOMNode, with a few + * characteristics, like a `value` and an `attachment`. + * + * The characteristics are optional, and their meaning is entirely up to you. + * - The `value` should be a string, passed as an argument. + * - The `attachment` is any kind of primitive or object, passed as an argument. + * + * Iterable via "for (let childItem of parentItem) { }". + * + * @param object ownerView + * The owner view creating this item. + * @param nsIDOMNode element + * A prebuilt node to be wrapped. + * @param string value + * A string identifying the node. + * @param any attachment + * Some attached primitive/object. + */ +function Item(ownerView, element, value, attachment) { + this.ownerView = ownerView; + this.attachment = attachment; + this._value = value + ""; + this._prebuiltNode = element; + this._itemsByElement = new Map(); +} + +Item.prototype = { + get value() { + return this._value; + }, + get target() { + return this._target; + }, + get prebuiltNode() { + return this._prebuiltNode; + }, + + /** + * Immediately appends a child item to this item. + * + * @param nsIDOMNode element + * An nsIDOMNode representing the child element to append. + * @param object options [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 (element, options = {}) { + let item = new Item(this, element, "", options.attachment); + + // Entangle the item with the newly inserted child node. + // Make sure this is done with the value returned by appendChild(), + // to avoid storing a potential DocumentFragment. + this._entangleItem(item, this._target.appendChild(element)); + + // Handle any additional options after entangling the item. + if (options.attributes) { + options.attributes.forEach(e => item._target.setAttribute(e[0], e[1])); + } + if (options.finalize) { + item.finalize = options.finalize; + } + + // Return the item associated with the displayed element. + return item; + }, + + /** + * Immediately removes the specified child item from this item. + * + * @param Item item + * The item associated with the element to remove. + */ + remove: function (item) { + if (!item) { + return; + } + this._target.removeChild(item._target); + this._untangleItem(item); + }, + + /** + * Entangles an item (model) with a displayed node element (view). + * + * @param Item item + * The item describing a target element. + * @param nsIDOMNode element + * The element displaying the item. + */ + _entangleItem: function (item, element) { + this._itemsByElement.set(element, item); + item._target = element; + }, + + /** + * Untangles an item (model) from a displayed node element (view). + * + * @param Item item + * The item describing a target element. + */ + _untangleItem: function (item) { + if (item.finalize) { + item.finalize(item); + } + for (let childItem of item) { + item.remove(childItem); + } + + this._unlinkItem(item); + item._target = null; + }, + + /** + * Deletes an item from the its parent's storage maps. + * + * @param Item item + * The item describing a target element. + */ + _unlinkItem: function (item) { + this._itemsByElement.delete(item._target); + }, + + /** + * Returns a string representing the object. + * Avoid using `toString` to avoid accidental JSONification. + * @return string + */ + stringify: function () { + return JSON.stringify({ + value: this._value, + target: this._target + "", + prebuiltNode: this._prebuiltNode + "", + attachment: this.attachment + }, null, 2); + }, + + _value: "", + _target: null, + _prebuiltNode: null, + finalize: null, + attachment: null +}; + +/** + * Some generic Widget methods handling Item instances. + * Iterable via "for (let childItem of 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. + * The devtools/shared/widgets/SimpleListWidget.jsm is an implementation + * example. + * + * Language: + * - An "item" is an instance of an Item. + * - An "element" or "node" is a nsIDOMNode. + * + * The supplied widget can be any object implementing the following + * methods: + * - function:nsIDOMNode insertItemAt(aIndex:number, aNode:nsIDOMNode, + * 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) + * + * Optional methods that can be implemented by the widget: + * - function ensureElementIsVisible(aChild:nsIDOMNode) + * + * Optional attributes that may be handled (when calling + * get/set/removeAttribute): + * - "emptyText": label temporarily added when there are no items present + * - "headerText": label permanently added as a header + * + * For automagical keyboard and mouse accessibility, the widget should be an + * event emitter with the following events: + * - "keyPress" -> (aName:string, aEvent:KeyboardEvent) + * - "mousePress" -> (aName:string, aEvent:MouseEvent) + */ +const WidgetMethods = exports.WidgetMethods = { + /** + * Sets the element node or widget associated with this container. + * @param nsIDOMNode | object widget + */ + set widget(widget) { + this._widget = widget; + + // Can't use a WeakMap for _itemsByValue because keys are strings, and + // can't use one for _itemsByElement either, since it needs to be iterable. + this._itemsByValue = new Map(); + this._itemsByElement = new Map(); + this._stagedItems = []; + + // Handle internal events emitted by the widget if necessary. + if (ViewHelpers.isEventEmitter(widget)) { + widget.on("keyPress", this._onWidgetKeyPress.bind(this)); + widget.on("mousePress", this._onWidgetMousePress.bind(this)); + } + }, + + /** + * Gets the element node or widget associated with this container. + * @return nsIDOMNode | object + */ + get widget() { + return 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 value. 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. + * + * @param nsIDOMNode element + * A prebuilt node to be wrapped. + * @param string value + * A string identifying the node. + * @param object options [optional] + * Additional options or flags supported by this operation: + * - attachment: some attached primitive/object for the item + * - staged: true to stage the item to be appended later + * - index: specifies on which position should the item be appended + * - attributes: a batch of attributes set to the displayed element + * - finalize: function invoked 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 ([element, value], options = {}) { + let item = new Item(this, element, value, options.attachment); + + // Batch the item to be added later. + if (options.staged) { + // An ulterior commit operation will ignore any specified index, so + // no reason to keep it around. + options.index = undefined; + return void this._stagedItems.push({ item: item, options: options }); + } + // Find the target position in this container and insert the item there. + if (!("index" in options)) { + return this._insertItemAt(this._findExpectedIndexFor(item), item, + options); + } + // Insert the item at the specified index. If negative or out of bounds, + // the item will be simply appended. + return this._insertItemAt(options.index, item, options); + }, + + /** + * Flushes all the prepared items into this container. + * Any specified index on the items will be ignored. Everything is appended. + * + * @param object options [optional] + * Additional options or flags supported by this operation: + * - sorted: true to sort all the items before adding them + */ + commit: function (options = {}) { + let stagedItems = this._stagedItems; + + // Sort the items before adding them to this container, if preferred. + if (options.sorted) { + stagedItems.sort((a, b) => this._currentSortPredicate(a.item, b.item)); + } + // Append the prepared items to this container. + for (let { item, opt } of stagedItems) { + this._insertItemAt(-1, item, opt); + } + // Recreate the temporary items list for ulterior pushes. + this._stagedItems.length = 0; + }, + + /** + * Immediately removes the specified item from this container. + * + * @param Item item + * The item associated with the element to remove. + */ + remove: function (item) { + if (!item) { + return; + } + this._widget.removeChild(item._target); + this._untangleItem(item); + + if (!this._itemsByElement.size) { + this._preferredValue = this.selectedValue; + this._widget.selectedItem = null; + this._widget.setAttribute("emptyText", this._emptyText); + } + }, + + /** + * Removes the item at the specified index from this container. + * + * @param number index + * The index of the item to remove. + */ + removeAt: function (index) { + this.remove(this.getItemAtIndex(index)); + }, + + /** + * Removes the items in this container based on a predicate. + */ + removeForPredicate: function (predicate) { + let item; + while ((item = this.getItemForPredicate(predicate))) { + this.remove(item); + } + }, + + /** + * Removes all items from this container. + */ + empty: function () { + this._preferredValue = this.selectedValue; + this._widget.selectedItem = null; + this._widget.removeAllItems(); + this._widget.setAttribute("emptyText", this._emptyText); + + for (let [, item] of this._itemsByElement) { + this._untangleItem(item); + } + + this._itemsByValue.clear(); + this._itemsByElement.clear(); + this._stagedItems.length = 0; + }, + + /** + * Ensures the specified item is visible in this container. + * + * @param Item item + * The item to bring into view. + */ + ensureItemIsVisible: function (item) { + this._widget.ensureElementIsVisible(item._target); + }, + + /** + * Ensures the item at the specified index is visible in this container. + * + * @param number index + * The index of the item to bring into view. + */ + ensureIndexIsVisible: function (index) { + this.ensureItemIsVisible(this.getItemAtIndex(index)); + }, + + /** + * Sugar for ensuring the selected item is visible in this container. + */ + ensureSelectedItemIsVisible: function () { + this.ensureItemIsVisible(this.selectedItem); + }, + + /** + * If supported by the widget, the label string temporarily added to this + * container when there are no child items present. + */ + set emptyText(value) { + this._emptyText = value; + + // Apply the emptyText attribute right now if there are no child items. + if (!this._itemsByElement.size) { + this._widget.setAttribute("emptyText", value); + } + }, + + /** + * If supported by the widget, the label string permanently added to this + * container as a header. + * @param string value + */ + set headerText(value) { + this._headerText = value; + this._widget.setAttribute("headerText", value); + }, + + /** + * 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 visibleFlag + * Specifies the intended visibility. + */ + toggleContents: function (visibleFlag) { + for (let [element] of this._itemsByElement) { + element.hidden = !visibleFlag; + } + }, + + /** + * Toggles all items in this container hidden or visible based on a predicate. + * + * @param function predicate [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 (predicate = this._currentFilterPredicate) { + this._currentFilterPredicate = predicate; + + for (let [element, item] of this._itemsByElement) { + element.hidden = !predicate(item); + } + }, + + /** + * Sorts all the items in this container based on a predicate. + * + * @param function predicate [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 value. + */ + sortContents: function (predicate = this._currentSortPredicate) { + let sortedItems = this.items.sort(this._currentSortPredicate = predicate); + + 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 first + * The first item to be swapped. + * @param Item second + * The second item to be swapped. + */ + swapItems: function (first, second) { + if (first == second) { + // We're just dandy, thank you. + return; + } + let { _prebuiltNode: firstPrebuiltTarget, _target: firstTarget } = first; + let { _prebuiltNode: secondPrebuiltTarget, _target: secondTarget } = second; + + // 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 DocumentFragment) { + for (let node of firstTarget.childNodes) { + firstPrebuiltTarget.appendChild(node.cloneNode(true)); + } + } + if (secondPrebuiltTarget instanceof DocumentFragment) { + 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(first); + this._unlinkItem(second); + + // 4. Add the items again, but reversing their indices. + this._insertItemAt.apply(this, i < j ? [i, second] : [j, first]); + this._insertItemAt.apply(this, i < j ? [j, first] : [i, second]); + + // 5. Restore the previous selection, if necessary. + if (selectedIndex == i) { + this._widget.selectedItem = first._target; + } else if (selectedIndex == j) { + this._widget.selectedItem = second._target; + } + + // 6. Let the outside world know that these two items were swapped. + ViewHelpers.dispatchEvent(first.target, "swap", [second, first]); + }, + + /** + * Visually swaps two items in this container at specific indices. + * + * @param number first + * The index of the first item to be swapped. + * @param number second + * The index of the second item to be swapped. + */ + swapItemsAtIndices: function (first, second) { + this.swapItems(this.getItemAtIndex(first), this.getItemAtIndex(second)); + }, + + /** + * Checks whether an item with the specified value is among the elements + * shown in this container. + * + * @param string value + * The item's value. + * @return boolean + * True if the value is known, false otherwise. + */ + containsValue: function (value) { + return this._itemsByValue.has(value) || + this._stagedItems.some(({ item }) => item._value == value); + }, + + /** + * Gets the "preferred value". This is the latest selected item's value, + * remembered just before emptying this container. + * @return string + */ + get preferredValue() { + return this._preferredValue; + }, + + /** + * Retrieves the item associated with the selected element. + * @return Item | null + */ + 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 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 object | null + */ + get selectedAttachment() { + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + return this._itemsByElement.get(selectedElement).attachment; + } + return null; + }, + + _selectItem: function (item) { + // A falsy item is allowed to invalidate the current selection. + let targetElement = item ? item._target : null; + let prevElement = this._widget.selectedItem; + + // Make sure the selected item's target element is focused and visible. + if (this.autoFocusOnSelection && targetElement) { + targetElement.focus(); + } + + if (targetElement != prevElement) { + this._widget.selectedItem = targetElement; + } + }, + + /** + * Selects the element with the entangled item in this container. + * @param Item | function item + */ + set selectedItem(item) { + // A predicate is allowed to select a specific item. + // If no item is matched, then the current selection is removed. + if (typeof item == "function") { + item = this.getItemForPredicate(item); + } + + let targetElement = item ? item._target : null; + let prevElement = this._widget.selectedItem; + + if (this.maintainSelectionVisible && targetElement) { + // Some methods are optional. See the WidgetMethods object documentation + // for a comprehensive list. + if ("ensureElementIsVisible" in this._widget) { + this._widget.ensureElementIsVisible(targetElement); + } + } + + this._selectItem(item); + + // Prevent selecting the same item again and avoid dispatching + // a redundant selection event, so return early. + if (targetElement != prevElement) { + let dispTarget = targetElement || prevElement; + let dispName = this.suppressSelectionEvents ? "suppressed-select" + : "select"; + ViewHelpers.dispatchEvent(dispTarget, dispName, item); + } + }, + + /** + * Selects the element at the specified index in this container. + * @param number index + */ + set selectedIndex(index) { + let targetElement = this._widget.getItemAtIndex(index); + if (targetElement) { + this.selectedItem = this._itemsByElement.get(targetElement); + return; + } + this.selectedItem = null; + }, + + /** + * Selects the element with the specified value in this container. + * @param string value + */ + set selectedValue(value) { + this.selectedItem = this._itemsByValue.get(value); + }, + + /** + * Deselects and re-selects an item in this container. + * + * Useful when you want a "select" event to be emitted, even though + * the specified item was already selected. + * + * @param Item | function item + * @see `set selectedItem` + */ + forceSelect: function (item) { + this.selectedItem = null; + this.selectedItem = item; + }, + + /** + * Specifies if this container should try to keep the selected item visible. + * (For example, when new items are added the selection is brought into view). + */ + maintainSelectionVisible: true, + + /** + * Specifies if "select" events dispatched from the elements in this container + * when their respective items are selected should be suppressed or not. + * + * If this flag is set to true, then consumers of this container won't + * be normally notified when items are selected. + */ + suppressSelectionEvents: false, + + /** + * 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 delta + * A scalar specifying by how many items should the selection change. + */ + focusItemAtDelta: function (delta) { + // 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 delta <= 1). + let selectedElement = this._widget.selectedItem; + if (selectedElement) { + selectedElement.focus(); + } else { + this.selectedIndex = Math.max(0, delta - 1); + return; + } + + let direction = delta > 0 ? "advanceFocus" : "rewindFocus"; + let distance = Math.abs(Math[delta > 0 ? "ceil" : "floor"](delta)); + while (distance--) { + if (!this._focusChange(direction)) { + // Out of bounds. + break; + } + } + + // 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 direction + * 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 (direction) { + let commandDispatcher = this._commandDispatcher; + let prevFocusedElement = commandDispatcher.focusedElement; + let currFocusedElement; + + do { + commandDispatcher.suppressFocusScroll = true; + commandDispatcher[direction](); + 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; + this._cachedCommandDispatcher = commandDispatcher; + return 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 index + * The index used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemAtIndex: function (index) { + return this.getItemForElement(this._widget.getItemAtIndex(index)); + }, + + /** + * Gets the item in the container having the specified value. + * + * @param string value + * The value used to identify the element. + * @return Item + * The matched item, or null if nothing is found. + */ + getItemByValue: function (value) { + return this._itemsByValue.get(value); + }, + + /** + * Gets the item in the container associated with the specified element. + * + * @param nsIDOMNode element + * The element used to identify the item. + * @param object flags [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 (element, flags = {}) { + while (element) { + let item = this._itemsByElement.get(element); + + // Also search the siblings if allowed. + if (!flags.noSiblings) { + item = item || + this._itemsByElement.get(element.nextElementSibling) || + this._itemsByElement.get(element.previousElementSibling); + } + if (item) { + return item; + } + element = element.parentNode; + } + return null; + }, + + /** + * Gets a visible item in this container validating a specified predicate. + * + * @param function predicate + * The first item which validates this predicate is returned + * @return Item + * The matched item, or null if nothing is found. + */ + getItemForPredicate: function (predicate, owner = this) { + // Recursively check the items in this widget for a predicate match. + for (let [element, item] of owner._itemsByElement) { + let match; + if (predicate(item) && !element.hidden) { + match = item; + } else { + match = this.getItemForPredicate(predicate, 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 (predicate(item)) { + return item; + } + } + return null; + }, + + /** + * Shortcut function for getItemForPredicate which works on item attachments. + * @see getItemForPredicate + */ + getItemForAttachment: function (predicate, owner = this) { + return this.getItemForPredicate(e => predicate(e.attachment)); + }, + + /** + * Finds the index of an item in the container. + * + * @param Item item + * The item get the index for. + * @return number + * The index of the matched item, or -1 if nothing is found. + */ + indexOfItem: function (item) { + return this._indexOfElement(item._target); + }, + + /** + * Finds the index of an element in the container. + * + * @param nsIDOMNode element + * The element get the index for. + * @return number + * The index of the matched element, or -1 if nothing is found. + */ + _indexOfElement: function (element) { + for (let i = 0; i < this._itemsByElement.size; i++) { + if (this._widget.getItemAtIndex(i) == element) { + return i; + } + } + return -1; + }, + + /** + * Gets the total number of items in this container. + * @return number + */ + get itemCount() { + return 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 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); + }, + + /** + * Checks if an item is unique in this container. If an item's value is an + * empty string, "undefined" or "null", it is considered unique. + * + * @param Item item + * The item for which to verify uniqueness. + * @return boolean + * True if the item is unique, false otherwise. + */ + isUnique: function (item) { + let value = item._value; + if (value == "" || value == "undefined" || value == "null") { + return true; + } + return !this._itemsByValue.has(value); + }, + + /** + * Checks if an item is eligible for this container. By default, this checks + * whether an item is unique and has a prebuilt target node. + * + * @param Item item + * The item for which to verify eligibility. + * @return boolean + * True if the item is eligible, false otherwise. + */ + isEligible: function (item) { + return this.isUnique(item) && item._prebuiltNode; + }, + + /** + * Finds the expected item index in this container based on the default + * sort predicate. + * + * @param Item item + * The item for which to get the expected index. + * @return number + * The expected item index. + */ + _findExpectedIndexFor: function (item) { + let itemCount = this.itemCount; + for (let i = 0; i < itemCount; i++) { + if (this._currentSortPredicate(this.getItemAtIndex(i), item) > 0) { + return i; + } + } + return itemCount; + }, + + /** + * Immediately inserts an item in this container at the specified index. + * + * @param number index + * The position in the container intended for this item. + * @param Item item + * The item describing a target element. + * @param object options [optional] + * Additional options or flags supported by this operation: + * - 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 (index, item, options = {}) { + if (!this.isEligible(item)) { + return null; + } + + // Entangle the item with the newly inserted node. + // Make sure this is done with the value returned by insertItemAt(), + // to avoid storing a potential DocumentFragment. + let node = item._prebuiltNode; + let attachment = item.attachment; + this._entangleItem(item, + this._widget.insertItemAt(index, node, attachment)); + + // Handle any additional options after entangling the item. + if (!this._currentFilterPredicate(item)) { + item._target.hidden = true; + } + if (this.autoFocusOnFirstItem && this._itemsByElement.size == 1) { + item._target.focus(); + } + if (options.attributes) { + options.attributes.forEach(e => item._target.setAttribute(e[0], e[1])); + } + if (options.finalize) { + item.finalize = options.finalize; + } + + // Hide the empty text if the selection wasn't lost. + this._widget.removeAttribute("emptyText"); + + // Return the item associated with the displayed element. + return item; + }, + + /** + * Entangles an item (model) with a displayed node element (view). + * + * @param Item item + * The item describing a target element. + * @param nsIDOMNode element + * The element displaying the item. + */ + _entangleItem: function (item, element) { + this._itemsByValue.set(item._value, item); + this._itemsByElement.set(element, item); + item._target = element; + }, + + /** + * Untangles an item (model) from a displayed node element (view). + * + * @param Item item + * The item describing a target element. + */ + _untangleItem: function (item) { + if (item.finalize) { + item.finalize(item); + } + for (let childItem of item) { + item.remove(childItem); + } + + this._unlinkItem(item); + item._target = null; + }, + + /** + * Deletes an item from the its parent's storage maps. + * + * @param Item item + * The item describing a target element. + */ + _unlinkItem: function (item) { + this._itemsByValue.delete(item._value); + this._itemsByElement.delete(item._target); + }, + + /** + * The keyPress event listener for this container. + * @param string name + * @param KeyboardEvent event + */ + _onWidgetKeyPress: function (name, event) { + // Prevent scrolling when pressing navigation keys. + ViewHelpers.preventScrolling(event); + + switch (event.keyCode) { + case KeyCodes.DOM_VK_UP: + case KeyCodes.DOM_VK_LEFT: + this.focusPrevItem(); + return; + case KeyCodes.DOM_VK_DOWN: + case KeyCodes.DOM_VK_RIGHT: + this.focusNextItem(); + return; + case KeyCodes.DOM_VK_PAGE_UP: + this.focusItemAtDelta(-(this.pageSize || + (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); + return; + case KeyCodes.DOM_VK_PAGE_DOWN: + this.focusItemAtDelta(+(this.pageSize || + (this.itemCount / PAGE_SIZE_ITEM_COUNT_RATIO))); + return; + case KeyCodes.DOM_VK_HOME: + this.focusFirstVisibleItem(); + return; + case KeyCodes.DOM_VK_END: + this.focusLastVisibleItem(); + return; + } + }, + + /** + * The mousePress event listener for this container. + * @param string name + * @param MouseEvent event + */ + _onWidgetMousePress: function (name, event) { + if (event.button != 0 && !this.allowFocusOnRightClick) { + // Only allow left-click to trigger this event. + return; + } + + let item = this.getItemForElement(event.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 item + * The item passing through the filter. + * @return boolean + * True if the item should be visible, false otherwise. + */ + _currentFilterPredicate: function (item) { + return true; + }, + + /** + * The predicate used when sorting items. By default, items in this view + * are sorted by their label. + * + * @param Item first + * The first item used in the comparison. + * @param Item second + * The second item used in the comparison. + * @return number + * -1 to sort first to a lower index than second + * 0 to leave first and second unchanged with respect to each other + * 1 to sort second to a lower index than first + */ + _currentSortPredicate: function (first, second) { + return +(first._value.toLowerCase() > second._value.toLowerCase()); + }, + + /** + * Call a method on this widget named `methodName`. Any further arguments are + * passed on to the method. Returns the result of the method call. + * + * @param String methodName + * The name of the method you want to call. + * @param args + * Optional. Any arguments you want to pass through to the method. + */ + callMethod: function (methodName, ...args) { + return this._widget[methodName].apply(this._widget, args); + }, + + _widget: null, + _emptyText: "", + _headerText: "", + _preferredValue: "", + _cachedCommandDispatcher: null +}; + +/** + * A generator-iterator over all the items in this container. + */ +Item.prototype[Symbol.iterator] = +WidgetMethods[Symbol.iterator] = function* () { + yield* this._itemsByElement.values(); +}; |