summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/autocomplete-popup.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/autocomplete-popup.js')
-rw-r--r--devtools/client/shared/autocomplete-popup.js599
1 files changed, 599 insertions, 0 deletions
diff --git a/devtools/client/shared/autocomplete-popup.js b/devtools/client/shared/autocomplete-popup.js
new file mode 100644
index 000000000..1d24c948e
--- /dev/null
+++ b/devtools/client/shared/autocomplete-popup.js
@@ -0,0 +1,599 @@
+/* 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 HTML_NS = "http://www.w3.org/1999/xhtml";
+const Services = require("Services");
+const {gDevTools} = require("devtools/client/framework/devtools");
+const {HTMLTooltip} = require("devtools/client/shared/widgets/tooltip/HTMLTooltip");
+const EventEmitter = require("devtools/shared/event-emitter");
+
+let itemIdCounter = 0;
+/**
+ * Autocomplete popup UI implementation.
+ *
+ * @constructor
+ * @param {Document} toolboxDoc
+ * The toolbox document to attach the autocomplete popup panel.
+ * @param {Object} options
+ * An object consiting any of the following options:
+ * - listId {String} The id for the list <LI> element.
+ * - position {String} The position for the tooltip ("top" or "bottom").
+ * - theme {String} String related to the theme of the popup
+ * - autoSelect {Boolean} Boolean to allow the first entry of the popup
+ * panel to be automatically selected when the popup shows.
+ * - onSelect {String} Callback called when the selected index is updated.
+ * - onClick {String} Callback called when the autocomplete popup receives a click
+ * event. The selectedIndex will already be updated if need be.
+ */
+function AutocompletePopup(toolboxDoc, options = {}) {
+ EventEmitter.decorate(this);
+
+ this._document = toolboxDoc;
+
+ this.autoSelect = options.autoSelect || false;
+ this.position = options.position || "bottom";
+ let theme = options.theme || "dark";
+
+ this.onSelectCallback = options.onSelect;
+ this.onClickCallback = options.onClick;
+
+ // If theme is auto, use the devtools.theme pref
+ if (theme === "auto") {
+ theme = Services.prefs.getCharPref("devtools.theme");
+ this.autoThemeEnabled = true;
+ // Setup theme change listener.
+ this._handleThemeChange = this._handleThemeChange.bind(this);
+ gDevTools.on("pref-changed", this._handleThemeChange);
+ }
+
+ // Create HTMLTooltip instance
+ this._tooltip = new HTMLTooltip(this._document);
+ this._tooltip.panel.classList.add(
+ "devtools-autocomplete-popup",
+ "devtools-monospace",
+ theme + "-theme");
+ // Stop this appearing as an alert to accessibility.
+ this._tooltip.panel.setAttribute("role", "presentation");
+
+ this._list = this._document.createElementNS(HTML_NS, "ul");
+ this._list.setAttribute("flex", "1");
+
+ // The list clone will be inserted in the same document as the anchor, and will receive
+ // a copy of the main list innerHTML to allow screen readers to access the list.
+ this._listClone = this._document.createElementNS(HTML_NS, "ul");
+ this._listClone.className = "devtools-autocomplete-list-aria-clone";
+
+ if (options.listId) {
+ this._list.setAttribute("id", options.listId);
+ }
+ this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
+
+ this._tooltip.setContent(this._list);
+
+ this.onClick = this.onClick.bind(this);
+ this._list.addEventListener("click", this.onClick, false);
+
+ // Array of raw autocomplete items
+ this.items = [];
+ // Map of autocompleteItem to HTMLElement
+ this.elements = new WeakMap();
+
+ this.selectedIndex = -1;
+}
+exports.AutocompletePopup = AutocompletePopup;
+
+AutocompletePopup.prototype = {
+ _document: null,
+ _tooltip: null,
+ _list: null,
+
+ onSelect: function (e) {
+ if (this.onSelectCallback) {
+ this.onSelectCallback(e);
+ }
+ },
+
+ onClick: function (e) {
+ let item = e.target.closest(".autocomplete-item");
+ if (item && typeof item.dataset.index !== "undefined") {
+ this.selectedIndex = parseInt(item.dataset.index, 10);
+ }
+
+ this.emit("popup-click");
+ if (this.onClickCallback) {
+ this.onClickCallback(e);
+ }
+ },
+
+ /**
+ * Open the autocomplete popup panel.
+ *
+ * @param {nsIDOMNode} anchor
+ * Optional node to anchor the panel to.
+ * @param {Number} xOffset
+ * Horizontal offset in pixels from the left of the node to the left
+ * of the popup.
+ * @param {Number} yOffset
+ * Vertical offset in pixels from the top of the node to the starting
+ * of the popup.
+ * @param {Number} index
+ * The position of item to select.
+ */
+ openPopup: function (anchor, xOffset = 0, yOffset = 0, index) {
+ this.__maxLabelLength = -1;
+ this._updateSize();
+
+ // Retrieve the anchor's document active element to add accessibility metadata.
+ this._activeElement = anchor.ownerDocument.activeElement;
+
+ this._tooltip.show(anchor, {
+ x: xOffset,
+ y: yOffset,
+ position: this.position,
+ });
+
+ this._tooltip.once("shown", () => {
+ if (this.autoSelect) {
+ this.selectItemAtIndex(index);
+ }
+
+ this.emit("popup-opened");
+ });
+ },
+
+ /**
+ * Select item at the provided index.
+ *
+ * @param {Number} index
+ * The position of the item to select.
+ */
+ selectItemAtIndex: function (index) {
+ if (typeof index !== "number") {
+ // If no index was provided, select the item closest to the input.
+ let isAboveInput = this.position === "top";
+ index = isAboveInput ? this.itemCount - 1 : 0;
+ }
+ this.selectedIndex = index;
+ },
+
+ /**
+ * Hide the autocomplete popup panel.
+ */
+ hidePopup: function () {
+ this._tooltip.once("hidden", () => {
+ this.emit("popup-closed");
+ });
+
+ this._clearActiveDescendant();
+ this._activeElement = null;
+ this._tooltip.hide();
+ },
+
+ /**
+ * Check if the autocomplete popup is open.
+ */
+ get isOpen() {
+ return this._tooltip && this._tooltip.isVisible();
+ },
+
+ /**
+ * Destroy the object instance. Please note that the panel DOM elements remain
+ * in the DOM, because they might still be in use by other instances of the
+ * same code. It is the responsability of the client code to perform DOM
+ * cleanup.
+ */
+ destroy: function () {
+ if (this.isOpen) {
+ this.hidePopup();
+ }
+
+ this._list.removeEventListener("click", this.onClick, false);
+
+ if (this.autoThemeEnabled) {
+ gDevTools.off("pref-changed", this._handleThemeChange);
+ }
+
+ this._list.remove();
+ this._listClone.remove();
+ this._tooltip.destroy();
+ this._document = null;
+ this._list = null;
+ this._tooltip = null;
+ },
+
+ /**
+ * Get the autocomplete items array.
+ *
+ * @param {Number} index
+ * The index of the item what is wanted.
+ *
+ * @return {Object} The autocomplete item at index index.
+ */
+ getItemAtIndex: function (index) {
+ return this.items[index];
+ },
+
+ /**
+ * Get the autocomplete items array.
+ *
+ * @return {Array} The array of autocomplete items.
+ */
+ getItems: function () {
+ // Return a copy of the array to avoid side effects from the caller code.
+ return this.items.slice(0);
+ },
+
+ /**
+ * Set the autocomplete items list, in one go.
+ *
+ * @param {Array} items
+ * The list of items you want displayed in the popup list.
+ * @param {Number} index
+ * The position of the item to select.
+ */
+ setItems: function (items, index) {
+ this.clearItems();
+ items.forEach(this.appendItem, this);
+
+ if (this.isOpen && this.autoSelect) {
+ this.selectItemAtIndex(index);
+ }
+ },
+
+ __maxLabelLength: -1,
+
+ get _maxLabelLength() {
+ if (this.__maxLabelLength !== -1) {
+ return this.__maxLabelLength;
+ }
+
+ let max = 0;
+ for (let {label, count} of this.items) {
+ if (count) {
+ label += count + "";
+ }
+ max = Math.max(label.length, max);
+ }
+
+ this.__maxLabelLength = max;
+ return this.__maxLabelLength;
+ },
+
+ /**
+ * Update the panel size to fit the content.
+ */
+ _updateSize: function () {
+ if (!this._tooltip) {
+ return;
+ }
+
+ this._list.style.width = (this._maxLabelLength + 3) + "ch";
+ let selectedItem = this.selectedItem;
+ if (selectedItem) {
+ this._scrollElementIntoViewIfNeeded(this.elements.get(selectedItem));
+ }
+ },
+
+ _scrollElementIntoViewIfNeeded: function (element) {
+ let quads = element.getBoxQuads({relativeTo: this._tooltip.panel});
+ if (!quads || !quads[0]) {
+ return;
+ }
+
+ let {top, height} = quads[0].bounds;
+ let containerHeight = this._tooltip.panel.getBoundingClientRect().height;
+ if (top < 0) {
+ // Element is above container.
+ element.scrollIntoView(true);
+ } else if ((top + height) > containerHeight) {
+ // Element is beloew container.
+ element.scrollIntoView(false);
+ }
+ },
+
+ /**
+ * Clear all the items from the autocomplete list.
+ */
+ clearItems: function () {
+ // Reset the selectedIndex to -1 before clearing the list
+ this.selectedIndex = -1;
+ this._list.innerHTML = "";
+ this.__maxLabelLength = -1;
+ this.items = [];
+ this.elements = new WeakMap();
+ },
+
+ /**
+ * Getter for the index of the selected item.
+ *
+ * @type {Number}
+ */
+ get selectedIndex() {
+ return this._selectedIndex;
+ },
+
+ /**
+ * Setter for the selected index.
+ *
+ * @param {Number} index
+ * The number (index) of the item you want to select in the list.
+ */
+ set selectedIndex(index) {
+ let previousSelected = this._list.querySelector(".autocomplete-selected");
+ if (previousSelected) {
+ previousSelected.classList.remove("autocomplete-selected");
+ }
+
+ let item = this.items[index];
+ if (this.isOpen && item) {
+ let element = this.elements.get(item);
+
+ element.classList.add("autocomplete-selected");
+ this._scrollElementIntoViewIfNeeded(element);
+ this._setActiveDescendant(element.id);
+ } else {
+ this._clearActiveDescendant();
+ }
+ this._selectedIndex = index;
+
+ if (this.isOpen && item && this.onSelectCallback) {
+ // Call the user-defined select callback if defined.
+ this.onSelectCallback();
+ }
+ },
+
+ /**
+ * Getter for the selected item.
+ * @type Object
+ */
+ get selectedItem() {
+ return this.items[this._selectedIndex];
+ },
+
+ /**
+ * Setter for the selected item.
+ *
+ * @param {Object} item
+ * The object you want selected in the list.
+ */
+ set selectedItem(item) {
+ let index = this.items.indexOf(item);
+ if (index !== -1 && this.isOpen) {
+ this.selectedIndex = index;
+ }
+ },
+
+ /**
+ * Update the aria-activedescendant attribute on the current active element for
+ * accessibility.
+ *
+ * @param {String} id
+ * The id (as in DOM id) of the currently selected autocomplete suggestion
+ */
+ _setActiveDescendant: function (id) {
+ if (!this._activeElement) {
+ return;
+ }
+
+ // Make sure the list clone is in the same document as the anchor.
+ let anchorDoc = this._activeElement.ownerDocument;
+ if (!this._listClone.parentNode || this._listClone.ownerDocument !== anchorDoc) {
+ anchorDoc.documentElement.appendChild(this._listClone);
+ }
+
+ // Update the clone content to match the current list content.
+ this._listClone.innerHTML = this._list.innerHTML;
+
+ this._activeElement.setAttribute("aria-activedescendant", id);
+ },
+
+ /**
+ * Clear the aria-activedescendant attribute on the current active element.
+ */
+ _clearActiveDescendant: function () {
+ if (!this._activeElement) {
+ return;
+ }
+
+ this._activeElement.removeAttribute("aria-activedescendant");
+ },
+
+ /**
+ * Append an item into the autocomplete list.
+ *
+ * @param {Object} item
+ * The item you want appended to the list.
+ * The item object can have the following properties:
+ * - label {String} Property which is used as the displayed value.
+ * - preLabel {String} [Optional] The String that will be displayed
+ * before the label indicating that this is the already
+ * present text in the input box, and label is the text
+ * that will be auto completed. When this property is
+ * present, |preLabel.length| starting characters will be
+ * removed from label.
+ * - count {Number} [Optional] The number to represent the count of
+ * autocompleted label.
+ */
+ appendItem: function (item) {
+ let listItem = this._document.createElementNS(HTML_NS, "li");
+ // Items must have an id for accessibility.
+ listItem.setAttribute("id", "autocomplete-item-" + itemIdCounter++);
+ listItem.className = "autocomplete-item";
+ listItem.setAttribute("data-index", this.items.length);
+ if (this.direction) {
+ listItem.setAttribute("dir", this.direction);
+ }
+ let label = this._document.createElementNS(HTML_NS, "span");
+ label.textContent = item.label;
+ label.className = "autocomplete-value";
+ if (item.preLabel) {
+ let preDesc = this._document.createElementNS(HTML_NS, "span");
+ preDesc.textContent = item.preLabel;
+ preDesc.className = "initial-value";
+ listItem.appendChild(preDesc);
+ label.textContent = item.label.slice(item.preLabel.length);
+ }
+ listItem.appendChild(label);
+ if (item.count && item.count > 1) {
+ let countDesc = this._document.createElementNS(HTML_NS, "span");
+ countDesc.textContent = item.count;
+ countDesc.setAttribute("flex", "1");
+ countDesc.className = "autocomplete-count";
+ listItem.appendChild(countDesc);
+ }
+
+ this._list.appendChild(listItem);
+ this.items.push(item);
+ this.elements.set(item, listItem);
+ },
+
+ /**
+ * Remove an item from the popup list.
+ *
+ * @param {Object} item
+ * The item you want removed.
+ */
+ removeItem: function (item) {
+ if (!this.items.includes(item)) {
+ return;
+ }
+
+ let itemIndex = this.items.indexOf(item);
+ let selectedIndex = this.selectedIndex;
+
+ // Remove autocomplete item.
+ this.items.splice(itemIndex, 1);
+
+ // Remove corresponding DOM element from the elements WeakMap and from the DOM.
+ let elementToRemove = this.elements.get(item);
+ this.elements.delete(elementToRemove);
+ elementToRemove.remove();
+
+ if (itemIndex <= selectedIndex) {
+ // If the removed item index was before or equal to the selected index, shift the
+ // selected index by 1.
+ this.selectedIndex = Math.max(0, selectedIndex - 1);
+ }
+ },
+
+ /**
+ * Getter for the number of items in the popup.
+ * @type {Number}
+ */
+ get itemCount() {
+ return this.items.length;
+ },
+
+ /**
+ * Getter for the height of each item in the list.
+ *
+ * @type {Number}
+ */
+ get _itemsPerPane() {
+ if (this.items.length) {
+ let listHeight = this._tooltip.panel.clientHeight;
+ let element = this.elements.get(this.items[0]);
+ let elementHeight = element.getBoundingClientRect().height;
+ return Math.floor(listHeight / elementHeight);
+ }
+ return 0;
+ },
+
+ /**
+ * Select the next item in the list.
+ *
+ * @return {Object}
+ * The newly selected item object.
+ */
+ selectNextItem: function () {
+ if (this.selectedIndex < (this.items.length - 1)) {
+ this.selectedIndex++;
+ } else {
+ this.selectedIndex = 0;
+ }
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the previous item in the list.
+ *
+ * @return {Object}
+ * The newly-selected item object.
+ */
+ selectPreviousItem: function () {
+ if (this.selectedIndex > 0) {
+ this.selectedIndex--;
+ } else {
+ this.selectedIndex = this.items.length - 1;
+ }
+
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the top-most item in the next page of items or
+ * the last item in the list.
+ *
+ * @return {Object}
+ * The newly-selected item object.
+ */
+ selectNextPageItem: function () {
+ let nextPageIndex = this.selectedIndex + this._itemsPerPane + 1;
+ this.selectedIndex = Math.min(nextPageIndex, this.itemCount - 1);
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the bottom-most item in the previous page of items,
+ * or the first item in the list.
+ *
+ * @return {Object}
+ * The newly-selected item object.
+ */
+ selectPreviousPageItem: function () {
+ let prevPageIndex = this.selectedIndex - this._itemsPerPane - 1;
+ this.selectedIndex = Math.max(prevPageIndex, 0);
+ return this.selectedItem;
+ },
+
+ /**
+ * Manages theme switching for the popup based on the devtools.theme pref.
+ *
+ * @private
+ *
+ * @param {String} event
+ * The name of the event. In this case, "pref-changed".
+ * @param {Object} data
+ * An object passed by the emitter of the event. In this case, the
+ * object consists of three properties:
+ * - pref {String} The name of the preference that was modified.
+ * - newValue {Object} The new value of the preference.
+ * - oldValue {Object} The old value of the preference.
+ */
+ _handleThemeChange: function (event, data) {
+ if (data.pref === "devtools.theme") {
+ this._tooltip.panel.classList.toggle(data.oldValue + "-theme", false);
+ this._tooltip.panel.classList.toggle(data.newValue + "-theme", true);
+ this._list.classList.toggle(data.oldValue + "-theme", false);
+ this._list.classList.toggle(data.newValue + "-theme", true);
+ }
+ },
+
+ /**
+ * Used by tests.
+ */
+ get _panel() {
+ return this._tooltip.panel;
+ },
+
+ /**
+ * Used by tests.
+ */
+ get _window() {
+ return this._document.defaultView;
+ },
+};