Mypal/devtools/client/shared/widgets/view-helpers.js
2021-02-04 16:48:36 +02:00

1625 lines
49 KiB
JavaScript

/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* 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();
};