tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

autocomplete-popup.js (20105B)


      1 /* This Source Code Form is subject to the terms of the Mozilla Public
      2 * License, v. 2.0. If a copy of the MPL was not distributed with this
      3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
      4 
      5 "use strict";
      6 
      7 const EventEmitter = require("resource://devtools/shared/event-emitter.js");
      8 
      9 loader.lazyRequireGetter(
     10  this,
     11  "HTMLTooltip",
     12  "resource://devtools/client/shared/widgets/tooltip/HTMLTooltip.js",
     13  true
     14 );
     15 loader.lazyRequireGetter(
     16  this,
     17  "colorUtils",
     18  "resource://devtools/shared/css/color.js",
     19  true
     20 );
     21 
     22 const HTML_NS = "http://www.w3.org/1999/xhtml";
     23 let itemIdCounter = 0;
     24 
     25 /**
     26 * Autocomplete popup UI implementation.
     27 */
     28 class AutocompletePopup extends EventEmitter {
     29  /**
     30   * @param {Document} toolboxDoc
     31   *        The toolbox document to attach the autocomplete popup panel.
     32   * @param {object} options
     33   *        An object consiting any of the following options:
     34   *        - listId {String} The id for the list <UL> element.
     35   *        - position {String} The position for the tooltip ("top" or "bottom").
     36   *        - useXulWrapper {Boolean} If the tooltip is hosted in a XUL document, use a
     37   *          XUL panel in order to use all the screen viewport available (defaults to false).
     38   *        - autoSelect {Boolean} Boolean to allow the first entry of the popup
     39   *          panel to be automatically selected when the popup shows.
     40   *        - onSelect {String} Callback called when the selected index is updated.
     41   *        - onClick {String} Callback called when the autocomplete popup receives a click
     42   *          event. The selectedIndex will already be updated if need be.
     43   *        - input {Element} Optional input element the popup will be bound to. If provided
     44   *          the event listeners for navigating the autocomplete list are going to be
     45   *          automatically added.
     46   */
     47  constructor(toolboxDoc, options = {}) {
     48    super();
     49 
     50    this.#document = toolboxDoc;
     51    this.#autoSelect = options.autoSelect || false;
     52    this.#listId = options.listId || null;
     53    this.#position = options.position || "bottom";
     54    this.#useXulWrapper = options.useXulWrapper || false;
     55 
     56    this.#onSelectCallback = options.onSelect;
     57    this.#onClickCallback = options.onClick;
     58 
     59    // Array of raw autocomplete items
     60    this.items = [];
     61    // Map of autocompleteItem to HTMLElement
     62    this.elements = new WeakMap();
     63 
     64    this.selectedIndex = -1;
     65 
     66    if (options.input) {
     67      this.#input = options.input;
     68      options.input.addEventListener("keydown", this.onInputKeyDown);
     69      options.input.addEventListener("blur", this.onInputBlur);
     70    }
     71  }
     72 
     73  #activeElement;
     74  #autoSelect;
     75  #document = null;
     76  #input;
     77  #list = null;
     78  #listClone = null;
     79  #listId;
     80  #listPadding;
     81  #onClickCallback;
     82  #onSelectCallback;
     83  #pendingShowPromise;
     84  #position;
     85  #tooltip;
     86  #useXulWrapper;
     87 
     88  get list() {
     89    if (this.#list) {
     90      return this.#list;
     91    }
     92 
     93    this.#list = this.#document.createElementNS(HTML_NS, "ul");
     94    this.#list.setAttribute("flex", "1");
     95 
     96    // The list clone will be inserted in the same document as the anchor, and will be a
     97    // copy of the main list to allow screen readers to access the list.
     98    this.#listClone = this.#list.cloneNode();
     99    this.#listClone.className = "devtools-autocomplete-list-aria-clone";
    100 
    101    if (this.#listId) {
    102      this.#list.setAttribute("id", this.#listId);
    103    }
    104 
    105    this.#list.className = "devtools-autocomplete-listbox";
    106 
    107    // We need to retrieve the item padding in order to correct the offset of the popup.
    108    const paddingPropertyName = "--autocomplete-item-padding-inline";
    109    const listPadding = this.#document.defaultView
    110      .getComputedStyle(this.#list)
    111      .getPropertyValue(paddingPropertyName)
    112      .replace("px", "");
    113 
    114    this.#listPadding = 0;
    115    if (!Number.isNaN(Number(listPadding))) {
    116      this.#listPadding = Number(listPadding);
    117    }
    118 
    119    this.#list.addEventListener("click", this.onClick);
    120 
    121    return this.#list;
    122  }
    123 
    124  get tooltip() {
    125    if (this.#tooltip) {
    126      return this.#tooltip;
    127    }
    128 
    129    this.#tooltip = new HTMLTooltip(this.#document, {
    130      useXulWrapper: this.#useXulWrapper,
    131    });
    132 
    133    this.#tooltip.panel.classList.add(
    134      "devtools-autocomplete-popup",
    135      "devtools-monospace"
    136    );
    137    this.#tooltip.panel.appendChild(this.list);
    138    this.#tooltip.setContentSize({ height: "auto" });
    139 
    140    return this.#tooltip;
    141  }
    142 
    143  onInputKeyDown = event => {
    144    // Only handle the even if the popup is opened.
    145    if (!this.isOpen) {
    146      return;
    147    }
    148 
    149    if (
    150      this.selectedItem &&
    151      this.#onClickCallback &&
    152      (event.key === "Enter" ||
    153        (event.key === "ArrowRight" && !event.shiftKey) ||
    154        (event.key === "Tab" && !event.shiftKey))
    155    ) {
    156      this.#onClickCallback(event, this.selectedItem);
    157 
    158      // Prevent the associated keypress to be triggered.
    159      event.preventDefault();
    160      event.stopPropagation();
    161      return;
    162    }
    163 
    164    // Close the popup when the user hit Left Arrow, but let the keypress be triggered
    165    // so the cursor moves as the user wanted.
    166    if (event.key === "ArrowLeft" && !event.shiftKey) {
    167      this.clearItems();
    168      this.hidePopup();
    169      return;
    170    }
    171 
    172    // Close the popup when the user hit Escape.
    173    if (event.key === "Escape") {
    174      this.clearItems();
    175      this.hidePopup();
    176      // Prevent the associated keypress to be triggered.
    177      event.preventDefault();
    178      event.stopPropagation();
    179      return;
    180    }
    181 
    182    if (event.key === "ArrowDown") {
    183      this.selectNextItem();
    184      event.preventDefault();
    185      event.stopPropagation();
    186      return;
    187    }
    188 
    189    if (event.key === "ArrowUp") {
    190      this.selectPreviousItem();
    191      event.preventDefault();
    192      event.stopPropagation();
    193    }
    194  };
    195 
    196  onInputBlur = () => {
    197    if (this.isOpen) {
    198      this.clearItems();
    199      this.hidePopup();
    200    }
    201  };
    202 
    203  onSelect(e) {
    204    if (this.#onSelectCallback) {
    205      this.#onSelectCallback(e);
    206    }
    207  }
    208 
    209  onClick = e => {
    210    const itemEl = e.target.closest(".autocomplete-item");
    211    const index =
    212      typeof itemEl?.dataset?.index !== "undefined"
    213        ? parseInt(itemEl.dataset.index, 10)
    214        : null;
    215 
    216    if (index !== null) {
    217      this.selectItemAtIndex(index);
    218    }
    219 
    220    this.emit("popup-click");
    221 
    222    if (this.#onClickCallback) {
    223      const item = index !== null ? this.items[index] : null;
    224      this.#onClickCallback(e, item);
    225    }
    226  };
    227 
    228  /**
    229   * Open the autocomplete popup panel.
    230   *
    231   * @param {Node} anchor
    232   *        Optional node to anchor the panel to. Will default to this.input if it exists.
    233   * @param {number} xOffset
    234   *        Horizontal offset in pixels from the left of the node to the left
    235   *        of the popup.
    236   * @param {number} yOffset
    237   *        Vertical offset in pixels from the top of the node to the starting
    238   *        of the popup.
    239   * @param {number} index
    240   *        The position of item to select.
    241   * @param {object} options: Check `selectItemAtIndex` for more information.
    242   */
    243  async openPopup(anchor, xOffset = 0, yOffset = 0, index, options) {
    244    if (!anchor && this.#input) {
    245      anchor = this.#input;
    246    }
    247 
    248    // Retrieve the anchor's document active element to add accessibility metadata.
    249    this.#activeElement = anchor.ownerDocument.activeElement;
    250 
    251    // We want the autocomplete items to be perflectly lined-up with the string the
    252    // user entered, so we need to remove the left-padding and the left-border from
    253    // the xOffset.
    254    const leftBorderSize = 1;
    255 
    256    // If we have another call to openPopup while the previous one isn't over yet, we
    257    // need to wait until it's settled to not be in a compromised state.
    258    if (this.#pendingShowPromise) {
    259      await this.#pendingShowPromise;
    260    }
    261 
    262    this.#pendingShowPromise = this.tooltip.show(anchor, {
    263      x: xOffset - this.#listPadding - leftBorderSize,
    264      y: yOffset,
    265      position: this.#position,
    266    });
    267    await this.#pendingShowPromise;
    268    this.#pendingShowPromise = null;
    269 
    270    if (this.#autoSelect) {
    271      this.selectItemAtIndex(index, options);
    272    }
    273 
    274    this.emit("popup-opened");
    275  }
    276 
    277  /**
    278   * Select item at the provided index.
    279   *
    280   * @param {number} index
    281   *        The position of the item to select.
    282   * @param {object} options: An object that can contain:
    283   *        -  {Boolean} preventSelectCallback: true to not call this.onSelectCallback as
    284   *                     during the initial autoSelect.
    285   */
    286  selectItemAtIndex(index, options = {}) {
    287    const { preventSelectCallback } = options;
    288 
    289    if (!Number.isInteger(index)) {
    290      // If no index was provided, select the first item.
    291      index = 0;
    292    }
    293    const item = this.items[index];
    294    const element = this.elements.get(item);
    295 
    296    const previousSelected = this.list.querySelector(".autocomplete-selected");
    297    if (previousSelected && previousSelected !== element) {
    298      previousSelected.classList.remove("autocomplete-selected");
    299    }
    300 
    301    if (element && !element.classList.contains("autocomplete-selected")) {
    302      element.classList.add("autocomplete-selected");
    303    }
    304 
    305    if (this.isOpen && item) {
    306      this.#scrollElementIntoViewIfNeeded(element);
    307      this.#setActiveDescendant(element.id);
    308    } else {
    309      this.#clearActiveDescendant();
    310    }
    311    this.selectedIndex = index;
    312 
    313    if (
    314      this.isOpen &&
    315      item &&
    316      this.#onSelectCallback &&
    317      !preventSelectCallback
    318    ) {
    319      // Call the user-defined select callback if defined.
    320      this.#onSelectCallback(item);
    321    }
    322  }
    323 
    324  /**
    325   * Hide the autocomplete popup panel.
    326   */
    327  hidePopup() {
    328    this.#pendingShowPromise = null;
    329    this.tooltip.once("hidden", () => {
    330      this.emit("popup-closed");
    331    });
    332 
    333    this.#clearActiveDescendant();
    334    this.#activeElement = null;
    335    this.tooltip.hide();
    336  }
    337 
    338  /**
    339   * Check if the autocomplete popup is open.
    340   */
    341  get isOpen() {
    342    return !!this.#tooltip && this.tooltip.isVisible();
    343  }
    344 
    345  /**
    346   * Destroy the object instance. Please note that the panel DOM elements remain
    347   * in the DOM, because they might still be in use by other instances of the
    348   * same code. It is the responsability of the client code to perform DOM
    349   * cleanup.
    350   */
    351  destroy() {
    352    this.#pendingShowPromise = null;
    353    if (this.isOpen) {
    354      this.hidePopup();
    355    }
    356 
    357    if (this.#list) {
    358      this.#list.removeEventListener("click", this.onClick);
    359 
    360      this.#list.remove();
    361      this.#listClone.remove();
    362 
    363      this.#list = null;
    364    }
    365 
    366    if (this.#tooltip) {
    367      this.#tooltip.destroy();
    368      this.#tooltip = null;
    369    }
    370 
    371    if (this.#input) {
    372      this.#input.addEventListener("keydown", this.onInputKeyDown);
    373      this.#input.addEventListener("blur", this.onInputBlur);
    374      this.#input = null;
    375    }
    376 
    377    this.#document = null;
    378  }
    379 
    380  /**
    381   * Get the autocomplete items array.
    382   *
    383   * @param {number} index
    384   *        The index of the item what is wanted.
    385   *
    386   * @return {object} The autocomplete item at index index.
    387   */
    388  getItemAtIndex(index) {
    389    return this.items[index];
    390  }
    391 
    392  /**
    393   * Get the autocomplete items array.
    394   *
    395   * @return {Array} The array of autocomplete items.
    396   */
    397  getItems() {
    398    // Return a copy of the array to avoid side effects from the caller code.
    399    return this.items.slice(0);
    400  }
    401 
    402  /**
    403   * Set the autocomplete items list, in one go.
    404   *
    405   * @param {Array} items
    406   *        The list of items you want displayed in the popup list.
    407   * @param {number} selectedIndex
    408   *        The position of the item to select.
    409   * @param {object} options: An object that can contain:
    410   *        -  {Boolean} preventSelectCallback: true to not call this.onSelectCallback as
    411   *                     during the initial autoSelect.
    412   */
    413  setItems(items, selectedIndex, options) {
    414    this.clearItems();
    415 
    416    // If there is no new items, no need to do unecessary work.
    417    if (items.length === 0) {
    418      return;
    419    }
    420 
    421    if (!Number.isInteger(selectedIndex) && this.#autoSelect) {
    422      selectedIndex = 0;
    423    }
    424 
    425    // Let's compute the max label length in the item list. This length will then be used
    426    // to set the width of the popup.
    427    let maxLabelLength = 0;
    428 
    429    const fragment = this.#document.createDocumentFragment();
    430    items.forEach((item, i) => {
    431      const selected = selectedIndex === i;
    432      const listItem = this.#createListItem(item, i, selected);
    433      this.items.push(item);
    434      this.elements.set(item, listItem);
    435      fragment.appendChild(listItem);
    436 
    437      let { label, postLabel, count } = item;
    438      if (count) {
    439        label += count + "";
    440      }
    441 
    442      if (postLabel) {
    443        label += postLabel;
    444      }
    445      maxLabelLength = Math.max(label.length, maxLabelLength);
    446    });
    447 
    448    // The popup should be as wide as its longest item.
    449    // We need to account for the inline padding
    450    const fragmentClone = fragment.cloneNode(true);
    451    let width = `calc(${
    452      maxLabelLength + 3
    453    }ch + 2 * var(--autocomplete-item-padding-inline, 0px))`;
    454    // As well as add more space if we're displaying color swatches
    455    if (fragment.querySelector(".autocomplete-colorswatch")) {
    456      width = `calc(${width} + var(--autocomplete-item-color-swatch-size) + 2 * var(--autocomplete-item-color-swatch-margin-inline))`;
    457    }
    458    this.list.style.width = width;
    459    this.list.appendChild(fragment);
    460    // Update the clone content to match the current list content.
    461    this.#listClone.appendChild(fragmentClone);
    462 
    463    this.selectItemAtIndex(selectedIndex, options);
    464  }
    465 
    466  #scrollElementIntoViewIfNeeded(element) {
    467    const quads = element.getBoxQuads({
    468      relativeTo: this.tooltip.panel,
    469      createFramesForSuppressedWhitespace: false,
    470    });
    471    if (!quads || !quads[0]) {
    472      return;
    473    }
    474 
    475    const { top, height } = quads[0].getBounds();
    476    const containerHeight = this.tooltip.panel.getBoundingClientRect().height;
    477    if (top < 0) {
    478      // Element is above container.
    479      element.scrollIntoView(true);
    480    } else if (top + height > containerHeight) {
    481      // Element is below container.
    482      element.scrollIntoView(false);
    483    }
    484  }
    485 
    486  /**
    487   * Clear all the items from the autocomplete list.
    488   */
    489  clearItems() {
    490    if (this.#list) {
    491      this.#list.innerHTML = "";
    492    }
    493    if (this.#listClone) {
    494      this.#listClone.innerHTML = "";
    495    }
    496 
    497    this.items = [];
    498    this.elements = new WeakMap();
    499    this.selectItemAtIndex(-1);
    500  }
    501 
    502  /**
    503   * Getter for the selected item.
    504   *
    505   * @type Object
    506   */
    507  get selectedItem() {
    508    return this.items[this.selectedIndex];
    509  }
    510 
    511  /**
    512   * Setter for the selected item.
    513   *
    514   * @param {object} item
    515   *        The object you want selected in the list.
    516   */
    517  set selectedItem(item) {
    518    const index = this.items.indexOf(item);
    519    if (index !== -1 && this.isOpen) {
    520      this.selectItemAtIndex(index);
    521    }
    522  }
    523 
    524  /**
    525   * Update the aria-activedescendant attribute on the current active element for
    526   * accessibility.
    527   *
    528   * @param {string} id
    529   *        The id (as in DOM id) of the currently selected autocomplete suggestion
    530   */
    531  #setActiveDescendant(id) {
    532    if (!this.#activeElement) {
    533      return;
    534    }
    535 
    536    // Make sure the list clone is in the same document as the anchor.
    537    const anchorDoc = this.#activeElement.ownerDocument;
    538    if (
    539      !this.#listClone.parentNode ||
    540      this.#listClone.ownerDocument !== anchorDoc
    541    ) {
    542      anchorDoc.documentElement.appendChild(this.#listClone);
    543    }
    544 
    545    this.#activeElement.setAttribute("aria-activedescendant", id);
    546  }
    547 
    548  /**
    549   * Clear the aria-activedescendant attribute on the current active element.
    550   */
    551  #clearActiveDescendant() {
    552    if (!this.#activeElement) {
    553      return;
    554    }
    555 
    556    this.#activeElement.removeAttribute("aria-activedescendant");
    557  }
    558 
    559  #createListItem(item, index, selected) {
    560    const listItem = this.#document.createElementNS(HTML_NS, "li");
    561    // Items must have an id for accessibility.
    562    listItem.setAttribute("id", "autocomplete-item-" + itemIdCounter++);
    563    listItem.classList.add("autocomplete-item");
    564    if (selected) {
    565      listItem.classList.add("autocomplete-selected");
    566    }
    567    listItem.setAttribute("data-index", index);
    568 
    569    if (this.direction) {
    570      listItem.setAttribute("dir", this.direction);
    571    }
    572 
    573    const label = this.#document.createElementNS(HTML_NS, "span");
    574    label.textContent = item.label;
    575    label.className = "autocomplete-value";
    576 
    577    if (item.preLabel) {
    578      const preDesc = this.#document.createElementNS(HTML_NS, "span");
    579      preDesc.textContent = item.preLabel;
    580      preDesc.className = "initial-value";
    581      listItem.appendChild(preDesc);
    582      label.textContent = item.label.slice(item.preLabel.length);
    583    }
    584 
    585    listItem.appendChild(label);
    586 
    587    if (item.postLabel) {
    588      const postDesc = this.#document.createElementNS(HTML_NS, "span");
    589      postDesc.className = "autocomplete-postlabel";
    590      postDesc.textContent = item.postLabel;
    591      // Determines if the postlabel is a valid colour or other value
    592      if (this.#isValidColor(item.postLabel)) {
    593        const colorSwatch = this.#document.createElementNS(HTML_NS, "span");
    594        colorSwatch.className = "autocomplete-swatch autocomplete-colorswatch";
    595        colorSwatch.style.cssText = "background-color: " + item.postLabel;
    596        postDesc.insertBefore(colorSwatch, postDesc.childNodes[0]);
    597      }
    598      listItem.appendChild(postDesc);
    599    }
    600 
    601    if (item.count && item.count > 1) {
    602      const countDesc = this.#document.createElementNS(HTML_NS, "span");
    603      countDesc.textContent = item.count;
    604      countDesc.setAttribute("flex", "1");
    605      countDesc.className = "autocomplete-count";
    606      listItem.appendChild(countDesc);
    607    }
    608 
    609    return listItem;
    610  }
    611 
    612  /**
    613   * Getter for the number of items in the popup.
    614   *
    615   * @type {number}
    616   */
    617  get itemCount() {
    618    return this.items.length;
    619  }
    620 
    621  /**
    622   * Getter for the height of each item in the list.
    623   *
    624   * @type {number}
    625   */
    626  get #itemsPerPane() {
    627    if (this.items.length) {
    628      const listHeight = this.tooltip.panel.clientHeight;
    629      const element = this.elements.get(this.items[0]);
    630      const elementHeight = element.getBoundingClientRect().height;
    631      return Math.floor(listHeight / elementHeight);
    632    }
    633    return 0;
    634  }
    635 
    636  /**
    637   * Select the next item in the list.
    638   *
    639   * @return {object}
    640   *         The newly selected item object.
    641   */
    642  selectNextItem() {
    643    if (this.selectedIndex < this.items.length - 1) {
    644      this.selectItemAtIndex(this.selectedIndex + 1);
    645    } else {
    646      this.selectItemAtIndex(0);
    647    }
    648    return this.selectedItem;
    649  }
    650 
    651  /**
    652   * Select the previous item in the list.
    653   *
    654   * @return {object}
    655   *         The newly-selected item object.
    656   */
    657  selectPreviousItem() {
    658    if (this.selectedIndex > 0) {
    659      this.selectItemAtIndex(this.selectedIndex - 1);
    660    } else {
    661      this.selectItemAtIndex(this.items.length - 1);
    662    }
    663 
    664    return this.selectedItem;
    665  }
    666 
    667  /**
    668   * Select the top-most item in the next page of items or
    669   * the last item in the list.
    670   *
    671   * @return {object}
    672   *         The newly-selected item object.
    673   */
    674  selectNextPageItem() {
    675    const nextPageIndex = this.selectedIndex + this.#itemsPerPane + 1;
    676    this.selectItemAtIndex(Math.min(nextPageIndex, this.itemCount - 1));
    677    return this.selectedItem;
    678  }
    679 
    680  /**
    681   * Select the bottom-most item in the previous page of items,
    682   * or the first item in the list.
    683   *
    684   * @return {object}
    685   *         The newly-selected item object.
    686   */
    687  selectPreviousPageItem() {
    688    const prevPageIndex = this.selectedIndex - this.#itemsPerPane - 1;
    689    this.selectItemAtIndex(Math.max(prevPageIndex, 0));
    690    return this.selectedItem;
    691  }
    692 
    693  /**
    694   * Determines if the specified colour object is a valid colour, and if
    695   * it is not a "special value"
    696   *
    697   * @return {boolean}
    698   *         If the object represents a proper colour or not.
    699   */
    700  #isValidColor(color) {
    701    const colorObj = new colorUtils.CssColor(color);
    702    return colorObj.valid && !colorObj.specialValue;
    703  }
    704 }
    705 
    706 module.exports = AutocompletePopup;