/* 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; }, };