Bug 1266456 - part9: use HTMLTooltip for autocomplete-popup;r=bgrins draft
authorJulian Descottes <jdescottes@mozilla.com>
Fri, 01 Jul 2016 14:57:47 +0200
changeset 385565 d38da891efc2eb2ba49c82c1f8dbcd3861a49b75
parent 385564 5e04aacd9d441ed19115805b7d4a7aff77816c2e
child 385566 7fe78b2e3c59581f752be458e8d45f402b13d4e2
push id22536
push userjdescottes@mozilla.com
push dateFri, 08 Jul 2016 16:44:25 +0000
reviewersbgrins
bugs1266456
milestone50.0a1
Bug 1266456 - part9: use HTMLTooltip for autocomplete-popup;r=bgrins Modify the devtools autocomplete-popup to rely on a HTMLTooltip instance instead of a XUL panel. Other than the straightforward migration to HTML, the main difference with the new implementation is that the richlistbox has now been replace with a simple HTML list element. The former XUL widget used to be able to take the focus from the input it was linked to. This is no longer the case. Most autocomplete users were always keeping the focus in the input, except for the inspector-search, which was moving the focus back and forth between the input and the autocomplete's richlistbox. Now the focus is always in the input. A practical example to illustrate how this changes the UX: before when the user had the focus on the first element of the list, pressing "DOWN" would keep the element selected but visually move the focus in the input. Now the selection simply cycles to the next item. Even though this introduces a difference in behaviour compared to the previous implementation, it makes the inspector search UX consistent with the other autocomplete widgets used in devtools. Another difference is about the display for the inspector-search. The position of the autocomplete popup used to be above the input. This is now impossible to achieve because the search input is at the top of the toolbox and the HTML tooltip can not exceed the limits of the toolbox. For this #2 issue, either we manage to use XUL panel wrappers, in which case, the autocomplete will be displayed as it used to. Or we can invert the order in which items are inserted and explicitly ask for the autocomplete to be displayed below the input. I prefered not to change this here in order to make the code change easier to understand, but it should be addressed in a follow-up. MozReview-Commit-ID: jH9aXm9Jvz
devtools/client/debugger/content/views/sources-view.js
devtools/client/inspector/inspector-search.js
devtools/client/inspector/markup/markup.js
devtools/client/inspector/rules/rules.js
devtools/client/shared/autocomplete-popup.js
devtools/client/shared/inplace-editor.js
devtools/client/shared/widgets/HTMLTooltip.js
devtools/client/sourceeditor/autocomplete.js
devtools/client/themes/common.css
devtools/client/themes/tooltips.css
devtools/client/webconsole/jsterm.js
--- a/devtools/client/debugger/content/views/sources-view.js
+++ b/devtools/client/debugger/content/views/sources-view.js
@@ -694,17 +694,16 @@ SourcesView.prototype = Heritage.extend(
     let bp = getBreakpoint(this.getState(), attachment);
     let expr = (bp ? (bp.condition || "") : "");
     let cbPanel = this._cbPanel;
 
     // Update the conditional expression textbox. If no expression was
     // previously set, revert to using an empty string by default.
     this._cbTextbox.value = expr;
 
-
     function openPopup() {
       // Show the conditional expression panel. The popup arrow should be pointing
       // at the line number node in the breakpoint item view.
       cbPanel.hidden = false;
       cbPanel.openPopup(breakpointItem.attachment.view.lineNumber,
                               BREAKPOINT_CONDITIONAL_POPUP_POSITION,
                               BREAKPOINT_CONDITIONAL_POPUP_OFFSET_X,
                               BREAKPOINT_CONDITIONAL_POPUP_OFFSET_Y);
--- a/devtools/client/inspector/inspector-search.js
+++ b/devtools/client/inspector/inspector-search.js
@@ -74,17 +74,17 @@ InspectorSearch.prototype = {
         this.emit("search-cleared");
       }
       return;
     }
 
     let res = yield this.walker.search(query, { reverse });
 
     // Value has changed since we started this request, we're done.
-    if (query != this.searchBox.value) {
+    if (query !== this.searchBox.value) {
       return;
     }
 
     if (res) {
       this.inspector.selection.setNodeFront(res.node, "inspectorsearch");
       this.searchBox.classList.remove("devtools-no-search-result");
 
       res.query = query;
@@ -137,31 +137,29 @@ InspectorSearch.prototype = {
  */
 function SelectorAutocompleter(inspector, inputNode) {
   this.inspector = inspector;
   this.searchBox = inputNode;
   this.panelDoc = this.searchBox.ownerDocument;
 
   this.showSuggestions = this.showSuggestions.bind(this);
   this._onSearchKeypress = this._onSearchKeypress.bind(this);
-  this._onListBoxKeypress = this._onListBoxKeypress.bind(this);
+  this._onSearchPopupClick = this._onSearchPopupClick.bind(this);
   this._onMarkupMutation = this._onMarkupMutation.bind(this);
 
   // Options for the AutocompletePopup.
   let options = {
-    panelId: "inspector-searchbox-panel",
-    listBoxId: "searchbox-panel-listbox",
+    listId: "searchbox-panel-listbox",
     autoSelect: true,
-    position: "before_start",
-    direction: "ltr",
+    position: "top",
     theme: "auto",
-    onClick: this._onListBoxKeypress,
-    onKeypress: this._onListBoxKeypress
+    onClick: this._onSearchPopupClick,
   };
-  this.searchPopup = new AutocompletePopup(this.panelDoc, options);
+
+  this.searchPopup = new AutocompletePopup(inspector._toolbox, options);
 
   this.searchBox.addEventListener("input", this.showSuggestions, true);
   this.searchBox.addEventListener("keypress", this._onSearchKeypress, true);
   this.inspector.on("markupmutation", this._onMarkupMutation);
 
   // For testing, we need to be able to wait for the most recent node request
   // to finish.  Tests can watch this promise for that.
   this._lastQuery = promise.resolve(null);
@@ -226,67 +224,67 @@ SelectorAutocompleter.prototype = {
       subQuery = query.slice(0, i);
       let [secondLastChar, lastChar] = subQuery.slice(-2);
       switch (this._state) {
         case null:
           // This will happen only in the first iteration of the for loop.
           lastChar = secondLastChar;
 
         case this.States.TAG: // eslint-disable-line
-          if (lastChar == ".") {
+          if (lastChar === ".") {
             this._state = this.States.CLASS;
-          } else if (lastChar == "#") {
+          } else if (lastChar === "#") {
             this._state = this.States.ID;
-          } else if (lastChar == "[") {
+          } else if (lastChar === "[") {
             this._state = this.States.ATTRIBUTE;
           } else {
             this._state = this.States.TAG;
           }
           break;
 
         case this.States.CLASS:
           if (subQuery.match(/[\.]+[^\.]*$/)[0].length > 2) {
             // Checks whether the subQuery has atleast one [a-zA-Z] after the
             // '.'.
-            if (lastChar == " " || lastChar == ">") {
+            if (lastChar === " " || lastChar === ">") {
               this._state = this.States.TAG;
-            } else if (lastChar == "#") {
+            } else if (lastChar === "#") {
               this._state = this.States.ID;
-            } else if (lastChar == "[") {
+            } else if (lastChar === "[") {
               this._state = this.States.ATTRIBUTE;
             } else {
               this._state = this.States.CLASS;
             }
           }
           break;
 
         case this.States.ID:
           if (subQuery.match(/[#]+[^#]*$/)[0].length > 2) {
             // Checks whether the subQuery has atleast one [a-zA-Z] after the
             // '#'.
-            if (lastChar == " " || lastChar == ">") {
+            if (lastChar === " " || lastChar === ">") {
               this._state = this.States.TAG;
-            } else if (lastChar == ".") {
+            } else if (lastChar === ".") {
               this._state = this.States.CLASS;
-            } else if (lastChar == "[") {
+            } else if (lastChar === "[") {
               this._state = this.States.ATTRIBUTE;
             } else {
               this._state = this.States.ID;
             }
           }
           break;
 
         case this.States.ATTRIBUTE:
-          if (subQuery.match(/[\[][^\]]+[\]]/) != null) {
+          if (subQuery.match(/[\[][^\]]+[\]]/) !== null) {
             // Checks whether the subQuery has at least one ']' after the '['.
-            if (lastChar == " " || lastChar == ">") {
+            if (lastChar === " " || lastChar === ">") {
               this._state = this.States.TAG;
-            } else if (lastChar == ".") {
+            } else if (lastChar === ".") {
               this._state = this.States.CLASS;
-            } else if (lastChar == "#") {
+            } else if (lastChar === "#") {
               this._state = this.States.ID;
             } else {
               this._state = this.States.ATTRIBUTE;
             }
           }
           break;
       }
     }
@@ -306,135 +304,100 @@ SelectorAutocompleter.prototype = {
     this.searchBox = null;
     this.panelDoc = null;
   },
 
   /**
    * Handles keypresses inside the input box.
    */
   _onSearchKeypress: function (event) {
-    let query = this.searchBox.value;
     let popup = this.searchPopup;
 
     switch (event.keyCode) {
       case event.DOM_VK_RETURN:
       case event.DOM_VK_TAB:
-        if (popup.isOpen &&
-            popup.getItemAtIndex(popup.itemCount - 1)
-                .preLabel == query) {
-          popup.selectedIndex = popup.itemCount - 1;
-          this.searchBox.value = popup.selectedItem.label;
+        if (popup.isOpen) {
+          if (popup.selectedItem) {
+            this.searchBox.value = popup.selectedItem.label;
+          }
           this.hidePopup();
-        } else if (!popup.isOpen &&
-                   event.keyCode === event.DOM_VK_TAB) {
+        } else if (!popup.isOpen) {
           // When tab is pressed with focus on searchbox and closed popup,
           // do not prevent the default to avoid a keyboard trap and move focus
           // to next/previous element.
           this.emit("processing-done");
           return;
         }
         break;
 
       case event.DOM_VK_UP:
         if (popup.isOpen && popup.itemCount > 0) {
-          popup.focus();
-          if (popup.selectedIndex == popup.itemCount - 1) {
-            popup.selectedIndex =
-              Math.max(0, popup.itemCount - 2);
+          if (popup.selectedIndex === 0) {
+            popup.selectedIndex = popup.itemCount - 1;
           } else {
-            popup.selectedIndex = popup.itemCount - 1;
+            popup.selectedIndex--;
           }
           this.searchBox.value = popup.selectedItem.label;
         }
         break;
 
       case event.DOM_VK_DOWN:
         if (popup.isOpen && popup.itemCount > 0) {
-          popup.focus();
-          popup.selectedIndex = 0;
+          if (popup.selectedIndex === popup.itemCount - 1) {
+            popup.selectedIndex = 0;
+          } else {
+            popup.selectedIndex++;
+          }
           this.searchBox.value = popup.selectedItem.label;
         }
         break;
 
+      case event.DOM_VK_ESCAPE:
+        if (popup.isOpen) {
+          this.hidePopup();
+        }
+        break;
+
       default:
         return;
     }
 
     event.preventDefault();
     event.stopPropagation();
     this.emit("processing-done");
   },
 
   /**
-   * Handles keypress and mouse click on the suggestions richlistbox.
+   * Handles click events from the autocomplete popup.
    */
-  _onListBoxKeypress: function (event) {
-    let popup = this.searchPopup;
-
-    switch (event.keyCode || event.button) {
-      case event.DOM_VK_RETURN:
-      case event.DOM_VK_TAB:
-      case 0:
-        // left mouse button
-        event.stopPropagation();
-        event.preventDefault();
-        this.searchBox.value = popup.selectedItem.label;
-        this.searchBox.focus();
-        this.hidePopup();
-        break;
+  _onSearchPopupClick: function (event) {
+    let selectedItem = this.searchPopup.selectedItem;
+    if (selectedItem) {
+      this.searchBox.value = selectedItem.label;
+    }
+    this.hidePopup();
 
-      case event.DOM_VK_UP:
-        if (popup.selectedIndex == 0) {
-          popup.selectedIndex = -1;
-          event.stopPropagation();
-          event.preventDefault();
-          this.searchBox.focus();
-        } else {
-          let index = popup.selectedIndex;
-          this.searchBox.value = popup.getItemAtIndex(index - 1).label;
-        }
-        break;
-
-      case event.DOM_VK_DOWN:
-        if (popup.selectedIndex == popup.itemCount - 1) {
-          popup.selectedIndex = -1;
-          event.stopPropagation();
-          event.preventDefault();
-          this.searchBox.focus();
-        } else {
-          let index = popup.selectedIndex;
-          this.searchBox.value = popup.getItemAtIndex(index + 1).label;
-        }
-        break;
-
-      case event.DOM_VK_BACK_SPACE:
-        event.stopPropagation();
-        event.preventDefault();
-        this.searchBox.focus();
-        if (this.searchBox.selectionStart > 0) {
-          this.searchBox.value = this.searchBox.value.substring(0,
-            this.searchBox.selectionStart - 1);
-        }
-        this.hidePopup();
-        break;
-    }
-    this.emit("processing-done");
+    event.preventDefault();
+    event.stopPropagation();
   },
 
   /**
    * Reset previous search results on markup-mutations to make sure we search
    * again after nodes have been added/removed/changed.
    */
   _onMarkupMutation: function () {
     this._searchResults = null;
     this._lastSearched = null;
   },
 
   /**
    * Populates the suggestions list and show the suggestion popup.
+   *
+   * @return {Promise} promise that will resolve when the autocomplete popup is fully
+   * displayed or hidden.
    */
   _showPopup: function (list, firstPart, popupState) {
     let total = 0;
     let query = this.searchBox.value;
     let items = [];
 
     for (let [value, , state] of list) {
       if (query.match(/[\s>+]$/)) {
@@ -468,31 +431,34 @@ SelectorAutocompleter.prototype = {
         item.preLabel = "#" + item.preLabel;
       }
 
       items.unshift(item);
       if (++total > MAX_SUGGESTIONS - 1) {
         break;
       }
     }
+
     if (total > 0) {
+      let onPopupOpened = this.searchPopup.once("popup-opened");
       this.searchPopup.setItems(items);
       this.searchPopup.openPopup(this.searchBox);
-    } else {
-      this.hidePopup();
+      return onPopupOpened;
     }
+
+    return this.hidePopup();
   },
 
   /**
    * Hide the suggestion popup if necessary.
    */
   hidePopup: function () {
-    if (this.searchPopup.isOpen) {
-      this.searchPopup.hidePopup();
-    }
+    let onPopupClosed = this.searchPopup.once("popup-closed");
+    this.searchPopup.hidePopup();
+    return onPopupClosed;
   },
 
   /**
    * Suggests classes,ids and tags based on the user input as user types in the
    * searchbox.
    */
   showSuggestions: function () {
     let query = this.searchBox.value;
@@ -529,30 +495,32 @@ SelectorAutocompleter.prototype = {
 
     let suggestionsPromise = this.walker.getSuggestionsForQuery(
       query, firstPart, state);
     this._lastQuery = suggestionsPromise.then(result => {
       this.emit("processing-done");
       if (result.query !== query) {
         // This means that this response is for a previous request and the user
         // as since typed something extra leading to a new request.
-        return;
+        return promise.resolve(null);
       }
 
       if (state === this.States.CLASS) {
         firstPart = "." + firstPart;
       } else if (state === this.States.ID) {
         firstPart = "#" + firstPart;
       }
 
       // If there is a single tag match and it's what the user typed, then
       // don't need to show a popup.
       if (result.suggestions.length === 1 &&
           result.suggestions[0][0] === firstPart) {
         result.suggestions = [];
       }
 
-      this._showPopup(result.suggestions, firstPart, state);
+      // Wait for the autocomplete-popup to fire its popup-opened event, to make sure
+      // the autoSelect item has been selected.
+      return this._showPopup(result.suggestions, firstPart, state);
     });
 
     return;
   }
 };
--- a/devtools/client/inspector/markup/markup.js
+++ b/devtools/client/inspector/markup/markup.js
@@ -18,17 +18,16 @@ const NEW_SELECTION_HIGHLIGHTER_TIMER = 
 const DRAG_DROP_AUTOSCROLL_EDGE_MAX_DISTANCE = 50;
 const DRAG_DROP_AUTOSCROLL_EDGE_RATIO = 0.1;
 const DRAG_DROP_MIN_AUTOSCROLL_SPEED = 2;
 const DRAG_DROP_MAX_AUTOSCROLL_SPEED = 8;
 const DRAG_DROP_MIN_INITIAL_DISTANCE = 10;
 const DRAG_DROP_HEIGHT_TO_SPEED = 500;
 const DRAG_DROP_HEIGHT_TO_SPEED_MIN = 0.5;
 const DRAG_DROP_HEIGHT_TO_SPEED_MAX = 1;
-const AUTOCOMPLETE_POPUP_PANEL_ID = "markupview_autoCompletePopup";
 const ATTR_COLLAPSE_ENABLED_PREF = "devtools.markup.collapseAttributes";
 const ATTR_COLLAPSE_LENGTH_PREF = "devtools.markup.collapseAttributeLength";
 const PREVIEW_MAX_DIM_PREF = "devtools.inspector.imagePreviewTooltipSize";
 
 // Contains only void (without end tag) HTML elements
 const HTML_VOID_ELEMENTS = [ "area", "base", "br", "col", "command", "embed",
   "hr", "img", "input", "keygen", "link", "meta", "param", "source",
   "track", "wbr" ];
@@ -104,22 +103,19 @@ function MarkupView(inspector, frame, co
     Services.prefs.getBoolPref(ATTR_COLLAPSE_ENABLED_PREF);
   this.collapseAttributeLength =
     Services.prefs.getIntPref(ATTR_COLLAPSE_LENGTH_PREF);
 
   // Creating the popup to be used to show CSS suggestions.
   let options = {
     autoSelect: true,
     theme: "auto",
-    // panelId option prevents the markupView autocomplete popup from
-    // sharing XUL elements with other views, such as ruleView (see Bug 1191093)
-    panelId: AUTOCOMPLETE_POPUP_PANEL_ID
   };
-  this.popup = new AutocompletePopup(this.doc.defaultView.parent.document,
-                                     options);
+
+  this.popup = new AutocompletePopup(inspector._toolbox, options);
 
   this.undo = new UndoStack();
   this.undo.installController(controllerWindow);
 
   this._containers = new Map();
 
   // Binding functions that need to be called in scope.
   this._mutationObserver = this._mutationObserver.bind(this);
--- a/devtools/client/inspector/rules/rules.js
+++ b/devtools/client/inspector/rules/rules.js
@@ -217,17 +217,17 @@ function CssRuleView(inspector, document
   this.showUserAgentStyles = Services.prefs.getBoolPref(PREF_UA_STYLES);
   this.enableMdnDocsTooltip =
     Services.prefs.getBoolPref(PREF_ENABLE_MDN_DOCS_TOOLTIP);
 
   let options = {
     autoSelect: true,
     theme: "auto"
   };
-  this.popup = new AutocompletePopup(this.styleDocument, options);
+  this.popup = new AutocompletePopup(inspector._toolbox, options);
 
   this._showEmpty();
 
   this._contextmenu = new StyleInspectorMenu(this, { isRuleView: true });
 
   // Add the tooltips and highlighters to the view
   this.tooltips = new overlays.TooltipsOverlay(this);
   this.tooltips.addToView();
--- a/devtools/client/shared/autocomplete-popup.js
+++ b/devtools/client/shared/autocomplete-popup.js
@@ -1,144 +1,115 @@
 /* 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 XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+const HTML_NS = "http://www.w3.org/1999/xhtml";
 const Services = require("Services");
 const {gDevTools} = require("devtools/client/framework/devtools");
-const events = require("devtools/shared/event-emitter");
+const {HTMLTooltip} = require("devtools/client/shared/widgets/HTMLTooltip");
+const EventEmitter = require("devtools/shared/event-emitter");
 
+let itemIdCounter = 0;
 /**
  * Autocomplete popup UI implementation.
  *
  * @constructor
- * @param {nsIDOMDocument} document
- *        The document you want the popup attached to.
+ * @param {Toolbox} toolbox
+ *        The devtools toolbox required to instanciate the HTMLTooltip.
  * @param {Object} options
  *        An object consiting any of the following options:
- *        - panelId {String} The id for the popup panel.
- *        - listBoxId {String} The id for the richlistbox inside the panel.
- *        - position {String} The position for the popup panel.
- *        - theme {String} String related to the theme of the popup.
+ *        - 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.
- *        - direction {String} The direction of the text in the panel. rtl / ltr
- *        - onSelect {String} The select event handler for the richlistbox
- *        - onClick {String} The click event handler for the richlistbox.
- *        - onKeypress {String} The keypress event handler for richlistitems.
+ *          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(document, options = {}) {
-  this._document = document;
+function AutocompletePopup(toolbox, options = {}) {
+  EventEmitter.decorate(this);
+
+  this._document = toolbox.doc;
 
   this.autoSelect = options.autoSelect || false;
-  this.position = options.position || "after_start";
-  this.direction = options.direction || "ltr";
+  this.position = options.position || "bottom";
+  let theme = options.theme || "dark";
 
   this.onSelectCallback = options.onSelect;
   this.onClickCallback = options.onClick;
-  this.onKeypressCallback = options.onKeypress;
 
-  let id = options.panelId || "devtools_autoCompletePopup";
-  let theme = options.theme || "dark";
   // If theme is auto, use the devtools.theme pref
-  if (theme == "auto") {
+  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);
   }
-  // Reuse the existing popup elements.
-  this._panel = this._document.getElementById(id);
-  if (!this._panel) {
-    this._panel = this._document.createElementNS(XUL_NS, "panel");
-    this._panel.setAttribute("id", id);
-    this._panel.className = "devtools-autocomplete-popup devtools-monospace "
-                            + theme + "-theme";
-
-    this._panel.setAttribute("noautofocus", "true");
-    this._panel.setAttribute("level", "top");
-    if (!options.onKeypress) {
-      this._panel.setAttribute("ignorekeys", "true");
-    }
-    // Stop this appearing as an alert to accessibility.
-    this._panel.setAttribute("role", "presentation");
 
-    let mainPopupSet = this._document.getElementById("mainPopupSet");
-    if (mainPopupSet) {
-      mainPopupSet.appendChild(this._panel);
-    } else {
-      this._document.documentElement.appendChild(this._panel);
-    }
-  } else {
-    this._list = this._panel.firstChild;
-  }
+  // Create HTMLTooltip instance
+  this._tooltip = new HTMLTooltip(toolbox);
+  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");
 
-  if (!this._list) {
-    this._list = this._document.createElementNS(XUL_NS, "richlistbox");
-    this._panel.appendChild(this._list);
-
-    // Open and hide the panel, so we initialize the API of the richlistbox.
-    this._panel.openPopup(null, this.position, 0, 0);
-    this._panel.hidePopup();
-  }
-
+  this._list = this._document.createElementNS(HTML_NS, "ul");
   this._list.setAttribute("flex", "1");
   this._list.setAttribute("seltype", "single");
 
-  if (options.listBoxId) {
-    this._list.setAttribute("id", options.listBoxId);
+  if (options.listId) {
+    this._list.setAttribute("id", options.listId);
   }
   this._list.className = "devtools-autocomplete-listbox " + theme + "-theme";
 
-  this.onSelect = this.onSelect.bind(this);
+  this._tooltip.setContent(this._list);
+
   this.onClick = this.onClick.bind(this);
-  this.onKeypress = this.onKeypress.bind(this);
-  this._list.addEventListener("select", this.onSelect, false);
   this._list.addEventListener("click", this.onClick, false);
-  this._list.addEventListener("keypress", this.onKeypress, false);
 
-  this._itemIdCounter = 0;
+  // Array of raw autocomplete items
+  this.items = [];
+  // Map of autocompleteItem to HTMLElement
+  this.elements = new WeakMap();
 
-  events.decorate(this);
+  this.selectedIndex = -1;
 }
 exports.AutocompletePopup = AutocompletePopup;
 
 AutocompletePopup.prototype = {
   _document: null,
-  _panel: null,
+  _tooltip: null,
   _list: null,
-  __scrollbarWidth: null,
 
-  // Event handlers.
   onSelect: function (e) {
-    this.emit("popup-select");
     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);
     }
   },
 
-  onKeypress: function (e) {
-    this.emit("popup-keypress");
-    if (this.onKeypressCallback) {
-      this.onKeypressCallback(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.
@@ -146,247 +117,278 @@ AutocompletePopup.prototype = {
    *        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();
-    this._panel.openPopup(anchor, this.position, xOffset, yOffset);
+    this._tooltip.show(anchor, {
+      x: xOffset,
+      y: yOffset,
+      position: this.position,
+    });
 
-    if (this.autoSelect) {
-      this.selectItemAtIndex(index);
-    }
+    this._tooltip.once("shown", () => {
+      if (this.autoSelect) {
+        this.selectItemAtIndex(index);
+      }
 
-    this.emit("popup-opened");
+      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 (typeof index !== "number") {
       // If no index was provided, select the item closest to the input.
-      let isAboveInput = this.position.includes("before");
+      let isAboveInput = this.position === "top";
       index = isAboveInput ? this.itemCount - 1 : 0;
     }
     this.selectedIndex = index;
-    this._list.ensureIndexIsVisible(this._list.selectedIndex);
   },
 
   /**
    * Hide the autocomplete popup panel.
    */
   hidePopup: function () {
     // Return accessibility focus to the input.
-    this._document.activeElement.removeAttribute("aria-activedescendant");
-    this._panel.hidePopup();
+    this._findActiveElement().removeAttribute("aria-activedescendant");
+    this._tooltip.once("hidden", () => {
+      this.emit("popup-closed");
+    });
+    this._tooltip.hide();
   },
 
   /**
    * Check if the autocomplete popup is open.
    */
   get isOpen() {
-    return this._panel &&
-           (this._panel.state == "open" || this._panel.state == "showing");
+    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("select", this.onSelect, false);
     this._list.removeEventListener("click", this.onClick, false);
-    this._list.removeEventListener("keypress", this.onKeypress, false);
 
     if (this.autoThemeEnabled) {
       gDevTools.off("pref-changed", this._handleThemeChange);
     }
 
     this._list.remove();
-    this._panel.remove();
+    this._tooltip.destroy();
     this._document = null;
     this._list = null;
-    this._panel = 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._list.getItemAtIndex(index)._autocompleteItem;
+    return this.items[index];
   },
 
   /**
    * Get the autocomplete items array.
    *
    * @return {Array} The array of autocomplete items.
    */
   getItems: function () {
-    let items = [];
-
-    Array.forEach(this._list.childNodes, function (item) {
-      items.push(item._autocompleteItem);
-    });
-
-    return items;
+    // 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);
 
-    // Make sure that the new content is properly fitted by the XUL richlistbox.
-    if (this.isOpen) {
-      if (this.autoSelect) {
-        this.selectItemAtIndex(index);
-      }
-      this._updateSize();
+    if (this.isOpen && this.autoSelect) {
+      this.selectItemAtIndex(index);
     }
   },
 
   __maxLabelLength: -1,
 
   get _maxLabelLength() {
-    if (this.__maxLabelLength != -1) {
+    if (this.__maxLabelLength !== -1) {
       return this.__maxLabelLength;
     }
 
     let max = 0;
-    for (let i = 0; i < this._list.childNodes.length; i++) {
-      let item = this._list.childNodes[i]._autocompleteItem;
-      let str = item.label;
-      if (item.count) {
-        str += (item.count + "");
+    for (let {label, count} of this.items) {
+      if (count) {
+        label += count + "";
       }
-      max = Math.max(str.length, max);
+      max = Math.max(label.length, max);
     }
 
     this.__maxLabelLength = max;
     return this.__maxLabelLength;
   },
 
   /**
    * Update the panel size to fit the content.
    */
   _updateSize: function () {
-    if (!this._panel) {
+    if (!this._tooltip) {
       return;
     }
 
     this._list.style.width = (this._maxLabelLength + 3) + "ch";
-    this._list.ensureIndexIsVisible(this._list.selectedIndex);
+    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);
+    }
   },
 
   /**
    * Update accessibility appropriately when the selected item is changed.
    */
   _updateAriaActiveDescendant: function () {
-    if (!this._list.selectedItem) {
-      // Return accessibility focus to the input.
-      this._document.activeElement.removeAttribute("aria-activedescendant");
+    let activeElement = this._findActiveElement();
+    if (!activeElement) {
       return;
     }
-    // Focus this for accessibility so users know about the selected item.
-    this._document.activeElement.setAttribute("aria-activedescendant",
-                                              this._list.selectedItem.id);
+
+    if (this.selectedItem) {
+      // Focus this for accessibility so users know about the selected item.
+      let selectedElement = this.elements.get(this.selectedItem);
+      activeElement.setAttribute("aria-activedescendant", selectedElement.id);
+    } else {
+      // Return accessibility focus to the input.
+      activeElement.removeAttribute("aria-activedescendant");
+    }
+  },
+
+  /**
+   * Find the active element if it belongs in a child document of the autocomplete
+   * document.
+   */
+  _findActiveElement: function () {
+    let activeElement = this._document.activeElement;
+    while (activeElement && activeElement.contentDocument) {
+      activeElement = activeElement.contentDocument.activeElement;
+    }
+    return activeElement;
   },
 
   /**
    * Clear all the items from the autocomplete list.
    */
   clearItems: function () {
     // Reset the selectedIndex to -1 before clearing the list
     this.selectedIndex = -1;
-
-    while (this._list.hasChildNodes()) {
-      this._list.removeChild(this._list.firstChild);
-    }
-
+    this._list.innerHTML = "";
     this.__maxLabelLength = -1;
-
-    // Reset the panel and list dimensions. New dimensions are calculated when
-    // a new set of items is added to the autocomplete popup.
-    this._list.width = "";
-    this._list.style.width = "";
-    this._list.height = "";
-    this._panel.width = "";
-    this._panel.height = "";
-    this._panel.top = "";
-    this._panel.left = "";
+    this.items = [];
+    this.elements = new WeakMap();
   },
 
   /**
    * Getter for the index of the selected item.
    *
    * @type {Number}
    */
   get selectedIndex() {
-    return this._list.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) {
-    this._list.selectedIndex = index;
-    if (this.isOpen && this._list.ensureIndexIsVisible) {
-      this._list.ensureIndexIsVisible(this._list.selectedIndex);
+    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._selectedIndex = index;
     this._updateAriaActiveDescendant();
+
+    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._list.selectedItem ?
-           this._list.selectedItem._autocompleteItem : null;
+    return this.items[this._selectedIndex];
   },
 
   /**
    * Setter for the selected item.
    *
    * @param {Object} item
    *        The object you want selected in the list.
    */
   set selectedItem(item) {
-    this._list.selectedItem = this._findListItem(item);
-    if (this.isOpen) {
-      this._list.ensureIndexIsVisible(this._list.selectedIndex);
+    let index = this.items.indexOf(item);
+    if (index !== -1 && this.isOpen) {
+      this.selectedIndex = index;
     }
-    this._updateAriaActiveDescendant();
   },
 
   /**
    * 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:
@@ -396,182 +398,186 @@ AutocompletePopup.prototype = {
    *                   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(XUL_NS, "richlistitem");
+    let listItem = this._document.createElementNS(HTML_NS, "li");
     // Items must have an id for accessibility.
-    listItem.id = this._panel.id + "_item_" + this._itemIdCounter++;
+    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(XUL_NS, "label");
-    label.setAttribute("value", item.label);
-    label.setAttribute("class", "autocomplete-value");
+    let label = this._document.createElementNS(HTML_NS, "span");
+    label.textContent = item.label;
+    label.className = "autocomplete-value";
     if (item.preLabel) {
-      let preDesc = this._document.createElementNS(XUL_NS, "label");
-      preDesc.setAttribute("value", item.preLabel);
-      preDesc.setAttribute("class", "initial-value");
+      let preDesc = this._document.createElementNS(HTML_NS, "span");
+      preDesc.textContent = item.preLabel;
+      preDesc.className = "initial-value";
       listItem.appendChild(preDesc);
-      label.setAttribute("value", item.label.slice(item.preLabel.length));
+      label.textContent = item.label.slice(item.preLabel.length);
     }
     listItem.appendChild(label);
     if (item.count && item.count > 1) {
-      let countDesc = this._document.createElementNS(XUL_NS, "label");
-      countDesc.setAttribute("value", item.count);
+      let countDesc = this._document.createElementNS(HTML_NS, "span");
+      countDesc.textContent = item.count;
       countDesc.setAttribute("flex", "1");
-      countDesc.setAttribute("class", "autocomplete-count");
+      countDesc.className = "autocomplete-count";
       listItem.appendChild(countDesc);
     }
-    listItem._autocompleteItem = item;
 
     this._list.appendChild(listItem);
-  },
-
-  /**
-   * Find the richlistitem element that belongs to an item.
-   *
-   * @private
-   *
-   * @param {Object} item
-   *        The object you want found in the list.
-   *
-   * @return {nsIDOMNode} The nsIDOMNode that belongs to the given item object.
-   *         This node is the richlistitem element. Can be null.
-   */
-  _findListItem: function (item) {
-    for (let i = 0; i < this._list.childNodes.length; i++) {
-      let child = this._list.childNodes[i];
-      if (child._autocompleteItem == item) {
-        return child;
-      }
-    }
-    return null;
+    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) {
-    let listItem = this._findListItem(item);
-    if (!listItem) {
-      throw new Error("Item not found!");
+    if (!this.items.includes(item)) {
+      return;
     }
-    this._list.removeChild(listItem);
+
+    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._list.childNodes.length;
+    return this.items.length;
   },
 
   /**
    * Getter for the height of each item in the list.
    *
    * @type {Number}
    */
-  get _itemHeight() {
-    return this._list.selectedItem.clientHeight;
+  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.itemCount - 1)) {
+    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.itemCount - 1;
+      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 itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
-    let nextPageIndex = this.selectedIndex + itemsPerPane + 1;
-    this.selectedIndex = nextPageIndex > this.itemCount - 1 ?
-      this.itemCount - 1 : nextPageIndex;
-
+    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 itemsPerPane = Math.floor(this._list.scrollHeight / this._itemHeight);
-    let prevPageIndex = this.selectedIndex - itemsPerPane - 1;
-    this.selectedIndex = prevPageIndex < 0 ? 0 : prevPageIndex;
-
+    let prevPageIndex = this.selectedIndex - this._itemsPerPane - 1;
+    this.selectedIndex = Math.max(prevPageIndex, 0);
     return this.selectedItem;
   },
 
   /**
-   * Focuses the richlistbox.
-   */
-  focus: function () {
-    this._list.focus();
-  },
-
-  /**
    * 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._panel.classList.toggle(data.oldValue + "-theme", false);
-      this._panel.classList.toggle(data.newValue + "-theme", true);
+    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;
+  },
 };
--- a/devtools/client/shared/inplace-editor.js
+++ b/devtools/client/shared/inplace-editor.js
@@ -28,17 +28,16 @@ const Services = require("Services");
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 const CONTENT_TYPES = {
   PLAIN_TEXT: 0,
   CSS_VALUE: 1,
   CSS_MIXED: 2,
   CSS_PROPERTY: 3,
 };
-const AUTOCOMPLETE_POPUP_CLASSNAME = "inplace-editor-autocomplete-popup";
 
 // The limit of 500 autocomplete suggestions should not be reached but is kept
 // for safety.
 const MAX_POPUP_ENTRIES = 500;
 
 const FOCUS_FORWARD = Ci.nsIFocusManager.MOVEFOCUS_FORWARD;
 const FOCUS_BACKWARD = Ci.nsIFocusManager.MOVEFOCUS_BACKWARD;
 
@@ -984,23 +983,23 @@ InplaceEditor.prototype = {
   _onAutocompletePopupClick: function () {
     this._acceptPopupSuggestion();
   },
 
   _acceptPopupSuggestion: function () {
     let label, preLabel;
 
     if (this._selectedIndex === undefined) {
-      ({label, preLabel} =
-        this.popup.getItemAtIndex(this.popup.selectedIndex));
+      ({label, preLabel} = this.popup.getItemAtIndex(this.popup.selectedIndex));
     } else {
       ({label, preLabel} = this.popup.getItemAtIndex(this._selectedIndex));
     }
 
     let input = this.input;
+
     let pre = "";
 
     // CSS_MIXED needs special treatment here to make it so that
     // multiple presses of tab will cycle through completions, but
     // without selecting the completed text.  However, this same
     // special treatment will do the wrong thing for other editing
     // styles.
     if (input.selectionStart < input.selectionEnd ||
@@ -1016,23 +1015,23 @@ InplaceEditor.prototype = {
     let toComplete = item.label.slice(item.preLabel.length);
     input.value = pre + toComplete + post;
     input.setSelectionRange(pre.length + toComplete.length,
                             pre.length + toComplete.length);
     this._updateSize();
     // Wait for the popup to hide and then focus input async otherwise it does
     // not work.
     let onPopupHidden = () => {
-      this.popup._panel.removeEventListener("popuphidden", onPopupHidden);
+      this.popup.off("popup-closed", onPopupHidden);
       this.doc.defaultView.setTimeout(()=> {
         input.focus();
         this.emit("after-suggest");
       }, 0);
     };
-    this.popup._panel.addEventListener("popuphidden", onPopupHidden);
+    this.popup.on("popup-closed", onPopupHidden);
     this._hideAutocompletePopup();
   },
 
   /**
    * Handle the input field's keypress event.
    */
   _onKeyPress: function (event) {
     let prevent = false;
@@ -1169,27 +1168,25 @@ InplaceEditor.prototype = {
    *
    * @param {Number} offset
    *        X-offset relative to the input starting edge.
    * @param {Number} selectedIndex
    *        The index of the item that should be selected. Use -1 to have no
    *        item selected.
    */
   _openAutocompletePopup: function (offset, selectedIndex) {
-    this.popup._panel.classList.add(AUTOCOMPLETE_POPUP_CLASSNAME);
     this.popup.on("popup-click", this._onAutocompletePopupClick);
     this.popup.openPopup(this.input, offset, 0, selectedIndex);
   },
 
   /**
    * Remove the custom classname and click handler and close the autocomplete
    * popup.
    */
   _hideAutocompletePopup: function () {
-    this.popup._panel.classList.remove(AUTOCOMPLETE_POPUP_CLASSNAME);
     this.popup.off("popup-click", this._onAutocompletePopupClick);
     this.popup.hidePopup();
   },
 
   /**
    * Get the increment/decrement step to use for the provided key event.
    */
   _getIncrement: function (event) {
--- a/devtools/client/shared/widgets/HTMLTooltip.js
+++ b/devtools/client/shared/widgets/HTMLTooltip.js
@@ -415,16 +415,17 @@ HTMLTooltip.prototype = {
 
   /**
    * Hide the current tooltip. The event "hidden" will be fired when the tooltip
    * is hidden.
    */
   hide: Task.async(function* () {
     this.doc.defaultView.clearTimeout(this.attachEventsTimer);
     if (!this.isVisible()) {
+      this.emit("hidden");
       return;
     }
 
     this.topWindow.removeEventListener("click", this._onClick, true);
     this.container.classList.remove("tooltip-visible");
     if (this.useXulWrapper) {
       yield this._hideXulWrapper();
     }
@@ -495,17 +496,17 @@ HTMLTooltip.prototype = {
     let win = node.ownerDocument.defaultView;
 
     // Check if the tooltip panel contains the node if they live in the same document.
     if (win === tooltipWindow) {
       return this.panel.contains(node);
     }
 
     // Check if the node window is in the tooltip container.
-    while (win.parent && win.parent != win) {
+    while (win.parent && win.parent !== win) {
       if (win.parent === tooltipWindow) {
         // If the parent window is the tooltip window, check if the tooltip contains
         // the current frame element.
         return this.panel.contains(win.frameElement);
       }
       win = win.parent;
     }
 
--- a/devtools/client/sourceeditor/autocomplete.js
+++ b/devtools/client/sourceeditor/autocomplete.js
@@ -1,31 +1,25 @@
 /* vim:set ts=2 sw=2 sts=2 et 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 { Cu } = require("chrome");
-const CSSCompleter =
-      require("devtools/client/sourceeditor/css-autocompleter");
-const { AutocompletePopup } =
-      require("devtools/client/shared/autocomplete-popup");
+const CSSCompleter = require("devtools/client/sourceeditor/css-autocompleter");
+const { AutocompletePopup } = require("devtools/client/shared/autocomplete-popup");
 
 const CM_TERN_SCRIPTS = [
   "chrome://devtools/content/sourceeditor/codemirror/addon/tern/tern.js",
   "chrome://devtools/content/sourceeditor/codemirror/addon/hint/show-hint.js"
 ];
 
 const autocompleteMap = new WeakMap();
 
-// A simple way to give each popup its own panelId.
-let autocompleteCounter = 0;
-
 /**
  * Prepares an editor instance for autocompletion.
  */
 function initializeAutoCompletion(ctx, options = {}) {
   let { cm, ed, Editor } = ctx;
   if (autocompleteMap.has(ed)) {
     return;
   }
@@ -119,33 +113,31 @@ function initializeAutoCompletion(ctx, o
       return false;
     }
 
     if (!autocompleteState.suggestionInsertedOnce && popup.selectedItem) {
       autocompleteMap.get(ed).insertingSuggestion = true;
       insertPopupItem(ed, popup.selectedItem);
     }
 
+    popup.once("popup-closed", () => {
+      // This event is used in tests.
+      ed.emit("popup-hidden");
+    });
     popup.hidePopup();
-    // This event is used in tests.
-    ed.emit("popup-hidden");
     return true;
   }
 
   // Give each popup a new name to avoid sharing the elements.
-  let panelId = "devtools_sourceEditorCompletePopup" + autocompleteCounter;
-  ++autocompleteCounter;
 
-  let popup = new AutocompletePopup(win.parent.document, {
-    position: "after_start",
-    fixedWidth: true,
+  let popup = new AutocompletePopup({ doc: win.parent.document }, {
+    position: "bottom",
     theme: "auto",
     autoSelect: true,
-    onClick: insertSelectedPopupItem,
-    panelId: panelId
+    onClick: insertSelectedPopupItem
   });
 
   let cycle = reverse => {
     if (popup && popup.isOpen) {
       cycleSuggestions(ed, reverse == true);
       return null;
     }
 
@@ -212,40 +204,43 @@ function autoComplete({ ed, cm }) {
   let autocompleteOpts = autocompleteMap.get(ed);
   let { completer, popup } = autocompleteOpts;
   if (!completer || autocompleteOpts.insertingSuggestion ||
       autocompleteOpts.doNotAutocomplete) {
     autocompleteOpts.insertingSuggestion = false;
     return;
   }
   let cur = ed.getCursor();
-  completer.complete(cm.getRange({line: 0, ch: 0}, cur), cur)
-    .then(suggestions => {
-      if (!suggestions || !suggestions.length ||
-          suggestions[0].preLabel == null) {
-        autocompleteOpts.suggestionInsertedOnce = false;
-        popup.hidePopup();
+  completer.complete(cm.getRange({line: 0, ch: 0}, cur), cur).then(suggestions => {
+    if (!suggestions || !suggestions.length || suggestions[0].preLabel == null) {
+      autocompleteOpts.suggestionInsertedOnce = false;
+      popup.once("popup-closed", () => {
+        // This event is used in tests.
         ed.emit("after-suggest");
-        return;
-      }
-      // The cursor is at the end of the currently entered part of the token,
-      // like "backgr|" but we need to open the popup at the beginning of the
-      // character "b". Thus we need to calculate the width of the entered part
-      // of the token ("backgr" here). 4 comes from the popup's left padding.
+      });
+      popup.hidePopup();
+      return;
+    }
+    // The cursor is at the end of the currently entered part of the token,
+    // like "backgr|" but we need to open the popup at the beginning of the
+    // character "b". Thus we need to calculate the width of the entered part
+    // of the token ("backgr" here). 4 comes from the popup's left padding.
 
-      let cursorElement =
-        cm.display.cursorDiv.querySelector(".CodeMirror-cursor");
-      let left = suggestions[0].preLabel.length * cm.defaultCharWidth() + 4;
-      popup.hidePopup();
-      popup.setItems(suggestions);
-      popup.openPopup(cursorElement, -1 * left, 0);
-      autocompleteOpts.suggestionInsertedOnce = false;
+    let cursorElement = cm.display.cursorDiv.querySelector(".CodeMirror-cursor");
+    let left = suggestions[0].preLabel.length * cm.defaultCharWidth() + 4;
+    popup.hidePopup();
+    popup.setItems(suggestions);
+
+    popup.once("popup-opened", () => {
       // This event is used in tests.
       ed.emit("after-suggest");
-    }).then(null, e => console.error(e));
+    });
+    popup.openPopup(cursorElement, -1 * left, 0);
+    autocompleteOpts.suggestionInsertedOnce = false;
+  }).then(null, e => console.error(e));
 }
 
 /**
  * Inserts a popup item into the current cursor location
  * in the editor.
  */
 function insertPopupItem(ed, popupItem) {
   let {preLabel, text} = popupItem;
--- a/devtools/client/themes/common.css
+++ b/devtools/client/themes/common.css
@@ -40,70 +40,80 @@
   -moz-appearance: none !important;
   box-shadow: 0 1px 0 hsla(209,29%,72%,.25) inset;
   background-color: transparent;
   border-radius: 3px;
   overflow-x: hidden;
   max-height: 20rem;
 }
 
+/* Reset list styles. */
+.devtools-autocomplete-popup ul {
+  list-style: none;
+}
+
+.devtools-autocomplete-popup ul,
+.devtools-autocomplete-popup li {
+  margin: 0;
+}
+
 :root[platform="linux"] .devtools-autocomplete-popup {
   /* Root font size is bigger on Linux, adjust rem-based values. */
   max-height: 16rem;
 }
 
 .devtools-autocomplete-listbox {
   -moz-appearance: none !important;
   background-color: transparent;
   border-width: 0px !important;
   margin: 0;
+  padding: 2px;
 }
 
 .devtools-autocomplete-listbox > scrollbox {
   padding: 2px;
 }
 
-.inplace-editor-autocomplete-popup .devtools-autocomplete-listbox {
-  /* Inplace editor closes the autocomplete popup on blur, the autocomplete
-  popup should not steal the focus here.*/
-  -moz-user-focus: ignore;
-}
-
-.devtools-autocomplete-listbox > richlistitem,
-.devtools-autocomplete-listbox > richlistitem[selected] {
+.devtools-autocomplete-listbox .autocomplete-item {
   width: 100%;
   background-color: transparent;
   border-radius: 4px;
+  padding: 1px 0;
 }
 
-.devtools-autocomplete-listbox.dark-theme > richlistitem[selected],
-.devtools-autocomplete-listbox.dark-theme > richlistitem:hover {
+.devtools-autocomplete-listbox .autocomplete-selected {
+  background-color: rgba(0,0,0,0.2);
+}
+
+.devtools-autocomplete-listbox.dark-theme .autocomplete-selected,
+.devtools-autocomplete-listbox.dark-theme .autocomplete-item:hover {
   background-color: rgba(0,0,0,0.5);
 }
 
-.devtools-autocomplete-listbox.dark-theme > richlistitem[selected] > .autocomplete-value,
-.devtools-autocomplete-listbox:focus.dark-theme > richlistitem[selected] > .initial-value {
+.devtools-autocomplete-listbox.dark-theme .autocomplete-selected > .autocomplete-value,
+.devtools-autocomplete-listbox:focus.dark-theme .autocomplete-selected > .initial-value {
   color: hsl(208,100%,60%);
 }
 
-.devtools-autocomplete-listbox.dark-theme > richlistitem[selected] > label {
+.devtools-autocomplete-listbox.dark-theme .autocomplete-selected > span {
   color: #eee;
 }
 
-.devtools-autocomplete-listbox.dark-theme > richlistitem > label {
+.devtools-autocomplete-listbox.dark-theme .autocomplete-item > span {
   color: #ccc;
 }
 
-.devtools-autocomplete-listbox > richlistitem > .initial-value,
-.devtools-autocomplete-listbox > richlistitem > .autocomplete-value {
+.devtools-autocomplete-listbox .autocomplete-item > .initial-value,
+.devtools-autocomplete-listbox .autocomplete-item > .autocomplete-value {
   margin: 0;
-  padding: 1px 0;
+  padding: 0;
+  cursor: default;
 }
 
-.devtools-autocomplete-listbox > richlistitem > .autocomplete-count {
+.devtools-autocomplete-listbox .autocomplete-item > .autocomplete-count {
   text-align: end;
 }
 
 /* Rest of the dark and light theme */
 
 .devtools-autocomplete-popup,
 .theme-dark .CodeMirror-hints,
 .theme-dark .CodeMirror-Tern-tooltip {
@@ -127,32 +137,32 @@
   border-radius: 5px;
   font-size: var(--theme-autompletion-font-size);
 }
 
 .devtools-autocomplete-popup.firebug-theme {
   background: var(--theme-body-background);
 }
 
-.devtools-autocomplete-listbox.firebug-theme > richlistitem[selected],
-.devtools-autocomplete-listbox.firebug-theme > richlistitem:hover,
-.devtools-autocomplete-listbox.light-theme > richlistitem[selected],
-.devtools-autocomplete-listbox.light-theme > richlistitem:hover {
+.devtools-autocomplete-listbox.firebug-theme .autocomplete-selected,
+.devtools-autocomplete-listbox.firebug-theme .autocomplete-item:hover,
+.devtools-autocomplete-listbox.light-theme .autocomplete-selected,
+.devtools-autocomplete-listbox.light-theme .autocomplete-item:hover {
   background-color: rgba(128,128,128,0.3);
 }
 
-.devtools-autocomplete-listbox.firebug-theme > richlistitem[selected] > .autocomplete-value,
-.devtools-autocomplete-listbox:focus.firebug-theme > richlistitem[selected] > .initial-value,
-.devtools-autocomplete-listbox.light-theme > richlistitem[selected] > .autocomplete-value,
-.devtools-autocomplete-listbox:focus.light-theme > richlistitem[selected] > .initial-value {
+.devtools-autocomplete-listbox.firebug-theme .autocomplete-selected > .autocomplete-value,
+.devtools-autocomplete-listbox:focus.firebug-theme .autocomplete-selected > .initial-value,
+.devtools-autocomplete-listbox.light-theme .autocomplete-selected > .autocomplete-value,
+.devtools-autocomplete-listbox:focus.light-theme .autocomplete-selected > .initial-value {
   color: #222;
 }
 
-.devtools-autocomplete-listbox.firebug-theme > richlistitem > label,
-.devtools-autocomplete-listbox.light-theme > richlistitem > label {
+.devtools-autocomplete-listbox.firebug-theme .autocomplete-item > span,
+.devtools-autocomplete-listbox.light-theme .autocomplete-item > span {
   color: #666;
 }
 
 /* links to source code, like displaying `myfile.js:45` */
 
 .devtools-source-link {
   font-family: var(--monospace-font-family);
   color: var(--theme-highlight-blue);
--- a/devtools/client/themes/tooltips.css
+++ b/devtools/client/themes/tooltips.css
@@ -102,16 +102,17 @@
 .tooltip-container {
   display: none;
   position: fixed;
   z-index: 9999;
   display: none;
   background: transparent;
   pointer-events: none;
   overflow: hidden;
+  filter: drop-shadow(0 3px 4px var(--theme-tooltip-shadow));
 }
 
 .tooltip-xul-wrapper {
   -moz-appearance: none;
   background: transparent;
   overflow: visible;
   border-style: none;
 }
@@ -139,21 +140,17 @@
 
 .tooltip-hidden {
   display: flex;
   visibility: hidden;
 }
 
 /* Tooltip : arrow style */
 
-.tooltip-container[type="arrow"] {
-  filter: drop-shadow(0 3px 4px var(--theme-tooltip-shadow));
-}
-
-.tooltip-xul-wrapper .tooltip-container[type="arrow"] {
+.tooltip-xul-wrapper .tooltip-container {
   /* When displayed in a XUL panel the drop shadow would be abruptly cut by the panel */
   filter: none;
 }
 
 .tooltip-container[type="arrow"] > .tooltip-panel {
   position: relative;
   flex-grow: 0;
   min-height: 10px;
--- a/devtools/client/webconsole/jsterm.js
+++ b/devtools/client/webconsole/jsterm.js
@@ -241,27 +241,31 @@ JSTerm.prototype = {
 
   /**
    * Initialize the JSTerminal UI.
    */
   init: function () {
     let autocompleteOptions = {
       onSelect: this.onAutocompleteSelect.bind(this),
       onClick: this.acceptProposedCompletion.bind(this),
-      panelId: "webConsole_autocompletePopup",
-      listBoxId: "webConsole_autocompletePopupListBox",
-      position: "before_start",
+      listId: "webConsole_autocompletePopupListBox",
+      position: "top",
       theme: "auto",
-      direction: "ltr",
       autoSelect: true
     };
-    this.autocompletePopup = new AutocompletePopup(this.hud.document,
-                                                   autocompleteOptions);
 
     let doc = this.hud.document;
+
+    let toolbox = gDevTools.getToolbox(this.hud.owner.target);
+    if (!toolbox) {
+      // In some cases (e.g. Browser Console), there is no toolbox.
+      toolbox = { doc };
+    }
+    this.autocompletePopup = new AutocompletePopup(toolbox, autocompleteOptions);
+
     let inputContainer = doc.querySelector(".jsterm-input-container");
     this.completeNode = doc.querySelector(".jsterm-complete-node");
     this.inputNode = doc.querySelector(".jsterm-input-node");
 
     if (this.hud.owner._browserConsole &&
         !Services.prefs.getBoolPref("devtools.chrome.enabled")) {
       inputContainer.style.display = "none";
     } else {
@@ -1696,22 +1700,16 @@ JSTerm.prototype = {
     this._sidebarDestroy();
 
     this.clearCompletion();
     this.clearOutput();
 
     this.autocompletePopup.destroy();
     this.autocompletePopup = null;
 
-    let popup = this.hud.owner.chromeWindow.document
-                .getElementById("webConsole_autocompletePopup");
-    if (popup) {
-      popup.parentNode.removeChild(popup);
-    }
-
     if (this._onPaste) {
       this.inputNode.removeEventListener("paste", this._onPaste, false);
       this.inputNode.removeEventListener("drop", this._onPaste, false);
       this._onPaste = null;
     }
 
     this.inputNode.removeEventListener("keypress", this._keyPress, false);
     this.inputNode.removeEventListener("input", this._inputEventHandler, false);