Bug 1506261 - Convert search-one-offs from a custom element to a plain JS class and initialize it lazily. r=bgrins
authorDão Gottwald <dao@mozilla.com>
Sat, 17 Nov 2018 08:46:35 +0000
changeset 503350 1106b713fd95481862967b3612a33b3e1dc99efb
parent 503328 efc1da42132b231dbdbfe8b06d19bfee1028dbd7
child 503351 7e6b465b73dc86606661742771441bceea83e310
push id10290
push userffxbld-merge
push dateMon, 03 Dec 2018 16:23:23 +0000
treeherdermozilla-beta@700bed2445e6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersbgrins
bugs1506261
milestone65.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1506261 - Convert search-one-offs from a custom element to a plain JS class and initialize it lazily. r=bgrins Differential Revision: https://phabricator.services.mozilla.com/D11889
browser/base/content/browser.xul
browser/base/content/global-scripts.inc
browser/base/content/test/urlbar/browser_urlbarOneOffs.js
browser/base/content/test/urlbar/browser_urlbarSearchFunction.js
browser/base/content/urlbarBindings.xml
browser/components/search/content/search-one-offs.js
browser/components/search/content/search.xml
browser/components/search/test/browser/browser_oneOffContextMenu.js
browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js
browser/components/urlbar/UrlbarView.jsm
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -84,16 +84,17 @@
 
 <script type="application/javascript"
 #ifdef BROWSER_XHTML
 xmlns="http://www.w3.org/1999/xhtml"
 #endif
 >
   Services.scriptloader.loadSubScript("chrome://global/content/contentAreaUtils.js", this);
   Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser.js", this);
+  Services.scriptloader.loadSubScript("chrome://browser/content/search/search-one-offs.js", this);
 
   window.onload = gBrowserInit.onLoad.bind(gBrowserInit);
   window.onunload = gBrowserInit.onUnload.bind(gBrowserInit);
   window.onclose = WindowIsClosing;
 
 #ifdef BROWSER_XHTML
   window.addEventListener("readystatechange", () => {
     // We initially hide the window to prevent layouts during parse. This lets us
@@ -255,24 +256,24 @@ xmlns="http://www.w3.org/1999/xhtml"
            flip="none"
            level="parent">
       <html:div class="urlbarView-body-outer">
         <html:div class="urlbarView-body-inner">
           <!-- TODO: add search suggestions notification -->
           <html:div class="urlbarView-results"/>
         </html:div>
       </html:div>
-      <search-one-offs class="search-one-offs"
-                       compact="true"
-                       includecurrentengine="true"
-                       disabletab="true"/>
+      <hbox class="search-one-offs"
+            compact="true"
+            includecurrentengine="true"
+            disabletab="true"/>
     </panel>
 
-   <!-- for date/time picker. consumeoutsideclicks is set to never, so that
-        clicks on the anchored input box are never consumed. -->
+    <!-- for date/time picker. consumeoutsideclicks is set to never, so that
+         clicks on the anchored input box are never consumed. -->
     <panel id="DateTimePickerPanel"
            type="arrow"
            hidden="true"
            orient="vertical"
            noautofocus="true"
            norolluponanchor="true"
            consumeoutsideclicks="never"
            level="parent"
--- a/browser/base/content/global-scripts.inc
+++ b/browser/base/content/global-scripts.inc
@@ -12,17 +12,16 @@
 xmlns="http://www.w3.org/1999/xhtml"
 #endif
 >
 Components.utils.import("resource://gre/modules/Services.jsm");
 
 for (let script of [
   "chrome://browser/content/browser.js",
   "chrome://browser/content/search/searchbar.js",
-  "chrome://browser/content/search/search-one-offs.js",
 
   "chrome://browser/content/browser-captivePortal.js",
   "chrome://browser/content/browser-compacttheme.js",
   "chrome://browser/content/browser-contentblocking.js",
   "chrome://browser/content/browser-media.js",
   "chrome://browser/content/browser-pageActions.js",
   "chrome://browser/content/browser-places.js",
   "chrome://browser/content/browser-plugins.js",
--- a/browser/base/content/test/urlbar/browser_urlbarOneOffs.js
+++ b/browser/base/content/test/urlbar/browser_urlbarOneOffs.js
@@ -255,27 +255,27 @@ add_task(async function collapsedOneOffs
 // The one-offs should be hidden when searching with an "@engine" search engine
 // alias.
 add_task(async function hiddenWhenUsingSearchAlias() {
   let typedValue = "@example";
   await promiseAutocompleteResultPopup(typedValue, window, true);
   await waitForAutocompleteResultAt(0);
   Assert.equal(gURLBar.popup.oneOffSearchesEnabled, false);
   Assert.equal(
-    window.getComputedStyle(gURLBar.popup.oneOffSearchButtons).display,
+    window.getComputedStyle(gURLBar.popup.oneOffSearchButtons.container).display,
     "none"
   );
   await hidePopup();
 
   typedValue = "not an engine alias";
   await promiseAutocompleteResultPopup(typedValue, window, true);
   await waitForAutocompleteResultAt(0);
   Assert.equal(gURLBar.popup.oneOffSearchesEnabled, true);
   Assert.equal(
-    window.getComputedStyle(gURLBar.popup.oneOffSearchButtons).display,
+    window.getComputedStyle(gURLBar.popup.oneOffSearchButtons.container).display,
     "-moz-box"
   );
   await hidePopup();
 });
 
 
 function assertState(result, oneOff, textValue = undefined) {
   Assert.equal(gURLBar.popup.selectedIndex, result,
--- a/browser/base/content/test/urlbar/browser_urlbarSearchFunction.js
+++ b/browser/base/content/test/urlbar/browser_urlbarSearchFunction.js
@@ -122,17 +122,17 @@ function assertSearchSuggestionsNotifica
  * Asserts that the one-off search buttons are or aren't visible.
  *
  * @param visible
  *        True if they should be visible, false if not.
  */
 function assertOneOffButtonsVisible(visible) {
   Assert.equal(gURLBar.popup.oneOffSearchesEnabled, visible);
   Assert.equal(
-    window.getComputedStyle(gURLBar.popup.oneOffSearchButtons).display,
+    window.getComputedStyle(gURLBar.popup.oneOffSearchButtons.container).display,
     visible ? "-moz-box" : "none"
   );
 }
 
 /**
  * Asserts that the urlbar's input value is the given value.  Also asserts that
  * the first (heuristic) result in the popup is a search suggestion whose search
  * query is the given value.
--- a/browser/base/content/urlbarBindings.xml
+++ b/browser/base/content/urlbarBindings.xml
@@ -1868,22 +1868,22 @@ file, You can obtain one at http://mozil
                      onclick="openPreferences('paneSearch', {origin: 'searchChangeSettings'});"
                      control="search-suggestions-change-settings"/>
         </xul:hbox>
       </xul:deck>
       <xul:richlistbox anonid="richlistbox" class="autocomplete-richlistbox"
                        flex="1"/>
       <xul:hbox anonid="footer">
         <children/>
-        <xul:search-one-offs anonid="one-off-search-buttons"
-                             class="search-one-offs"
-                             compact="true"
-                             includecurrentengine="true"
-                             disabletab="true"
-                             flex="1"/>
+        <xul:hbox anonid="one-off-search-buttons"
+                  class="search-one-offs"
+                  compact="true"
+                  includecurrentengine="true"
+                  disabletab="true"
+                  flex="1"/>
       </xul:hbox>
     </content>
 
     <implementation>
       <!--
         For performance reasons we want to limit the size of the text runs we
         build and show to the user.
       -->
@@ -1907,18 +1907,19 @@ file, You can obtain one at http://mozil
         document.getAnonymousElementByAttribute(this, "anonid", "footer");
       </field>
 
       <field name="shrinkDelay" readonly="true">
         250
       </field>
 
       <field name="oneOffSearchButtons" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid",
-                                                "one-off-search-buttons");
+        new window.SearchOneOffs(
+          document.getAnonymousElementByAttribute(this, "anonid",
+                                                  "one-off-search-buttons"));
       </field>
 
       <field name="_overrideValue">null</field>
       <property name="overrideValue"
                 onget="return this._overrideValue;"
                 onset="this._overrideValue = val; return val;"/>
 
       <method name="onPopupClick">
--- a/browser/components/search/content/search-one-offs.js
+++ b/browser/components/search/content/search-one-offs.js
@@ -3,228 +3,44 @@
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 /* eslint-env mozilla/browser-window */
 
 {
 
-let sharedFragment;
-function getFragment() {
-  if (!sharedFragment) {
-    sharedFragment = MozXULElement.parseXULToFragment(`
-    <deck class="search-panel-one-offs-header search-panel-header search-panel-current-input">
-      <label class="searchbar-oneoffheader-search" value="&searchWithHeader.label;"></label>
-      <hbox class="search-panel-searchforwith search-panel-current-input">
-        <label value="&searchFor.label;"></label>
-        <label class="searchbar-oneoffheader-searchtext search-panel-input-value" flex="1" crop="end"></label>
-        <label flex="10000" value="&searchWith.label;"></label>
-      </hbox>
-      <hbox class="search-panel-searchonengine search-panel-current-input">
-        <label value="&search.label;"></label>
-        <label class="searchbar-oneoffheader-engine search-panel-input-value" flex="1" crop="end"></label>
-        <label flex="10000" value="&searchAfter.label;"></label>
-      </hbox>
-    </deck>
-    <description role="group" class="search-panel-one-offs">
-      <button oncommand="showSettings();" class="searchbar-engine-one-off-item search-setting-button-compact" tooltiptext="&changeSearchSettings.tooltip;"></button>
-    </description>
-    <vbox class="search-add-engines"></vbox>
-    <button oncommand="showSettings();" class="search-setting-button search-panel-header" label="&changeSearchSettings.button;"></button>
-    <menupopup class="search-one-offs-context-menu">
-      <menuitem class="search-one-offs-context-open-in-new-tab" label="&searchInNewTab.label;" accesskey="&searchInNewTab.accesskey;"></menuitem>
-      <menuitem class="search-one-offs-context-set-default" label="&searchSetAsDefault.label;" accesskey="&searchSetAsDefault.accesskey;"></menuitem>
-    </menupopup>
-    `, ["chrome://browser/locale/browser.dtd"]);
-  }
-
-  return document.importNode(sharedFragment, true);
-}
-
-class MozSearchOneOffs extends MozXULElement {
-  constructor() {
-    super();
-
-    this.addEventListener("mousedown", event => {
-      let target = event.originalTarget;
-      if (target.classList.contains("addengine-menu-button")) {
-        return;
-      }
-      // Required to receive click events from the buttons on Linux.
-      event.preventDefault();
-    });
-
-    this.addEventListener("mousemove", event => {
-      let target = event.originalTarget;
-
-      // Handle mouseover on the add-engine menu button and its popup items.
-      if ((target.localName == "menuitem" && target.classList.contains("addengine-item")) ||
-          target.classList.contains("addengine-menu-button")) {
-        let menuButton = this.querySelector(".addengine-menu-button");
-        this._updateStateForButton(menuButton);
-        this._addEngineMenuShouldBeOpen = true;
-        this._resetAddEngineMenuTimeout();
-        return;
-      }
-
-      if (target.localName != "button") {
-        return;
-      }
-
-      // Ignore mouse events when the context menu is open.
-      if (this._ignoreMouseEvents) {
-        return;
-      }
-
-      let isOneOff =
-        target.classList.contains("searchbar-engine-one-off-item") &&
-        !target.classList.contains("dummy");
-      if (isOneOff ||
-          target.classList.contains("addengine-item") ||
-          target.classList.contains("search-setting-button")) {
-        this._updateStateForButton(target);
-      }
-    });
-
-    this.addEventListener("mouseout", event => {
-      let target = event.originalTarget;
-
-      // Handle mouseout on the add-engine menu button and its popup items.
-      if ((target.localName == "menuitem" && target.classList.contains("addengine-item")) ||
-          target.classList.contains("addengine-menu-button")) {
-        this._updateStateForButton(null);
-        this._addEngineMenuShouldBeOpen = false;
-        this._resetAddEngineMenuTimeout();
-        return;
-      }
-
-      if (target.localName != "button") {
-        return;
-      }
-
-      // Don't update the mouseover state if the context menu is open.
-      if (this._ignoreMouseEvents) {
-        return;
-      }
-
-      this._updateStateForButton(null);
-    });
-
-    this.addEventListener("click", event => {
-      if (event.button == 2) {
-        return; // ignore right clicks.
-      }
+class SearchOneOffs {
+  constructor(container) {
+    this.container = container;
 
-      let button = event.originalTarget;
-      let engine = button.engine;
-
-      if (!engine) {
-        return;
-      }
-
-      // Select the clicked button so that consumers can easily tell which
-      // button was acted on.
-      this.selectedButton = button;
-      this.handleSearchCommand(event, engine);
-    });
-
-    this.addEventListener("command", event => {
-      let target = event.originalTarget;
-      if (target.classList.contains("addengine-item")) {
-        // On success, hide the panel and tell event listeners to reshow it to
-        // show the new engine.
-        let installCallback = {
-          onSuccess: engine => {
-            this._rebuild();
-          },
-          onError(errorCode) {
-            if (errorCode != Ci.nsISearchInstallCallback.ERROR_DUPLICATE_ENGINE) {
-              // Download error is shown by the search service
-              return;
-            }
-            const kSearchBundleURI =
-              "chrome://global/locale/search/search.properties";
-            let searchBundle = Services.strings.createBundle(kSearchBundleURI);
-            let brandBundle = document.getElementById("bundle_brand");
-            let brandName = brandBundle.getString("brandShortName");
-            let title = searchBundle.GetStringFromName(
-              "error_invalid_engine_title"
-            );
-            let text = searchBundle.formatStringFromName(
-              "error_duplicate_engine_msg",
-              [brandName, target.getAttribute("uri")],
-              2
-            );
-            Services.prompt.QueryInterface(Ci.nsIPromptFactory);
-            let prompt = Services.prompt.getPrompt(
-              gBrowser.contentWindow,
-              Ci.nsIPrompt
-            );
-            prompt.QueryInterface(Ci.nsIWritablePropertyBag2);
-            prompt.setPropertyAsBool("allowTabModal", true);
-            prompt.alert(title, text);
-          },
-        };
-        Services.search.addEngine(target.getAttribute("uri"),
-                                  target.getAttribute("image"), false,
-                                  installCallback);
-      }
-      if (target.classList.contains("search-one-offs-context-open-in-new-tab")) {
-        // Select the context-clicked button so that consumers can easily
-        // tell which button was acted on.
-        this.selectedButton = this._buttonForEngine(this._contextEngine);
-        this.handleSearchCommand(event, this._contextEngine, true);
-      }
-      if (target.classList.contains("search-one-offs-context-set-default")) {
-        let currentEngine = Services.search.defaultEngine;
-
-        if (!this.getAttribute("includecurrentengine")) {
-          // Make the target button of the context menu reflect the current
-          // search engine first. Doing this as opposed to rebuilding all the
-          // one-off buttons avoids flicker.
-          let button = this._buttonForEngine(this._contextEngine);
-          button.id = this._buttonIDForEngine(currentEngine);
-          let uri = "chrome://browser/skin/search-engine-placeholder.png";
-          if (currentEngine.iconURI) {
-            uri = currentEngine.iconURI.spec;
-          }
-          button.setAttribute("image", uri);
-          button.setAttribute("tooltiptext", currentEngine.name);
-          button.engine = currentEngine;
-        }
-
-        Services.search.defaultEngine = this._contextEngine;
-      }
-    });
-
-    this.addEventListener("contextmenu", event => {
-      let target = event.originalTarget;
-      // Prevent the context menu from appearing except on the one off buttons.
-      if (!target.classList.contains("searchbar-engine-one-off-item") ||
-          target.classList.contains("dummy")) {
-        event.preventDefault();
-        return;
-      }
-      this.querySelector(".search-one-offs-context-set-default")
-          .setAttribute("disabled", target.engine == Services.search.defaultEngine);
-
-      this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
-      event.preventDefault();
-
-      this._contextEngine = target.engine;
-    });
-  }
-
-  connectedCallback() {
-    if (this.delayConnectedCallback()) {
-      return;
-    }
-
-    this.appendChild(getFragment());
+    this.container.appendChild(MozXULElement.parseXULToFragment(`
+      <deck class="search-panel-one-offs-header search-panel-header search-panel-current-input">
+        <label class="searchbar-oneoffheader-search" value="&searchWithHeader.label;"/>
+        <hbox class="search-panel-searchforwith search-panel-current-input">
+          <label value="&searchFor.label;"/>
+          <label class="searchbar-oneoffheader-searchtext search-panel-input-value" flex="1" crop="end"/>
+          <label flex="10000" value="&searchWith.label;"/>
+        </hbox>
+        <hbox class="search-panel-searchonengine search-panel-current-input">
+          <label value="&search.label;"/>
+          <label class="searchbar-oneoffheader-engine search-panel-input-value" flex="1" crop="end"/>
+          <label flex="10000" value="&searchAfter.label;"/>
+        </hbox>
+      </deck>
+      <description role="group" class="search-panel-one-offs">
+        <button class="searchbar-engine-one-off-item search-setting-button-compact" tooltiptext="&changeSearchSettings.tooltip;"/>
+      </description>
+      <vbox class="search-add-engines"/>
+      <button class="search-setting-button search-panel-header" label="&changeSearchSettings.button;"/>
+      <menupopup class="search-one-offs-context-menu">
+        <menuitem class="search-one-offs-context-open-in-new-tab" label="&searchInNewTab.label;" accesskey="&searchInNewTab.accesskey;"/>
+        <menuitem class="search-one-offs-context-set-default" label="&searchSetAsDefault.label;" accesskey="&searchSetAsDefault.accesskey;"/>
+      </menupopup>
+      `, ["chrome://browser/locale/browser.dtd"]));
 
     this._popup = null;
 
     this._textbox = null;
 
     this._textboxWidth = 0;
 
     /**
@@ -272,41 +88,82 @@ class MozSearchOneOffs extends MozXULEle
      * many engines offered by the current site.
      */
     this._addEngineMenuTimeoutMs = 200;
 
     this._addEngineMenuTimeout = null;
 
     this._addEngineMenuShouldBeOpen = false;
 
+    this.addEventListener("mousedown", this);
+    this.addEventListener("mousemove", this);
+    this.addEventListener("mouseout", this);
+    this.addEventListener("click", this);
+    this.addEventListener("command", this);
+    this.addEventListener("contextmenu", this);
+
     // Prevent popup events from the context menu from reaching the autocomplete
     // binding (or other listeners).
     let listener = aEvent => aEvent.stopPropagation();
     this.contextMenuPopup.addEventListener("popupshowing", listener);
     this.contextMenuPopup.addEventListener("popuphiding", listener);
     this.contextMenuPopup.addEventListener("popupshown", aEvent => {
       this._ignoreMouseEvents = true;
       aEvent.stopPropagation();
     });
     this.contextMenuPopup.addEventListener("popuphidden", aEvent => {
       this._ignoreMouseEvents = false;
       aEvent.stopPropagation();
     });
 
     // Add weak referenced observers to invalidate our cached list of engines.
+    this.QueryInterface = ChromeUtils.generateQI([Ci.nsIObserver, Ci.nsISupportsWeakReference]);
     Services.prefs.addObserver("browser.search.hiddenOneOffs", this, true);
     Services.obs.addObserver(this, "browser-search-engine-modified", true);
     Services.obs.addObserver(this, "browser-search-service", true);
 
     // Rebuild the buttons when the theme changes.  See bug 1357800 for
     // details.  Summary: On Linux, switching between themes can cause a row
     // of buttons to disappear.
     Services.obs.addObserver(this, "lightweight-theme-changed", true);
   }
 
+  addEventListener(...args) {
+    this.container.addEventListener(...args);
+  }
+
+  removeEventListener(...args) {
+    this.container.removeEventListener(...args);
+  }
+
+  dispatchEvent(...args) {
+    this.container.dispatchEvent(...args);
+  }
+
+  getAttribute(...args) {
+    return this.container.getAttribute(...args);
+  }
+
+  setAttribute(...args) {
+    this.container.setAttribute(...args);
+  }
+
+  querySelector(...args) {
+    return this.container.querySelector(...args);
+  }
+
+  handleEvent(event) {
+    let methodName = "_on_" + event.type;
+    if (methodName in this) {
+      this[methodName](event);
+    } else {
+      throw new Error("Unrecognized search-one-offs event: " + event.type);
+    }
+  }
+
   /**
    * Width in pixels of the one-off buttons.  49px is the min-width of
    * each search engine button, adapt this const when changing the css.
    * It's actually 48px + 1px of right border.
    */
   get buttonWidth() {
     return 49;
   }
@@ -333,41 +190,48 @@ class MozSearchOneOffs extends MozXULEle
     this._popup = val;
 
     // If the popup is already open, rebuild the one-offs now.  The
     // popup may be opening, so check that the state is not closed
     // instead of checking popupOpen.
     if (val && val.state != "closed") {
       this._rebuild();
     }
+
     return val;
   }
 
   get popup() {
     return this._popup;
   }
+
   /**
    * The textbox associated with the one-offs.  Set this to a textbox to
    * automatically keep the related one-offs UI up to date.  Otherwise you
    * can leave it null/undefined, and in that case you should update the
    * query property manually.
    */
   set textbox(val) {
     if (this._textbox) {
       this._textbox.removeEventListener("input", this);
     }
     if (val) {
       val.addEventListener("input", this);
     }
     return this._textbox = val;
   }
 
+  get style() {
+    return this.container.style;
+  }
+
   get textbox() {
     return this._textbox;
   }
+
   /**
    * The query string currently shown in the one-offs.  If the textbox
    * property is non-null, then this is automatically updated on
    * input.
    */
   set query(val) {
     this._query = val;
     if (this.popup && this.popup.popupOpen) {
@@ -461,54 +325,21 @@ class MozSearchOneOffs extends MozXULEle
       return (!currentEngineNameToIgnore ||
               name != currentEngineNameToIgnore) &&
              !hiddenList.includes(name);
     });
 
     return this._engines;
   }
 
-  /**
-   * This handles events outside the one-off buttons, like on the popup
-   * and textbox.
-   */
-  handleEvent(event) {
-    switch (event.type) {
-      case "input":
-        // Allow the consumer's input to override its value property with
-        // a oneOffSearchQuery property.  That way if the value is not
-        // actually what the user typed (e.g., it's autofilled, or it's a
-        // mozaction URI), the consumer has some way of providing it.
-        this.query = event.target.oneOffSearchQuery || event.target.value;
-        break;
-      case "popupshowing":
-        this._rebuild();
-        break;
-      case "popuphidden":
-        Services.tm.dispatchToMainThread(() => {
-          this.selectedButton = null;
-          this._contextEngine = null;
-        });
-        break;
-    }
-  }
-
   observe(aEngine, aTopic, aData) {
     // Make sure the engine list is refetched next time it's needed.
     this._engines = null;
   }
 
-  showSettings() {
-    openPreferences("paneSearch", { origin: "contentSearch" });
-
-    // If the preference tab was already selected, the panel doesn't
-    // close itself automatically.
-    this.popup.hidePopup();
-  }
-
   /**
    * Updates the parts of the UI that show the query string.
    */
   _updateAfterQueryChanged() {
     let headerSearchText = this.querySelector(".searchbar-oneoffheader-searchtext");
     headerSearchText.setAttribute("value", this.query);
     let groupText;
     let isOneOffSelected =
@@ -1192,14 +1023,217 @@ class MozSearchOneOffs extends MozXULEle
       clearTimeout(this._addEngineMenuTimeout);
     }
     this._addEngineMenuTimeout = setTimeout(() => {
       delete this._addEngineMenuTimeout;
       let button = this.querySelector(".addengine-menu-button");
       button.open = this._addEngineMenuShouldBeOpen;
     }, this._addEngineMenuTimeoutMs);
   }
+
+  // Event handlers below.
+
+  _on_mousedown(event) {
+    let target = event.originalTarget;
+    if (target.classList.contains("addengine-menu-button")) {
+      return;
+    }
+    // Required to receive click events from the buttons on Linux.
+    event.preventDefault();
+  }
+
+  _on_mousemove(event) {
+    let target = event.originalTarget;
+
+    // Handle mouseover on the add-engine menu button and its popup items.
+    if ((target.localName == "menuitem" && target.classList.contains("addengine-item")) ||
+        target.classList.contains("addengine-menu-button")) {
+      let menuButton = this.querySelector(".addengine-menu-button");
+      this._updateStateForButton(menuButton);
+      this._addEngineMenuShouldBeOpen = true;
+      this._resetAddEngineMenuTimeout();
+      return;
+    }
+
+    if (target.localName != "button") {
+      return;
+    }
+
+    // Ignore mouse events when the context menu is open.
+    if (this._ignoreMouseEvents) {
+      return;
+    }
+
+    let isOneOff =
+      target.classList.contains("searchbar-engine-one-off-item") &&
+      !target.classList.contains("dummy");
+    if (isOneOff ||
+        target.classList.contains("addengine-item") ||
+        target.classList.contains("search-setting-button")) {
+      this._updateStateForButton(target);
+    }
+  }
+
+  _on_mouseout(event) {
+    let target = event.originalTarget;
+
+    // Handle mouseout on the add-engine menu button and its popup items.
+    if ((target.localName == "menuitem" && target.classList.contains("addengine-item")) ||
+        target.classList.contains("addengine-menu-button")) {
+      this._updateStateForButton(null);
+      this._addEngineMenuShouldBeOpen = false;
+      this._resetAddEngineMenuTimeout();
+      return;
+    }
+
+    if (target.localName != "button") {
+      return;
+    }
+
+    // Don't update the mouseover state if the context menu is open.
+    if (this._ignoreMouseEvents) {
+      return;
+    }
+
+    this._updateStateForButton(null);
+  }
+
+  _on_click(event) {
+    if (event.button == 2) {
+      return; // ignore right clicks.
+    }
+
+    let button = event.originalTarget;
+    let engine = button.engine;
+
+    if (!engine) {
+      return;
+    }
+
+    // Select the clicked button so that consumers can easily tell which
+    // button was acted on.
+    this.selectedButton = button;
+    this.handleSearchCommand(event, engine);
+  }
+
+  _on_command(event) {
+    let target = event.target;
+
+    if (target == this.settingsButton ||
+        target == this.settingsButtonCompact) {
+      openPreferences("paneSearch", { origin: "contentSearch" });
+
+      // If the preference tab was already selected, the panel doesn't
+      // close itself automatically.
+      this.popup.hidePopup();
+      return;
+    }
+
+    if (target.classList.contains("addengine-item")) {
+      // On success, hide the panel and tell event listeners to reshow it to
+      // show the new engine.
+      let installCallback = {
+        onSuccess: engine => {
+          this._rebuild();
+        },
+        onError(errorCode) {
+          if (errorCode != Ci.nsISearchInstallCallback.ERROR_DUPLICATE_ENGINE) {
+            // Download error is shown by the search service
+            return;
+          }
+          const kSearchBundleURI =
+            "chrome://global/locale/search/search.properties";
+          let searchBundle = Services.strings.createBundle(kSearchBundleURI);
+          let brandBundle = document.getElementById("bundle_brand");
+          let brandName = brandBundle.getString("brandShortName");
+          let title = searchBundle.GetStringFromName(
+            "error_invalid_engine_title"
+          );
+          let text = searchBundle.formatStringFromName(
+            "error_duplicate_engine_msg",
+            [brandName, target.getAttribute("uri")],
+            2
+          );
+          Services.prompt.QueryInterface(Ci.nsIPromptFactory);
+          let prompt = Services.prompt.getPrompt(
+            gBrowser.contentWindow,
+            Ci.nsIPrompt
+          );
+          prompt.QueryInterface(Ci.nsIWritablePropertyBag2);
+          prompt.setPropertyAsBool("allowTabModal", true);
+          prompt.alert(title, text);
+        },
+      };
+      Services.search.addEngine(target.getAttribute("uri"),
+                                target.getAttribute("image"), false,
+                                installCallback);
+    }
+
+    if (target.classList.contains("search-one-offs-context-open-in-new-tab")) {
+      // Select the context-clicked button so that consumers can easily
+      // tell which button was acted on.
+      this.selectedButton = this._buttonForEngine(this._contextEngine);
+      this.handleSearchCommand(event, this._contextEngine, true);
+    }
+
+    if (target.classList.contains("search-one-offs-context-set-default")) {
+      let currentEngine = Services.search.defaultEngine;
+
+      if (!this.getAttribute("includecurrentengine")) {
+        // Make the target button of the context menu reflect the current
+        // search engine first. Doing this as opposed to rebuilding all the
+        // one-off buttons avoids flicker.
+        let button = this._buttonForEngine(this._contextEngine);
+        button.id = this._buttonIDForEngine(currentEngine);
+        let uri = "chrome://browser/skin/search-engine-placeholder.png";
+        if (currentEngine.iconURI) {
+          uri = currentEngine.iconURI.spec;
+        }
+        button.setAttribute("image", uri);
+        button.setAttribute("tooltiptext", currentEngine.name);
+        button.engine = currentEngine;
+      }
+
+      Services.search.defaultEngine = this._contextEngine;
+    }
+  }
+
+  _on_contextmenu(event) {
+    let target = event.originalTarget;
+    // Prevent the context menu from appearing except on the one off buttons.
+    if (!target.classList.contains("searchbar-engine-one-off-item") ||
+        target.classList.contains("dummy")) {
+      event.preventDefault();
+      return;
+    }
+    this.querySelector(".search-one-offs-context-set-default")
+        .setAttribute("disabled", target.engine == Services.search.defaultEngine);
+
+    this.contextMenuPopup.openPopupAtScreen(event.screenX, event.screenY, true);
+    event.preventDefault();
+
+    this._contextEngine = target.engine;
+  }
+
+  _on_input(event) {
+    // Allow the consumer's input to override its value property with
+    // a oneOffSearchQuery property.  That way if the value is not
+    // actually what the user typed (e.g., it's autofilled, or it's a
+    // mozaction URI), the consumer has some way of providing it.
+    this.query = event.target.oneOffSearchQuery || event.target.value;
+  }
+
+  _on_popupshowing() {
+    this._rebuild();
+  }
+
+  _on_popuphidden() {
+    Services.tm.dispatchToMainThread(() => {
+      this.selectedButton = null;
+      this._contextEngine = null;
+    });
+  }
 }
 
-MozXULElement.implementCustomInterface(MozSearchOneOffs, [Ci.nsIObserver, Ci.nsIWeakReference]);
-customElements.define("search-one-offs", MozSearchOneOffs);
+window.SearchOneOffs = SearchOneOffs;
 
 }
+
--- a/browser/components/search/content/search.xml
+++ b/browser/components/search/content/search.xml
@@ -372,17 +372,17 @@
     <content ignorekeys="true" level="top" consumeoutsideclicks="never">
       <xul:hbox anonid="searchbar-engine" xbl:inherits="showonlysettings"
                 class="search-panel-header search-panel-current-engine">
         <xul:image class="searchbar-engine-image" xbl:inherits="src"/>
         <xul:label anonid="searchbar-engine-name" flex="1" crop="end"
                    role="presentation"/>
       </xul:hbox>
       <xul:richlistbox anonid="richlistbox" class="autocomplete-richlistbox search-panel-tree" flex="1"/>
-      <xul:search-one-offs anonid="search-one-off-buttons" class="search-one-offs"/>
+      <xul:hbox anonid="search-one-off-buttons" class="search-one-offs"/>
     </content>
     <implementation>
       <method name="openAutocompletePopup">
         <parameter name="aInput"/>
         <parameter name="aElement"/>
         <body><![CDATA[
           // initially the panel is hidden
           // to avoid impacting startup / new window performance
@@ -468,18 +468,19 @@
               this._bundle = Services.strings.createBundle(kBundleURI);
             }
             return this._bundle;
           ]]>
         </getter>
       </property>
 
       <field name="oneOffButtons" readonly="true">
-        document.getAnonymousElementByAttribute(this, "anonid",
-                                                "search-one-off-buttons");
+        new window.SearchOneOffs(
+          document.getAnonymousElementByAttribute(this, "anonid",
+                                                  "search-one-off-buttons"));
       </field>
 
       <method name="updateHeader">
         <body><![CDATA[
           let currentEngine = Services.search.defaultEngine;
           let uri = currentEngine.iconURI;
           if (uri) {
             this.setAttribute("src", uri.spec);
--- a/browser/components/search/test/browser/browser_oneOffContextMenu.js
+++ b/browser/components/search/test/browser/browser_oneOffContextMenu.js
@@ -1,20 +1,18 @@
 "use strict";
 
 const TEST_ENGINE_NAME = "Foo";
 const TEST_ENGINE_BASENAME = "testEngine.xml";
 
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
-const oneOffElement = document.getAnonymousElementByAttribute(
-  searchPopup, "anonid", "search-one-off-buttons"
-);
-const contextMenu = oneOffElement.querySelector(".search-one-offs-context-menu");
-const oneOffButtons = oneOffElement.buttons;
-const searchInNewTabMenuItem = oneOffElement.querySelector(".search-one-offs-context-open-in-new-tab");
+const oneOffInstance = searchPopup.oneOffButtons;
+const contextMenu = oneOffInstance.querySelector(".search-one-offs-context-menu");
+const oneOffButtons = oneOffInstance.buttons;
+const searchInNewTabMenuItem = oneOffInstance.querySelector(".search-one-offs-context-open-in-new-tab");
 
 let searchbar;
 let searchIcon;
 
 add_task(async function init() {
   searchbar = await gCUITestUtils.addSearchBar();
   registerCleanupFunction(() => {
     gCUITestUtils.removeSearchBar();
--- a/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js
+++ b/browser/components/search/test/browser/browser_oneOffContextMenu_setDefault.js
@@ -4,22 +4,18 @@ const TEST_ENGINE_NAME = "Foo";
 const TEST_ENGINE_BASENAME = "testEngine.xml";
 const SEARCHBAR_BASE_ID = "searchbar-engine-one-off-item-";
 const URLBAR_BASE_ID = "urlbar-engine-one-off-item-";
 const ONEOFF_URLBAR_PREF = "browser.urlbar.oneOffSearches";
 
 const urlbar = document.getElementById("urlbar");
 const searchPopup = document.getElementById("PopupSearchAutoComplete");
 const urlbarPopup = document.getElementById("PopupAutoCompleteRichResult");
-const searchOneOffElement = document.getAnonymousElementByAttribute(
-  searchPopup, "anonid", "search-one-off-buttons"
-);
-const urlBarOneOffElement = document.getAnonymousElementByAttribute(
-  urlbarPopup, "anonid", "one-off-search-buttons"
-);
+const searchOneOff = searchPopup.oneOffButtons;
+const urlBarOneOff = urlbarPopup.oneOffSearchButtons;
 
 let originalEngine = Services.search.defaultEngine;
 
 function resetEngine() {
   Services.search.defaultEngine = originalEngine;
 }
 
 registerCleanupFunction(resetEngine);
@@ -35,20 +31,20 @@ add_task(async function init() {
 
   await promiseNewEngine(TEST_ENGINE_BASENAME, {
     setAsCurrent: false,
   });
 });
 
 add_task(async function test_searchBarChangeEngine() {
   let oneOffButton = await openPopupAndGetEngineButton(true, searchPopup,
-                                                       searchOneOffElement,
+                                                       searchOneOff,
                                                        SEARCHBAR_BASE_ID);
 
-  const setDefaultEngineMenuItem = searchOneOffElement.querySelector(
+  const setDefaultEngineMenuItem = searchOneOff.querySelector(
     ".search-one-offs-context-set-default"
   );
 
   // Click the set default engine menu item.
   let promise = promiseCurrentEngineChanged();
   EventUtils.synthesizeMouseAtCenter(setDefaultEngineMenuItem, {});
 
   // This also checks the engine correctly changed.
@@ -69,20 +65,20 @@ add_task(async function test_urlBarChang
   registerCleanupFunction(function() {
     Services.prefs.clearUserPref(ONEOFF_URLBAR_PREF);
   });
 
   // Ensure the engine is reset.
   resetEngine();
 
   let oneOffButton = await openPopupAndGetEngineButton(false, urlbarPopup,
-                                                       urlBarOneOffElement,
+                                                       urlBarOneOff,
                                                        URLBAR_BASE_ID);
 
-  const setDefaultEngineMenuItem = urlBarOneOffElement.querySelector(
+  const setDefaultEngineMenuItem = urlBarOneOff.querySelector(
     ".search-one-offs-context-set-default"
   );
 
   // Click the set default engine menu item.
   let promise = promiseCurrentEngineChanged();
   EventUtils.synthesizeMouseAtCenter(setDefaultEngineMenuItem, {});
 
   // This also checks the engine correctly changed.
@@ -123,40 +119,40 @@ function promiseCurrentEngineChanged() {
 
 /**
  * Opens the specified urlbar/search popup and gets the test engine from the
  * one-off buttons.
  *
  * @param {Boolean} isSearch true if the search popup should be opened; false
  *                           for the urlbar popup.
  * @param {Object} popup The expected popup.
- * @param {Object} oneOffElement The expected one-off-element for the popup.
+ * @param {Object} oneOffInstance The expected one-off instance for the popup.
  * @param {String} baseId The expected string for the id of the current
  *                        engine button, without the engine name.
  * @return {Object} Returns an object that represents the one off button for the
  *                          test engine.
  */
-async function openPopupAndGetEngineButton(isSearch, popup, oneOffElement, baseId) {
+async function openPopupAndGetEngineButton(isSearch, popup, oneOffInstance, baseId) {
   // Open the popup.
   let promise = promiseEvent(popup, "popupshown");
   info("Opening panel");
 
   // We have to open the popups in differnt ways.
   if (isSearch) {
     // Use the search icon to avoid hitting the network.
     EventUtils.synthesizeMouseAtCenter(searchIcon, {});
   } else {
     // There's no history at this stage, so we need to press a key.
     urlbar.focus();
     EventUtils.sendString("a");
   }
   await promise;
 
-  const contextMenu = oneOffElement.contextMenuPopup;
-  const oneOffButtons = oneOffElement.buttons;
+  const contextMenu = oneOffInstance.contextMenuPopup;
+  const oneOffButtons = oneOffInstance.buttons;
 
   // Get the one-off button for the test engine.
   let oneOffButton;
   for (let node of oneOffButtons.children) {
     if (node.engine && node.engine.name == TEST_ENGINE_NAME) {
       oneOffButton = node;
       break;
     }
--- a/browser/components/urlbar/UrlbarView.jsm
+++ b/browser/components/urlbar/UrlbarView.jsm
@@ -40,16 +40,22 @@ class UrlbarView {
       if (event.target.classList.contains("urlbarView-row-inner")) {
         event.target.toggleAttribute("overflow", false);
       }
     });
 
     this.controller.addQueryListener(this);
   }
 
+  get oneOffSearchButtons() {
+    return this._oneOffSearchButtons ||
+      (this._oneOffSearchButtons =
+         new this.window.SearchOneOffs(this.panel.querySelector(".search-one-offs")));
+  }
+
   /**
    * Opens the autocomplete results popup.
    */
   open() {
     this.panel.removeAttribute("hidden");
 
     let panelDirection = this.panel.style.direction;
     if (!panelDirection) {
@@ -61,16 +67,20 @@ class UrlbarView {
     let documentRect =
       this._getBoundsWithoutFlushing(this.document.documentElement);
     let width = documentRect.right - documentRect.left;
     this.panel.setAttribute("width", width);
 
     // Subtract two pixels for left and right borders on the panel.
     this._mainContainer.style.maxWidth = (width - 2) + "px";
 
+    // TODO: Search one off buttons are a stub right now.
+    //       We'll need to set them up properly.
+    this.oneOffSearchButtons;
+
     this.panel.openPopup(this.urlbar.textbox.closest("toolbar"), "after_end", 0, -1);
 
     this._rows.firstElementChild.toggleAttribute("selected", true);
   }
 
   /**
    * Closes the autocomplete results popup.
    */