browser/devtools/shared/autocomplete-popup.js

changeset 0
6474c204b198
     1.1 --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
     1.2 +++ b/browser/devtools/shared/autocomplete-popup.js	Wed Dec 31 06:09:35 2014 +0100
     1.3 @@ -0,0 +1,567 @@
     1.4 +/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
     1.5 +/* This Source Code Form is subject to the terms of the Mozilla Public
     1.6 + * License, v. 2.0. If a copy of the MPL was not distributed with this
     1.7 + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
     1.8 +
     1.9 +"use strict";
    1.10 +
    1.11 +const {Cc, Ci, Cu} = require("chrome");
    1.12 +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
    1.13 +
    1.14 +loader.lazyImporter(this, "Services", "resource://gre/modules/Services.jsm");
    1.15 +loader.lazyImporter(this, "gDevTools", "resource:///modules/devtools/gDevTools.jsm");
    1.16 +
    1.17 +/**
    1.18 + * Autocomplete popup UI implementation.
    1.19 + *
    1.20 + * @constructor
    1.21 + * @param nsIDOMDocument aDocument
    1.22 + *        The document you want the popup attached to.
    1.23 + * @param Object aOptions
    1.24 + *        An object consiting any of the following options:
    1.25 + *        - panelId {String} The id for the popup panel.
    1.26 + *        - listBoxId {String} The id for the richlistbox inside the panel.
    1.27 + *        - position {String} The position for the popup panel.
    1.28 + *        - theme {String} String related to the theme of the popup.
    1.29 + *        - autoSelect {Boolean} Boolean to allow the first entry of the popup
    1.30 + *                     panel to be automatically selected when the popup shows.
    1.31 + *        - direction {String} The direction of the text in the panel. rtl or ltr
    1.32 + *        - onSelect {String} The select event handler for the richlistbox
    1.33 + *        - onClick {String} The click event handler for the richlistbox.
    1.34 + *        - onKeypress {String} The keypress event handler for the richlistitems.
    1.35 + */
    1.36 +function AutocompletePopup(aDocument, aOptions = {})
    1.37 +{
    1.38 +  this._document = aDocument;
    1.39 +
    1.40 +  this.autoSelect = aOptions.autoSelect || false;
    1.41 +  this.position = aOptions.position || "after_start";
    1.42 +  this.direction = aOptions.direction || "ltr";
    1.43 +
    1.44 +  this.onSelect = aOptions.onSelect;
    1.45 +  this.onClick = aOptions.onClick;
    1.46 +  this.onKeypress = aOptions.onKeypress;
    1.47 +
    1.48 +  let id = aOptions.panelId || "devtools_autoCompletePopup";
    1.49 +  let theme = aOptions.theme || "dark";
    1.50 +  // If theme is auto, use the devtools.theme pref
    1.51 +  if (theme == "auto") {
    1.52 +    theme = Services.prefs.getCharPref("devtools.theme");
    1.53 +    this.autoThemeEnabled = true;
    1.54 +    // Setup theme change listener.
    1.55 +    this._handleThemeChange = this._handleThemeChange.bind(this);
    1.56 +    gDevTools.on("pref-changed", this._handleThemeChange);
    1.57 +  }
    1.58 +  // Reuse the existing popup elements.
    1.59 +  this._panel = this._document.getElementById(id);
    1.60 +  if (!this._panel) {
    1.61 +    this._panel = this._document.createElementNS(XUL_NS, "panel");
    1.62 +    this._panel.setAttribute("id", id);
    1.63 +    this._panel.className = "devtools-autocomplete-popup devtools-monospace "
    1.64 +                            + theme + "-theme";
    1.65 +
    1.66 +    this._panel.setAttribute("noautofocus", "true");
    1.67 +    this._panel.setAttribute("level", "top");
    1.68 +    if (!aOptions.onKeypress) {
    1.69 +      this._panel.setAttribute("ignorekeys", "true");
    1.70 +    }
    1.71 +
    1.72 +    let mainPopupSet = this._document.getElementById("mainPopupSet");
    1.73 +    if (mainPopupSet) {
    1.74 +      mainPopupSet.appendChild(this._panel);
    1.75 +    }
    1.76 +    else {
    1.77 +      this._document.documentElement.appendChild(this._panel);
    1.78 +    }
    1.79 +  }
    1.80 +  else {
    1.81 +    this._list = this._panel.firstChild;
    1.82 +  }
    1.83 +
    1.84 +  if (!this._list) {
    1.85 +    this._list = this._document.createElementNS(XUL_NS, "richlistbox");
    1.86 +    this._panel.appendChild(this._list);
    1.87 +
    1.88 +    // Open and hide the panel, so we initialize the API of the richlistbox.
    1.89 +    this._panel.openPopup(null, this.position, 0, 0);
    1.90 +    this._panel.hidePopup();
    1.91 +  }
    1.92 +
    1.93 +  this._list.setAttribute("flex", "1");
    1.94 +  this._list.setAttribute("seltype", "single");
    1.95 +
    1.96 +  if (aOptions.listBoxId) {
    1.97 +    this._list.setAttribute("id", aOptions.listBoxId);
    1.98 +  }
    1.99 +  this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
   1.100 +
   1.101 +  if (this.onSelect) {
   1.102 +    this._list.addEventListener("select", this.onSelect, false);
   1.103 +  }
   1.104 +
   1.105 +  if (this.onClick) {
   1.106 +    this._list.addEventListener("click", this.onClick, false);
   1.107 +  }
   1.108 +
   1.109 +  if (this.onKeypress) {
   1.110 +    this._list.addEventListener("keypress", this.onKeypress, false);
   1.111 +  }
   1.112 +}
   1.113 +exports.AutocompletePopup = AutocompletePopup;
   1.114 +
   1.115 +AutocompletePopup.prototype = {
   1.116 +  _document: null,
   1.117 +  _panel: null,
   1.118 +  _list: null,
   1.119 +  __scrollbarWidth: null,
   1.120 +
   1.121 +  // Event handlers.
   1.122 +  onSelect: null,
   1.123 +  onClick: null,
   1.124 +  onKeypress: null,
   1.125 +
   1.126 +  /**
   1.127 +   * Open the autocomplete popup panel.
   1.128 +   *
   1.129 +   * @param nsIDOMNode aAnchor
   1.130 +   *        Optional node to anchor the panel to.
   1.131 +   * @param Number aXOffset
   1.132 +   *        Horizontal offset in pixels from the left of the node to the left
   1.133 +   *        of the popup.
   1.134 +   * @param Number aYOffset
   1.135 +   *        Vertical offset in pixels from the top of the node to the starting
   1.136 +   *        of the popup.
   1.137 +   */
   1.138 +  openPopup: function AP_openPopup(aAnchor, aXOffset = 0, aYOffset = 0)
   1.139 +  {
   1.140 +    this.__maxLabelLength = -1;
   1.141 +    this._updateSize();
   1.142 +    this._panel.openPopup(aAnchor, this.position, aXOffset, aYOffset);
   1.143 +
   1.144 +    if (this.autoSelect) {
   1.145 +      this.selectFirstItem();
   1.146 +    }
   1.147 +  },
   1.148 +
   1.149 +  /**
   1.150 +   * Hide the autocomplete popup panel.
   1.151 +   */
   1.152 +  hidePopup: function AP_hidePopup()
   1.153 +  {
   1.154 +    this._panel.hidePopup();
   1.155 +  },
   1.156 +
   1.157 +  /**
   1.158 +   * Check if the autocomplete popup is open.
   1.159 +   */
   1.160 +  get isOpen() {
   1.161 +    return this._panel.state == "open" || this._panel.state == "showing";
   1.162 +  },
   1.163 +
   1.164 +  /**
   1.165 +   * Destroy the object instance. Please note that the panel DOM elements remain
   1.166 +   * in the DOM, because they might still be in use by other instances of the
   1.167 +   * same code. It is the responsability of the client code to perform DOM
   1.168 +   * cleanup.
   1.169 +   */
   1.170 +  destroy: function AP_destroy()
   1.171 +  {
   1.172 +    if (this.isOpen) {
   1.173 +      this.hidePopup();
   1.174 +    }
   1.175 +    this.clearItems();
   1.176 +
   1.177 +    if (this.onSelect) {
   1.178 +      this._list.removeEventListener("select", this.onSelect, false);
   1.179 +    }
   1.180 +
   1.181 +    if (this.onClick) {
   1.182 +      this._list.removeEventListener("click", this.onClick, false);
   1.183 +    }
   1.184 +
   1.185 +    if (this.onKeypress) {
   1.186 +      this._list.removeEventListener("keypress", this.onKeypress, false);
   1.187 +    }
   1.188 +
   1.189 +    if (this.autoThemeEnabled) {
   1.190 +      gDevTools.off("pref-changed", this._handleThemeChange);
   1.191 +    }
   1.192 +
   1.193 +    this._document = null;
   1.194 +    this._list = null;
   1.195 +    this._panel = null;
   1.196 +  },
   1.197 +
   1.198 +  /**
   1.199 +   * Get the autocomplete items array.
   1.200 +   *
   1.201 +   * @param Number aIndex The index of the item what is wanted.
   1.202 +   *
   1.203 +   * @return The autocomplete item at index aIndex.
   1.204 +   */
   1.205 +  getItemAtIndex: function AP_getItemAtIndex(aIndex)
   1.206 +  {
   1.207 +    return this._list.getItemAtIndex(aIndex)._autocompleteItem;
   1.208 +  },
   1.209 +
   1.210 +  /**
   1.211 +   * Get the autocomplete items array.
   1.212 +   *
   1.213 +   * @return array
   1.214 +   *         The array of autocomplete items.
   1.215 +   */
   1.216 +  getItems: function AP_getItems()
   1.217 +  {
   1.218 +    let items = [];
   1.219 +
   1.220 +    Array.forEach(this._list.childNodes, function(aItem) {
   1.221 +      items.push(aItem._autocompleteItem);
   1.222 +    });
   1.223 +
   1.224 +    return items;
   1.225 +  },
   1.226 +
   1.227 +  /**
   1.228 +   * Set the autocomplete items list, in one go.
   1.229 +   *
   1.230 +   * @param array aItems
   1.231 +   *        The list of items you want displayed in the popup list.
   1.232 +   */
   1.233 +  setItems: function AP_setItems(aItems)
   1.234 +  {
   1.235 +    this.clearItems();
   1.236 +    aItems.forEach(this.appendItem, this);
   1.237 +
   1.238 +    // Make sure that the new content is properly fitted by the XUL richlistbox.
   1.239 +    if (this.isOpen) {
   1.240 +      if (this.autoSelect) {
   1.241 +        this.selectFirstItem();
   1.242 +      }
   1.243 +      this._updateSize();
   1.244 +    }
   1.245 +  },
   1.246 +
   1.247 +  /**
   1.248 +   * Selects the first item of the richlistbox. Note that first item here is the
   1.249 +   * item closes to the input element, which means that 0th index if position is
   1.250 +   * below, and last index if position is above.
   1.251 +   */
   1.252 +  selectFirstItem: function AP_selectFirstItem()
   1.253 +  {
   1.254 +    if (this.position.contains("before")) {
   1.255 +      this.selectedIndex = this.itemCount - 1;
   1.256 +    }
   1.257 +    else {
   1.258 +      this.selectedIndex = 0;
   1.259 +    }
   1.260 +    this._list.ensureIndexIsVisible(this._list.selectedIndex);
   1.261 +  },
   1.262 +
   1.263 +  __maxLabelLength: -1,
   1.264 +
   1.265 +  get _maxLabelLength() {
   1.266 +    if (this.__maxLabelLength != -1) {
   1.267 +      return this.__maxLabelLength;
   1.268 +    }
   1.269 +
   1.270 +    let max = 0;
   1.271 +    for (let i = 0; i < this._list.childNodes.length; i++) {
   1.272 +      let item = this._list.childNodes[i]._autocompleteItem;
   1.273 +      let str = item.label;
   1.274 +      if (item.count) {
   1.275 +        str += (item.count + "");
   1.276 +      }
   1.277 +      max = Math.max(str.length, max);
   1.278 +    }
   1.279 +
   1.280 +    this.__maxLabelLength = max;
   1.281 +    return this.__maxLabelLength;
   1.282 +  },
   1.283 +
   1.284 +  /**
   1.285 +   * Update the panel size to fit the content.
   1.286 +   *
   1.287 +   * @private
   1.288 +   */
   1.289 +  _updateSize: function AP__updateSize()
   1.290 +  {
   1.291 +    if (!this._panel) {
   1.292 +      return;
   1.293 +    }
   1.294 +
   1.295 +    this._list.style.width = (this._maxLabelLength + 3) +"ch";
   1.296 +    this._list.ensureIndexIsVisible(this._list.selectedIndex);
   1.297 +  },
   1.298 +
   1.299 +  /**
   1.300 +   * Clear all the items from the autocomplete list.
   1.301 +   */
   1.302 +  clearItems: function AP_clearItems()
   1.303 +  {
   1.304 +    // Reset the selectedIndex to -1 before clearing the list
   1.305 +    this.selectedIndex = -1;
   1.306 +
   1.307 +    while (this._list.hasChildNodes()) {
   1.308 +      this._list.removeChild(this._list.firstChild);
   1.309 +    }
   1.310 +
   1.311 +    this.__maxLabelLength = -1;
   1.312 +
   1.313 +    // Reset the panel and list dimensions. New dimensions are calculated when
   1.314 +    // a new set of items is added to the autocomplete popup.
   1.315 +    this._list.width = "";
   1.316 +    this._list.style.width = "";
   1.317 +    this._list.height = "";
   1.318 +    this._panel.width = "";
   1.319 +    this._panel.height = "";
   1.320 +    this._panel.top = "";
   1.321 +    this._panel.left = "";
   1.322 +  },
   1.323 +
   1.324 +  /**
   1.325 +   * Getter for the index of the selected item.
   1.326 +   *
   1.327 +   * @type number
   1.328 +   */
   1.329 +  get selectedIndex() {
   1.330 +    return this._list.selectedIndex;
   1.331 +  },
   1.332 +
   1.333 +  /**
   1.334 +   * Setter for the selected index.
   1.335 +   *
   1.336 +   * @param number aIndex
   1.337 +   *        The number (index) of the item you want to select in the list.
   1.338 +   */
   1.339 +  set selectedIndex(aIndex) {
   1.340 +    this._list.selectedIndex = aIndex;
   1.341 +    if (this.isOpen && this._list.ensureIndexIsVisible) {
   1.342 +      this._list.ensureIndexIsVisible(this._list.selectedIndex);
   1.343 +    }
   1.344 +  },
   1.345 +
   1.346 +  /**
   1.347 +   * Getter for the selected item.
   1.348 +   * @type object
   1.349 +   */
   1.350 +  get selectedItem() {
   1.351 +    return this._list.selectedItem ?
   1.352 +           this._list.selectedItem._autocompleteItem : null;
   1.353 +  },
   1.354 +
   1.355 +  /**
   1.356 +   * Setter for the selected item.
   1.357 +   *
   1.358 +   * @param object aItem
   1.359 +   *        The object you want selected in the list.
   1.360 +   */
   1.361 +  set selectedItem(aItem) {
   1.362 +    this._list.selectedItem = this._findListItem(aItem);
   1.363 +    if (this.isOpen) {
   1.364 +      this._list.ensureIndexIsVisible(this._list.selectedIndex);
   1.365 +    }
   1.366 +  },
   1.367 +
   1.368 +  /**
   1.369 +   * Append an item into the autocomplete list.
   1.370 +   *
   1.371 +   * @param object aItem
   1.372 +   *        The item you want appended to the list.
   1.373 +   *        The item object can have the following properties:
   1.374 +   *        - label {String} Property which is used as the displayed value.
   1.375 +   *        - preLabel {String} [Optional] The String that will be displayed
   1.376 +   *                   before the label indicating that this is the already
   1.377 +   *                   present text in the input box, and label is the text
   1.378 +   *                   that will be auto completed. When this property is
   1.379 +   *                   present, |preLabel.length| starting characters will be
   1.380 +   *                   removed from label.
   1.381 +   *        - count {Number} [Optional] The number to represent the count of
   1.382 +   *                autocompleted label.
   1.383 +   */
   1.384 +  appendItem: function AP_appendItem(aItem)
   1.385 +  {
   1.386 +    let listItem = this._document.createElementNS(XUL_NS, "richlistitem");
   1.387 +    if (this.direction) {
   1.388 +      listItem.setAttribute("dir", this.direction);
   1.389 +    }
   1.390 +    let label = this._document.createElementNS(XUL_NS, "label");
   1.391 +    label.setAttribute("value", aItem.label);
   1.392 +    label.setAttribute("class", "autocomplete-value");
   1.393 +    if (aItem.preLabel) {
   1.394 +      let preDesc = this._document.createElementNS(XUL_NS, "label");
   1.395 +      preDesc.setAttribute("value", aItem.preLabel);
   1.396 +      preDesc.setAttribute("class", "initial-value");
   1.397 +      listItem.appendChild(preDesc);
   1.398 +      label.setAttribute("value", aItem.label.slice(aItem.preLabel.length));
   1.399 +    }
   1.400 +    listItem.appendChild(label);
   1.401 +    if (aItem.count && aItem.count > 1) {
   1.402 +      let countDesc = this._document.createElementNS(XUL_NS, "label");
   1.403 +      countDesc.setAttribute("value", aItem.count);
   1.404 +      countDesc.setAttribute("flex", "1");
   1.405 +      countDesc.setAttribute("class", "autocomplete-count");
   1.406 +      listItem.appendChild(countDesc);
   1.407 +    }
   1.408 +    listItem._autocompleteItem = aItem;
   1.409 +
   1.410 +    this._list.appendChild(listItem);
   1.411 +  },
   1.412 +
   1.413 +  /**
   1.414 +   * Find the richlistitem element that belongs to an item.
   1.415 +   *
   1.416 +   * @private
   1.417 +   *
   1.418 +   * @param object aItem
   1.419 +   *        The object you want found in the list.
   1.420 +   *
   1.421 +   * @return nsIDOMNode|null
   1.422 +   *         The nsIDOMNode that belongs to the given item object. This node is
   1.423 +   *         the richlistitem element.
   1.424 +   */
   1.425 +  _findListItem: function AP__findListItem(aItem)
   1.426 +  {
   1.427 +    for (let i = 0; i < this._list.childNodes.length; i++) {
   1.428 +      let child = this._list.childNodes[i];
   1.429 +      if (child._autocompleteItem == aItem) {
   1.430 +        return child;
   1.431 +      }
   1.432 +    }
   1.433 +    return null;
   1.434 +  },
   1.435 +
   1.436 +  /**
   1.437 +   * Remove an item from the popup list.
   1.438 +   *
   1.439 +   * @param object aItem
   1.440 +   *        The item you want removed.
   1.441 +   */
   1.442 +  removeItem: function AP_removeItem(aItem)
   1.443 +  {
   1.444 +    let item = this._findListItem(aItem);
   1.445 +    if (!item) {
   1.446 +      throw new Error("Item not found!");
   1.447 +    }
   1.448 +    this._list.removeChild(item);
   1.449 +  },
   1.450 +
   1.451 +  /**
   1.452 +   * Getter for the number of items in the popup.
   1.453 +   * @type number
   1.454 +   */
   1.455 +  get itemCount() {
   1.456 +    return this._list.childNodes.length;
   1.457 +  },
   1.458 +
   1.459 +  /**
   1.460 +   * Getter for the height of each item in the list.
   1.461 +   *
   1.462 +   * @private
   1.463 +   *
   1.464 +   * @type number
   1.465 +   */
   1.466 +  get _itemHeight() {
   1.467 +    return this._list.selectedItem.clientHeight;
   1.468 +  },
   1.469 +
   1.470 +  /**
   1.471 +   * Select the next item in the list.
   1.472 +   *
   1.473 +   * @return object
   1.474 +   *         The newly selected item object.
   1.475 +   */
   1.476 +  selectNextItem: function AP_selectNextItem()
   1.477 +  {
   1.478 +    if (this.selectedIndex < (this.itemCount - 1)) {
   1.479 +      this.selectedIndex++;
   1.480 +    }
   1.481 +    else {
   1.482 +      this.selectedIndex = 0;
   1.483 +    }
   1.484 +
   1.485 +    return this.selectedItem;
   1.486 +  },
   1.487 +
   1.488 +  /**
   1.489 +   * Select the previous item in the list.
   1.490 +   *
   1.491 +   * @return object
   1.492 +   *         The newly-selected item object.
   1.493 +   */
   1.494 +  selectPreviousItem: function AP_selectPreviousItem()
   1.495 +  {
   1.496 +    if (this.selectedIndex > 0) {
   1.497 +      this.selectedIndex--;
   1.498 +    }
   1.499 +    else {
   1.500 +      this.selectedIndex = this.itemCount - 1;
   1.501 +    }
   1.502 +
   1.503 +    return this.selectedItem;
   1.504 +  },
   1.505 +
   1.506 +  /**
   1.507 +   * Select the top-most item in the next page of items or
   1.508 +   * the last item in the list.
   1.509 +   *
   1.510 +   * @return object
   1.511 +   *         The newly-selected item object.
   1.512 +   */
   1.513 +  selectNextPageItem: function AP_selectNextPageItem()
   1.514 +  {
   1.515 +    let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
   1.516 +    let nextPageIndex = this.selectedIndex + itemsPerPane + 1;
   1.517 +    this.selectedIndex = nextPageIndex > this.itemCount - 1 ?
   1.518 +      this.itemCount - 1 : nextPageIndex;
   1.519 +
   1.520 +    return this.selectedItem;
   1.521 +  },
   1.522 +
   1.523 +  /**
   1.524 +   * Select the bottom-most item in the previous page of items,
   1.525 +   * or the first item in the list.
   1.526 +   *
   1.527 +   * @return object
   1.528 +   *         The newly-selected item object.
   1.529 +   */
   1.530 +  selectPreviousPageItem: function AP_selectPreviousPageItem()
   1.531 +  {
   1.532 +    let itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
   1.533 +    let prevPageIndex = this.selectedIndex - itemsPerPane - 1;
   1.534 +    this.selectedIndex = prevPageIndex < 0 ? 0 : prevPageIndex;
   1.535 +
   1.536 +    return this.selectedItem;
   1.537 +  },
   1.538 +
   1.539 +  /**
   1.540 +   * Focuses the richlistbox.
   1.541 +   */
   1.542 +  focus: function AP_focus()
   1.543 +  {
   1.544 +    this._list.focus();
   1.545 +  },
   1.546 +
   1.547 +  /**
   1.548 +   * Manages theme switching for the popup based on the devtools.theme pref.
   1.549 +   *
   1.550 +   * @private
   1.551 +   *
   1.552 +   * @param String aEvent
   1.553 +   *        The name of the event. In this case, "pref-changed".
   1.554 +   * @param Object aData
   1.555 +   *        An object passed by the emitter of the event. In this case, the
   1.556 +   *        object consists of three properties:
   1.557 +   *        - pref {String} The name of the preference that was modified.
   1.558 +   *        - newValue {Object} The new value of the preference.
   1.559 +   *        - oldValue {Object} The old value of the preference.
   1.560 +   */
   1.561 +  _handleThemeChange: function AP__handleThemeChange(aEvent, aData)
   1.562 +  {
   1.563 +    if (aData.pref == "devtools.theme") {
   1.564 +      this._panel.classList.toggle(aData.oldValue + "-theme", false);
   1.565 +      this._panel.classList.toggle(aData.newValue + "-theme", true);
   1.566 +      this._list.classList.toggle(aData.oldValue + "-theme", false);
   1.567 +      this._list.classList.toggle(aData.newValue + "-theme", true);
   1.568 +    }
   1.569 +  },
   1.570 +};

mercurial