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.js709
1 files changed, 709 insertions, 0 deletions
diff --git a/devtools/client/shared/autocomplete-popup.js b/devtools/client/shared/autocomplete-popup.js
new file mode 100644
index 0000000000..93ebc8d688
--- /dev/null
+++ b/devtools/client/shared/autocomplete-popup.js
@@ -0,0 +1,709 @@
+/* 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 EventEmitter = require("resource://devtools/shared/event-emitter.js");
+
+loader.lazyRequireGetter(
+ this,
+ "HTMLTooltip",
+ "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js",
+ true
+);
+loader.lazyRequireGetter(
+ this,
+ "colorUtils",
+ "resource://devtools/shared/css/color.js",
+ true
+);
+
+const HTML_NS = "http://www.w3.org/1999/xhtml";
+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 <UL> element.
+ * - position {String} The position for the tooltip ("top" or "bottom").
+ * - useXulWrapper {Boolean} If the tooltip is hosted in a XUL document, use a
+ * XUL panel in order to use all the screen viewport available (defaults to false).
+ * - 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.
+ * - input {Element} Optional input element the popup will be bound to. If provided
+ * the event listeners for navigating the autocomplete list are going to be
+ * automatically added.
+ */
+function AutocompletePopup(toolboxDoc, options = {}) {
+ EventEmitter.decorate(this);
+
+ this._document = toolboxDoc;
+ this.autoSelect = options.autoSelect || false;
+ this.listId = options.listId || null;
+ this.position = options.position || "bottom";
+ this.useXulWrapper = options.useXulWrapper || false;
+
+ this.onSelectCallback = options.onSelect;
+ this.onClickCallback = options.onClick;
+
+ // Array of raw autocomplete items
+ this.items = [];
+ // Map of autocompleteItem to HTMLElement
+ this.elements = new WeakMap();
+
+ this.selectedIndex = -1;
+
+ this.onClick = this.onClick.bind(this);
+ this.onInputKeyDown = this.onInputKeyDown.bind(this);
+ this.onInputBlur = this.onInputBlur.bind(this);
+
+ if (options.input) {
+ this.input = options.input;
+ options.input.addEventListener("keydown", this.onInputKeyDown);
+ options.input.addEventListener("blur", this.onInputBlur);
+ }
+}
+
+AutocompletePopup.prototype = {
+ _document: null,
+
+ get list() {
+ if (this._list) {
+ return this._list;
+ }
+
+ 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 be a
+ // copy of the main list to allow screen readers to access the list.
+ this._listClone = this._list.cloneNode();
+ this._listClone.className = "devtools-autocomplete-list-aria-clone";
+
+ if (this.listId) {
+ this._list.setAttribute("id", this.listId);
+ }
+
+ this._list.className = "devtools-autocomplete-listbox";
+
+ // We need to retrieve the item padding in order to correct the offset of the popup.
+ const paddingPropertyName = "--autocomplete-item-padding-inline";
+ const listPadding = this._document.defaultView
+ .getComputedStyle(this._list)
+ .getPropertyValue(paddingPropertyName)
+ .replace("px", "");
+
+ this._listPadding = 0;
+ if (!Number.isNaN(Number(listPadding))) {
+ this._listPadding = Number(listPadding);
+ }
+
+ this._list.addEventListener("click", this.onClick);
+
+ return this._list;
+ },
+
+ get tooltip() {
+ if (this._tooltip) {
+ return this._tooltip;
+ }
+
+ this._tooltip = new HTMLTooltip(this._document, {
+ useXulWrapper: this.useXulWrapper,
+ });
+
+ this._tooltip.panel.classList.add(
+ "devtools-autocomplete-popup",
+ "devtools-monospace"
+ );
+ this._tooltip.panel.appendChild(this.list);
+ this._tooltip.setContentSize({ height: "auto" });
+
+ return this._tooltip;
+ },
+
+ onInputKeyDown(event) {
+ // Only handle the even if the popup is opened.
+ if (!this.isOpen) {
+ return;
+ }
+
+ if (
+ this.selectedItem &&
+ this.onClickCallback &&
+ (event.key === "Enter" ||
+ (event.key === "ArrowRight" && !event.shiftKey) ||
+ (event.key === "Tab" && !event.shiftKey))
+ ) {
+ this.onClickCallback(event, this.selectedItem);
+
+ // Prevent the associated keypress to be triggered.
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ // Close the popup when the user hit Left Arrow, but let the keypress be triggered
+ // so the cursor moves as the user wanted.
+ if (event.key === "ArrowLeft" && !event.shiftKey) {
+ this.clearItems();
+ this.hidePopup();
+ return;
+ }
+
+ // Close the popup when the user hit Escape.
+ if (event.key === "Escape") {
+ this.clearItems();
+ this.hidePopup();
+ // Prevent the associated keypress to be triggered.
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.key === "ArrowDown") {
+ this.selectNextItem();
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ if (event.key === "ArrowUp") {
+ this.selectPreviousItem();
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ },
+
+ onInputBlur(event) {
+ if (this.isOpen) {
+ this.clearItems();
+ this.hidePopup();
+ }
+ },
+
+ onSelect(e) {
+ if (this.onSelectCallback) {
+ this.onSelectCallback(e);
+ }
+ },
+
+ onClick(e) {
+ const itemEl = e.target.closest(".autocomplete-item");
+ const index =
+ typeof itemEl?.dataset?.index !== "undefined"
+ ? parseInt(itemEl.dataset.index, 10)
+ : null;
+
+ if (index !== null) {
+ this.selectItemAtIndex(index);
+ }
+
+ this.emit("popup-click");
+
+ if (this.onClickCallback) {
+ const item = index !== null ? this.items[index] : null;
+ this.onClickCallback(e, item);
+ }
+ },
+
+ /**
+ * Open the autocomplete popup panel.
+ *
+ * @param {Node} anchor
+ * Optional node to anchor the panel to. Will default to this.input if it exists.
+ * @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.
+ * @param {Object} options: Check `selectItemAtIndex` for more information.
+ */
+ async openPopup(anchor, xOffset = 0, yOffset = 0, index, options) {
+ if (!anchor && this.input) {
+ anchor = this.input;
+ }
+
+ // Retrieve the anchor's document active element to add accessibility metadata.
+ this._activeElement = anchor.ownerDocument.activeElement;
+
+ // We want the autocomplete items to be perflectly lined-up with the string the
+ // user entered, so we need to remove the left-padding and the left-border from
+ // the xOffset.
+ const leftBorderSize = 1;
+
+ // If we have another call to openPopup while the previous one isn't over yet, we
+ // need to wait until it's settled to not be in a compromised state.
+ if (this._pendingShowPromise) {
+ await this._pendingShowPromise;
+ }
+
+ this._pendingShowPromise = this.tooltip.show(anchor, {
+ x: xOffset - this._listPadding - leftBorderSize,
+ y: yOffset,
+ position: this.position,
+ });
+ await this._pendingShowPromise;
+ this._pendingShowPromise = null;
+
+ if (this.autoSelect) {
+ this.selectItemAtIndex(index, options);
+ }
+
+ this.emit("popup-opened");
+ },
+
+ /**
+ * Select item at the provided index.
+ *
+ * @param {Number} index
+ * The position of the item to select.
+ * @param {Object} options: An object that can contain:
+ * - {Boolean} preventSelectCallback: true to not call this.onSelectCallback as
+ * during the initial autoSelect.
+ */
+ selectItemAtIndex(index, options = {}) {
+ const { preventSelectCallback } = options;
+
+ if (!Number.isInteger(index)) {
+ // If no index was provided, select the first item.
+ index = 0;
+ }
+ const item = this.items[index];
+ const element = this.elements.get(item);
+
+ const previousSelected = this.list.querySelector(".autocomplete-selected");
+ if (previousSelected && previousSelected !== element) {
+ previousSelected.classList.remove("autocomplete-selected");
+ }
+
+ if (element && !element.classList.contains("autocomplete-selected")) {
+ element.classList.add("autocomplete-selected");
+ }
+
+ if (this.isOpen && item) {
+ this._scrollElementIntoViewIfNeeded(element);
+ this._setActiveDescendant(element.id);
+ } else {
+ this._clearActiveDescendant();
+ }
+ this.selectedIndex = index;
+
+ if (
+ this.isOpen &&
+ item &&
+ this.onSelectCallback &&
+ !preventSelectCallback
+ ) {
+ // Call the user-defined select callback if defined.
+ this.onSelectCallback(item);
+ }
+ },
+
+ /**
+ * Hide the autocomplete popup panel.
+ */
+ hidePopup() {
+ this._pendingShowPromise = null;
+ 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() {
+ this._pendingShowPromise = null;
+ if (this.isOpen) {
+ this.hidePopup();
+ }
+
+ if (this._list) {
+ this._list.removeEventListener("click", this.onClick);
+
+ this._list.remove();
+ this._listClone.remove();
+
+ this._list = null;
+ }
+
+ if (this._tooltip) {
+ this._tooltip.destroy();
+ this._tooltip = null;
+ }
+
+ if (this.input) {
+ this.input.addEventListener("keydown", this.onInputKeyDown);
+ this.input.addEventListener("blur", this.onInputBlur);
+ this.input = null;
+ }
+
+ this._document = 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(index) {
+ return this.items[index];
+ },
+
+ /**
+ * Get the autocomplete items array.
+ *
+ * @return {Array} The array of autocomplete items.
+ */
+ getItems() {
+ // 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} selectedIndex
+ * The position of the item to select.
+ * @param {Object} options: An object that can contain:
+ * - {Boolean} preventSelectCallback: true to not call this.onSelectCallback as
+ * during the initial autoSelect.
+ */
+ setItems(items, selectedIndex, options) {
+ this.clearItems();
+
+ // If there is no new items, no need to do unecessary work.
+ if (items.length === 0) {
+ return;
+ }
+
+ if (!Number.isInteger(selectedIndex) && this.autoSelect) {
+ selectedIndex = 0;
+ }
+
+ // Let's compute the max label length in the item list. This length will then be used
+ // to set the width of the popup.
+ let maxLabelLength = 0;
+
+ const fragment = this._document.createDocumentFragment();
+ items.forEach((item, i) => {
+ const selected = selectedIndex === i;
+ const listItem = this.createListItem(item, i, selected);
+ this.items.push(item);
+ this.elements.set(item, listItem);
+ fragment.appendChild(listItem);
+
+ let { label, postLabel, count } = item;
+ if (count) {
+ label += count + "";
+ }
+
+ if (postLabel) {
+ label += postLabel;
+ }
+ maxLabelLength = Math.max(label.length, maxLabelLength);
+ });
+
+ // The popup should be as wide as its longest item.
+ // We need to account for the inline padding
+ const fragmentClone = fragment.cloneNode(true);
+ let width = `calc(${
+ maxLabelLength + 3
+ }ch + 2 * var(--autocomplete-item-padding-inline, 0px))`;
+ // As well as add more space if we're displaying color swatches
+ if (fragment.querySelector(".autocomplete-colorswatch")) {
+ width = `calc(${width} + var(--autocomplete-item-color-swatch-size) + 2 * var(--autocomplete-item-color-swatch-margin-inline))`;
+ }
+ this.list.style.width = width;
+ this.list.appendChild(fragment);
+ // Update the clone content to match the current list content.
+ this._listClone.appendChild(fragmentClone);
+
+ this.selectItemAtIndex(selectedIndex, options);
+ },
+
+ _scrollElementIntoViewIfNeeded(element) {
+ const quads = element.getBoxQuads({
+ relativeTo: this.tooltip.panel,
+ createFramesForSuppressedWhitespace: false,
+ });
+ if (!quads || !quads[0]) {
+ return;
+ }
+
+ const { top, height } = quads[0].getBounds();
+ const containerHeight = this.tooltip.panel.getBoundingClientRect().height;
+ if (top < 0) {
+ // Element is above container.
+ element.scrollIntoView(true);
+ } else if (top + height > containerHeight) {
+ // Element is below container.
+ element.scrollIntoView(false);
+ }
+ },
+
+ /**
+ * Clear all the items from the autocomplete list.
+ */
+ clearItems() {
+ if (this._list) {
+ this._list.innerHTML = "";
+ }
+ if (this._listClone) {
+ this._listClone.innerHTML = "";
+ }
+
+ this.items = [];
+ this.elements = new WeakMap();
+ this.selectItemAtIndex(-1);
+ },
+
+ /**
+ * 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) {
+ const index = this.items.indexOf(item);
+ if (index !== -1 && this.isOpen) {
+ this.selectItemAtIndex(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(id) {
+ if (!this._activeElement) {
+ return;
+ }
+
+ // Make sure the list clone is in the same document as the anchor.
+ const anchorDoc = this._activeElement.ownerDocument;
+ if (
+ !this._listClone.parentNode ||
+ this._listClone.ownerDocument !== anchorDoc
+ ) {
+ anchorDoc.documentElement.appendChild(this._listClone);
+ }
+
+ this._activeElement.setAttribute("aria-activedescendant", id);
+ },
+
+ /**
+ * Clear the aria-activedescendant attribute on the current active element.
+ */
+ _clearActiveDescendant() {
+ if (!this._activeElement) {
+ return;
+ }
+
+ this._activeElement.removeAttribute("aria-activedescendant");
+ },
+
+ createListItem(item, index, selected) {
+ const listItem = this._document.createElementNS(HTML_NS, "li");
+ // Items must have an id for accessibility.
+ listItem.setAttribute("id", "autocomplete-item-" + itemIdCounter++);
+ listItem.classList.add("autocomplete-item");
+ if (selected) {
+ listItem.classList.add("autocomplete-selected");
+ }
+ listItem.setAttribute("data-index", index);
+
+ if (this.direction) {
+ listItem.setAttribute("dir", this.direction);
+ }
+
+ const label = this._document.createElementNS(HTML_NS, "span");
+ label.textContent = item.label;
+ label.className = "autocomplete-value";
+
+ if (item.preLabel) {
+ const 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.postLabel) {
+ const postDesc = this._document.createElementNS(HTML_NS, "span");
+ postDesc.className = "autocomplete-postlabel";
+ postDesc.textContent = item.postLabel;
+ // Determines if the postlabel is a valid colour or other value
+ if (this._isValidColor(item.postLabel)) {
+ const colorSwatch = this._document.createElementNS(HTML_NS, "span");
+ colorSwatch.className = "autocomplete-swatch autocomplete-colorswatch";
+ colorSwatch.style.cssText = "background-color: " + item.postLabel;
+ postDesc.insertBefore(colorSwatch, postDesc.childNodes[0]);
+ }
+ listItem.appendChild(postDesc);
+ }
+
+ if (item.count && item.count > 1) {
+ const countDesc = this._document.createElementNS(HTML_NS, "span");
+ countDesc.textContent = item.count;
+ countDesc.setAttribute("flex", "1");
+ countDesc.className = "autocomplete-count";
+ listItem.appendChild(countDesc);
+ }
+
+ return listItem;
+ },
+
+ /**
+ * 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) {
+ const listHeight = this.tooltip.panel.clientHeight;
+ const element = this.elements.get(this.items[0]);
+ const 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() {
+ if (this.selectedIndex < this.items.length - 1) {
+ this.selectItemAtIndex(this.selectedIndex + 1);
+ } else {
+ this.selectItemAtIndex(0);
+ }
+ return this.selectedItem;
+ },
+
+ /**
+ * Select the previous item in the list.
+ *
+ * @return {Object}
+ * The newly-selected item object.
+ */
+ selectPreviousItem() {
+ if (this.selectedIndex > 0) {
+ this.selectItemAtIndex(this.selectedIndex - 1);
+ } else {
+ this.selectItemAtIndex(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() {
+ const nextPageIndex = this.selectedIndex + this._itemsPerPane + 1;
+ this.selectItemAtIndex(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() {
+ const prevPageIndex = this.selectedIndex - this._itemsPerPane - 1;
+ this.selectItemAtIndex(Math.max(prevPageIndex, 0));
+ return this.selectedItem;
+ },
+
+ /**
+ * Determines if the specified colour object is a valid colour, and if
+ * it is not a "special value"
+ *
+ * @return {Boolean}
+ * If the object represents a proper colour or not.
+ */
+ _isValidColor(color) {
+ const colorObj = new colorUtils.CssColor(color);
+ return colorObj.valid && !colorObj.specialValue;
+ },
+
+ /**
+ * Used by tests.
+ */
+ get _panel() {
+ return this.tooltip.panel;
+ },
+
+ /**
+ * Used by tests.
+ */
+ get _window() {
+ return this._document.defaultView;
+ },
+};
+
+module.exports = AutocompletePopup;